bitspire/apps/machine/electron/preload.ts
Patrick Mulligan 64582e7fe6 feat(machine): Bolt Card (NFC) tap-to-receive on cash-in
Tap-to-receive for the buy flow, the receive counterpart to #83's
cash-out tap-to-pay. A Bolt Card only emits an lnurlw withdraw voucher
(wrong direction to deposit into it), so the tap is used as an
authenticated identity (external_id + SUN p/c) to resolve the card
wallet's lnurlp/Lightning Address; the ATM then pays an invoice for the
payout over its existing nostr transport.

- electron/lnurl-pay.ts: resolveCardInvoice() — resolve card -> pay
  target -> LUD-16/LUD-06 -> BOLT11. scanUrlToResolver() is the single
  HTTPS-today / nostr-tomorrow transport seam. 13 tests.
- IPC lnurl:pay-card (main-process HTTPS to dodge renderer CORS) +
  preload/electron.d.ts surface.
- stores/atm.ts: handleBoltCardReceive() settles via the existing
  payInvoice -> PAYMENT_RECEIVED path; the one NFC listener now routes
  the same tap by flow (cash-out pulls, cash-in receives).
- CashInView.vue: NFC status + dev tap input.
- docs/boltcard-receive-resolver.md: spec for the custom LNbits
  /boltcards/api/v1/pay/<id> resolver endpoint (omni-private side).

Card issuance is unchanged — same NDEF/keys/external_id; receive is a
server-side reading of the same tap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 00:30:13 +02:00

332 lines
13 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'),
getBootstrapPublishedAt: (): Promise<number | null> =>
ipcRenderer.invoke('state:get-bootstrap-published-at'),
markBootstrapPublished: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-bootstrap-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'),
resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'),
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),
applyOperatorCassettesConfig: (
payload: {
positions: Record<string, { denomination: number; count: number }>
},
eventCreatedAt: number
): Promise<{ applied: true } | { applied: false; reason: string }> =>
ipcRenderer.invoke('state:apply-operator-cassettes-config', payload, eventCreatedAt),
// 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.
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>
getBootstrapPublishedAt: () => Promise<number | null>
markBootstrapPublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void>
resetBootstrapGate: () => 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 }>
applyOperatorCassettesConfig: (
payload: { positions: Record<string, { denomination: number; count: number }> },
eventCreatedAt: number
) => Promise<{ applied: true } | { applied: false; reason: string }>
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
}
}
}