bitspire/apps/machine/electron/preload.ts
Padreug e8106b665a feat(machine): durable dispense-report outbox to spirekeeper (ADR-005 §2)
Every cash-out now produces one report_dispense — on success as well as
failure — and the machine does not stop sending it until spirekeeper
acknowledges it.

state.db gains a dispense_reports table (migration v13 → v14): the report
is written INSIDE recordTransaction's SQLite transaction, alongside the
transactions row, so a crash between the two cannot lose it. Rows carry
attempts / last_attempt_at / last_error / acked_at. Three IPC calls
(pending / ack / note-attempt) expose it to the renderer.

The store builds the report when a cash-out reaches complete,
dispenseFault or outOfCash: txid, payment hash, dispense_confirmed,
error / error_code / raw_code / error_class, per-denomination requested
vs dispensed vs rejected, the per-bay cassette record verbatim, and
counts_uncertain. The success report is what lets the server capture
(distribute) the settlement; the failure report is what puts a customer
on the owed-cash worklist instead of leaving the only record on the ATM.

Delivery is at-least-once: a flusher drains pending rows after each
persist, on relay (re)connect, and every 60 s, acking only on an OK reply
and backing off 30 s · 2^attempts (capped 1 h) otherwise. While
spirekeeper has not registered the RPC every send fails the same way; the
backoff keeps that quiet and the rows wait — this half ships first.

The lightning service exposes reportDispense; the function pointer is
set at all three lightning-init sites so the flusher works on every path.
2026-10-10 21:37:47 +02:00

421 lines
17 KiB
TypeScript

/**
* Electron Preload Script
*
* Exposes a secure API to the renderer process via contextBridge.
* This is the only way for the Vue app to communicate with the main process.
*/
import { contextBridge, ipcRenderer } from 'electron'
/** Mirrors state-store.CashOutHold (ADR-005 §5) — preload can't import main-process modules. */
interface CashOutHold {
reason: string
errorCode: string | null
rawCode: string | null
since: number
}
/** Mirrors state-store.PendingDispenseReport (ADR-005 §2). */
interface PendingDispenseReport {
txid: string
payload: unknown
createdAt: number
attempts: number
lastAttemptAt: number | null
lastError: string | null
}
/**
* Runtime configuration interface (public info only)
* These values are read from environment variables at runtime (not build time)
*
* SECURITY: Secrets are NOT included here. Use getAtmSecrets() instead.
*/
export interface RuntimeConfig {
relayUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
lnbitsServerPubkey: string
appId: string
machineModel: string
fiatCode: string
validatorDevice?: string
dispenserDevice?: string
cassettes?: string
allowMockFallback: boolean
operatorPubkeys: string
maintenanceMode: boolean
branding: BrandingConfig | null
}
export interface BrandingConfig {
title: string | null
theme: string | null
customColors?: Record<string, string>
customColorsDark?: Record<string, string>
logoDataUrl: string | null
logoDarkDataUrl: string | null
}
/**
* Persisted NIP-46 bunker binding (mirror of state-store's StoredBunkerBinding).
*/
export interface BunkerBindingRecord {
clientSecretHex: string
spirePubkey: string
bunkerUrl: string
seedFingerprint: string
pairedAt: number
/** LNbits transport relays from the seed (#70); absent on pre-#70 bindings. */
relays?: string[]
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
lnbitsServerPubkey?: string
}
/**
* ATM secrets — returned once by getAtmSecrets(), then empty on subsequent calls.
* The spire pairing seed (carries the one-shot connect token) plus the persisted
* bunker binding; the renderer resolves these into a signer.
*/
export interface AtmSecrets {
spireSeed: string
bunkerBinding: BunkerBindingRecord | null
}
// Expose protected methods to renderer
contextBridge.exposeInMainWorld('electronAPI', {
// Get app version
getVersion: (): Promise<string> => ipcRenderer.invoke('get-version'),
// Get runtime configuration (read from environment at runtime)
getConfig: (): Promise<RuntimeConfig> => ipcRenderer.invoke('get-config'),
// Get ATM secrets (one-shot: returns secrets once, then empty)
getAtmSecrets: (): Promise<AtmSecrets> => ipcRenderer.invoke('get-atm-secrets'),
// State persistence
loadCassettes: () => ipcRenderer.invoke('state:load-cassettes'),
setCassettes: (cassettes: { denomination: number; count: number }[]) =>
ipcRenderer.invoke('state:set-cassettes', cassettes),
getInventory: () => ipcRenderer.invoke('state:get-inventory'),
getCashbox: () => ipcRenderer.invoke('state:get-cashbox'),
recordTransaction: (tx: {
txid: string
type: 'cash_in' | 'cash_out' | 'manual_dispense'
status: 'complete' | 'dispense_error' | 'partial' | 'remediated'
fiatCents: number
sats: number
feeSats: number
feeFraction: number
exchangeRate: number
currency: string
bills: { denomination: number; count: number }[]
cassettes?: {
name: string
position: number
denomination: number
provisioned: number
dispensed: number
rejected: number
}[]
error?: string | null
}) => ipcRenderer.invoke('state:record-transaction', tx),
emptyCashbox: () => ipcRenderer.invoke('state:empty-cashbox'),
remediateTransaction: (txid: string, remediatedByTxid: string): Promise<boolean> =>
ipcRenderer.invoke('state:remediate-transaction', txid, remediatedByTxid),
// Operator-config consumer (aiolabs/lamassu-next#56)
getLastKnownConfigCreatedAt: (): Promise<number> =>
ipcRenderer.invoke('state:get-last-known-config-created-at'),
getLastStatePublishedAt: (): Promise<number | null> =>
ipcRenderer.invoke('state:get-last-state-published-at'),
getCountsUncertainSince: (): Promise<number | null> =>
ipcRenderer.invoke('state:get-counts-uncertain-since'),
markCountsUncertain: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp),
// Cash-out hold (ADR-005 §5)
getCashOutHold: (): Promise<CashOutHold | null> => ipcRenderer.invoke('state:get-cash-out-hold'),
setCashOutHold: (hold: CashOutHold): Promise<CashOutHold> =>
ipcRenderer.invoke('state:set-cash-out-hold', hold),
clearCashOutHold: (): Promise<boolean> => ipcRenderer.invoke('state:clear-cash-out-hold'),
// Dispense-report outbox (ADR-005 §2)
pendingDispenseReports: (limit?: number): Promise<PendingDispenseReport[]> =>
ipcRenderer.invoke('state:pending-dispense-reports', limit),
ackDispenseReport: (txid: string): Promise<boolean> =>
ipcRenderer.invoke('state:ack-dispense-report', txid),
noteDispenseReportAttempt: (txid: string, error: string | null): Promise<void> =>
ipcRenderer.invoke('state:note-dispense-report-attempt', txid, error),
markStatePublished: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
// Bunker binding persistence (aiolabs/bitspire#52)
saveBunkerBinding: (binding: BunkerBindingRecord): Promise<void> =>
ipcRenderer.invoke('state:save-bunker-binding', binding),
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
resetStatePublishWatermark: (): Promise<void> =>
ipcRenderer.invoke('state:reset-state-publish-watermark'),
resetForRepair: (): Promise<void> => ipcRenderer.invoke('state:reset-for-repair'),
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed,
// then relaunch so the normal boot flow pairs it.
saveSpireSeed: (seed: string): Promise<void> => ipcRenderer.invoke('state:save-spire-seed', seed),
relaunchApp: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
// Reload the renderer to re-attempt initialization (connectivity recovery).
recoverApp: (): Promise<void> => ipcRenderer.invoke('app:recover'),
// Bolt Card cash-out: pull payment for the current invoice from a tapped card.
lnurlWithdraw: (args: {
lnurlw: string
bolt11: string
amountMsat?: number
}): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('lnurl:withdraw', args),
// Bolt Card cash-in: resolve a tapped card + amount to a BOLT11 to pay.
resolveCardInvoice: (args: {
lnurlw: string
amountMsat: number
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
ipcRenderer.invoke('lnurl:pay-card', args),
// Bolt Card tap-to-enter: one verified session per tap (balance + fiat +
// the withdraw/pay second steps reused at Complete). Payload shapes are
// declared in src/types/electron.d.ts (CardSession).
openCardSession: (args: { lnurlw: string }): Promise<unknown> =>
ipcRenderer.invoke('lnurl:open-card-session', args),
withdrawWithSession: (args: {
withdraw: { callback: string; k1: string; minWithdrawable?: number; maxWithdrawable?: number }
bolt11: string
amountMsat?: number
}): Promise<{ ok: boolean; reason?: string }> =>
ipcRenderer.invoke('lnurl:withdraw-session', args),
resolveSessionInvoice: (args: {
pay: { callback: string; minSendable?: number; maxSendable?: number; metadata?: string }
amountMsat: number
}): Promise<{ ok: boolean; bolt11?: string; reason?: string }> =>
ipcRenderer.invoke('lnurl:pay-session', args),
applyOperatorCassetteOps: (
ops: {
id: string
at: number
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
position: number
bills?: number
count?: number
denomination?: number
}[]
): Promise<{
applied: string[]
rejected: { id: string; reason: string }[]
}> => ipcRenderer.invoke('state:apply-operator-cassette-ops', ops),
getAppliedOpIds: (limit?: number): Promise<string[]> =>
ipcRenderer.invoke('state:get-applied-op-ids', limit),
getCassetteStateSeq: (): Promise<number> => ipcRenderer.invoke('state:get-cassette-state-seq'),
// Operator-fees consumer (aiolabs/lamassu-next#57)
getFeeConfig: (): Promise<{
cashInFeeFraction: number
cashOutFeeFraction: number
schemaVersion: number
eventCreatedAt: number
appliedAt: number
} | null> => ipcRenderer.invoke('state:get-fee-config'),
getLastKnownFeeConfigCreatedAt: (): Promise<number> =>
ipcRenderer.invoke('state:get-last-known-fee-config-created-at'),
applyFeeConfig: (
payload: {
cashInFeeFraction: number
cashOutFeeFraction: number
schemaVersion: number
},
eventCreatedAt: number
): Promise<{ applied: true } | { applied: false; reason: string }> =>
ipcRenderer.invoke('state:apply-fee-config', payload, eventCreatedAt),
// Support pages
getSupportPages: (): Promise<{ id: string; title: string; content: string }[]> =>
ipcRenderer.invoke('support:get-pages'),
// HAL hardware (runs in main process, exposed via IPC)
halInit: (config: any): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('hal:init', config),
halDispense: (amounts: any): Promise<any> => ipcRenderer.invoke('hal:dispense', amounts),
halEnableValidator: (): Promise<void> => ipcRenderer.invoke('hal:enable-validator'),
halDisableValidator: (): Promise<void> => ipcRenderer.invoke('hal:disable-validator'),
halStackBill: (): Promise<void> => ipcRenderer.invoke('hal:stack-bill'),
halRejectBill: (): Promise<void> => ipcRenderer.invoke('hal:reject-bill'),
halGetInventory: (): Promise<Record<number, number>> => ipcRenderer.invoke('hal:get-inventory'),
halReloadCassettes: (
cassettes: { position: number; denomination: number; count?: number }[]
): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('hal:reload-cassettes', cassettes),
halCleanup: (): Promise<void> => ipcRenderer.invoke('hal:cleanup'),
// HAL event listeners (main process → renderer)
onHalBillRead: (callback: (denomination: number) => void) => {
ipcRenderer.on('hal:bill-read', (_event, denomination) => callback(denomination))
},
onHalBillInserted: (callback: (denomination: number) => void) => {
ipcRenderer.on('hal:bill-inserted', (_event, denomination) => callback(denomination))
},
onHalBillRejected: (callback: (reason: string) => void) => {
ipcRenderer.on('hal:bill-rejected', (_event, reason) => callback(reason))
},
onHalError: (callback: (error: string) => void) => {
ipcRenderer.on('hal:error', (_event, error) => callback(error))
},
// Bolt Card reader (main process → renderer). removeAllListeners first: a
// renderer reload re-runs this, and a duplicated card-tap listener would
// trigger the LNURL-withdraw twice.
// The main process changed the cassettes table (an operator-command dispense,
// boot seeding). The renderer reloads its inventory and republishes state.
onCassettesChanged: (callback: () => void) => {
ipcRenderer.removeAllListeners('cassettes:changed')
ipcRenderer.on('cassettes:changed', () => callback())
},
onNfcCardTapped: (callback: (lnurlw: string) => void) => {
ipcRenderer.removeAllListeners('nfc:card-tapped')
ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw))
},
onNfcStatus: (
callback: (status: { state: string; reader?: string; message?: string }) => void
) => {
ipcRenderer.removeAllListeners('nfc:status')
ipcRenderer.on('nfc:status', (_event, status) => callback(status))
},
// Watchdog heartbeat (main process → renderer → main process)
onWatchdogPing: (callback: () => void) => {
ipcRenderer.on('watchdog:ping', () => callback())
},
watchdogPong: (): Promise<void> => ipcRenderer.invoke('watchdog:pong'),
// Platform info
platform: process.platform,
})
// Type declaration for the exposed API
declare global {
interface Window {
electronAPI: {
getVersion: () => Promise<string>
getConfig: () => Promise<RuntimeConfig>
getAtmSecrets: () => Promise<AtmSecrets>
loadCassettes: () => Promise<{ denomination: number; count: number; position: number }[]>
setCassettes: (cassettes: { denomination: number; count: number }[]) => Promise<void>
getInventory: () => Promise<Record<number, number>>
getCashbox: () => Promise<{
totalBills: number
totalFiatCents: number
lastEmptiedAt: number | null
}>
recordTransaction: (tx: {
txid: string
type: 'cash_in' | 'cash_out'
status: 'complete' | 'dispense_error' | 'partial'
fiatCents: number
sats: number
feeSats: number
feeFraction: number
exchangeRate: number
currency: string
bills: { denomination: number; count: number }[]
cassettes?: {
name: string
position: number
denomination: number
provisioned: number
dispensed: number
rejected: number
}[]
error?: string | null
}) => Promise<void>
emptyCashbox: () => Promise<void>
remediateTransaction: (txid: string, remediatedByTxid: string) => Promise<boolean>
getLastKnownConfigCreatedAt: () => Promise<number>
getLastStatePublishedAt: () => Promise<number | null>
getCountsUncertainSince: () => Promise<number | null>
markCountsUncertain: (unixTimestamp: number) => Promise<void>
getCashOutHold: () => Promise<CashOutHold | null>
setCashOutHold: (hold: CashOutHold) => Promise<CashOutHold>
clearCashOutHold: () => Promise<boolean>
pendingDispenseReports: (limit?: number) => Promise<PendingDispenseReport[]>
ackDispenseReport: (txid: string) => Promise<boolean>
noteDispenseReportAttempt: (txid: string, error: string | null) => Promise<void>
markStatePublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void>
resetStatePublishWatermark: () => Promise<void>
resetForRepair: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void>
recoverApp: () => Promise<void>
lnurlWithdraw: (args: {
lnurlw: string
bolt11: string
amountMsat?: number
}) => Promise<{ ok: boolean; reason?: string }>
resolveCardInvoice: (args: {
lnurlw: string
amountMsat: number
}) => Promise<{ ok: boolean; bolt11?: string; reason?: string }>
applyOperatorCassetteOps: (
ops: {
id: string
at: number
type: 'refill' | 'empty' | 'recount' | 'set_denomination'
position: number
bills?: number
count?: number
denomination?: number
}[]
) => Promise<{
applied: string[]
rejected: { id: string; reason: string }[]
}>
getAppliedOpIds: (limit?: number) => Promise<string[]>
getCassetteStateSeq: () => Promise<number>
getFeeConfig: () => Promise<{
cashInFeeFraction: number
cashOutFeeFraction: number
schemaVersion: number
eventCreatedAt: number
appliedAt: number
} | null>
getLastKnownFeeConfigCreatedAt: () => Promise<number>
applyFeeConfig: (
payload: {
cashInFeeFraction: number
cashOutFeeFraction: number
schemaVersion: number
},
eventCreatedAt: number
) => Promise<{ applied: true } | { applied: false; reason: string }>
getSupportPages: () => Promise<{ id: string; title: string; content: string }[]>
// HAL hardware IPC
halInit: (config: any) => Promise<{ success: boolean; error?: string }>
halDispense: (amounts: any) => Promise<any>
halEnableValidator: () => Promise<void>
halDisableValidator: () => Promise<void>
halStackBill: () => Promise<void>
halRejectBill: () => Promise<void>
halGetInventory: () => Promise<Record<number, number>>
halReloadCassettes: (
cassettes: { position: number; denomination: number; count?: number }[]
) => Promise<{ ok: boolean; error?: string }>
halCleanup: () => Promise<void>
onHalBillRead: (callback: (denomination: number) => void) => void
onHalBillInserted: (callback: (denomination: number) => void) => void
onHalBillRejected: (callback: (reason: string) => void) => void
onHalError: (callback: (error: string) => void) => void
onNfcCardTapped: (callback: (lnurlw: string) => void) => void
onNfcStatus: (
callback: (status: { state: string; reader?: string; message?: string }) => void
) => void
onWatchdogPing: (callback: () => void) => void
watchdogPong: () => Promise<void>
platform: NodeJS.Platform
}
}
}