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.
421 lines
17 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|