The machine now owns its bay counts outright. The operator publishes what it did — a refill in notes added, an empty, a recount, a denomination change — and this process applies it to the total it already holds. Both sides used to write the same value over a transport that never tells a writer it lost. Addressable events order by created_at at second granularity with ties broken on event id, and a relay returns OK for an event it then discards, so a dashboard form loaded before a dispense silently discarded that dispense and neither side could detect it. A value with one writer cannot be clobbered. Schema v13 adds cassette_ops, the dedup ledger. A delta applied twice is wrong and addressable events are re-delivered on every reconnect, so the operator mints an id per operation and this table records the ones applied. That also retires the created_at watermark on this path: it was the only replay defence under absolute counts, but it drops an out-of-order event whole, operations included, where per-op ids let the unseen ones through and no-op the rest. A window is applied oldest-first by `at`, ties broken by id, in one transaction with the count mutation. A recount then a refill is not the same as the reverse, and a crash mid-apply must roll back to a coherent count rather than a partial one. A malformed op or one naming a bay this machine does not have is neither applied nor recorded, so it stays pending on the operator's dashboard. That is the honest outcome. Recording it as applied would stop the noise by telling the operator their refill landed. The state document gains applied_ops, seq and schema_version. applied_ops is the acknowledgement leg — echoing the ids back is the only way the operator can tell an operation that landed from one merely sent. seq is bumped on every local count change from any cause, so a reader can reject a regression without trusting either clock.
385 lines
15 KiB
TypeScript
385 lines
15 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'
|
|
|
|
/**
|
|
* 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),
|
|
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>
|
|
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
|
|
}
|
|
}
|
|
}
|