diff --git a/apps/machine/electron/hal-service.ts b/apps/machine/electron/hal-service.ts index 1259c77..636d7f2 100644 --- a/apps/machine/electron/hal-service.ts +++ b/apps/machine/electron/hal-service.ts @@ -6,7 +6,8 @@ * so it's available at runtime (unlike src/ which is only for Vite). */ -import type { BillValidator, BillDispenser } from '@bitSpire/hal' +import type { BillValidator, BillDispenser, DispenseErrorClass } from '@bitSpire/hal' +import { isDispenseError } from '@bitSpire/hal' export interface CassetteConfig { /** @@ -48,8 +49,20 @@ export interface ValidatorCallbacks { export interface DispenseResult { bills: { denomination: number; dispensed: number; rejected: number }[] - dispensed: boolean + /** + * Σ(denomination × dispensed) === Σ(denomination × requested). Computed + * here on VALUE (ADR-005 §3) — never a driver boolean. Only this routes + * the state machine to `complete`. + */ + dispenseConfirmed: boolean + /** Human message when not confirmed */ error?: string + /** The error's NAME — 'F56DispenseError', 'InsufficientInventory', … */ + errorCode?: string + /** Driver-native code, e.g. '78 42' */ + rawCode?: string + /** terminal | recoverable | inventory — see @bitSpire/hal error-codes */ + errorClass?: DispenseErrorClass cassettes?: { name: string position: number @@ -277,6 +290,8 @@ export async function initializeHal(config: HalConfig): Promise { notes[i] = (notes[i] ?? 0) + take remaining -= take } + // Nothing has been asked of the hardware in either refusal below: + // errorClass 'inventory' routes to outOfCash, not the fault screen. if (!matched) { return { bills: amounts.map((a) => ({ @@ -284,8 +299,10 @@ export async function initializeHal(config: HalConfig): Promise { dispensed: 0, rejected: 0, })), - dispensed: false, + dispenseConfirmed: false, error: `No cassette loaded with denomination: ${denomination}`, + errorCode: 'NoCassetteForDenomination', + errorClass: 'inventory', } } if (remaining > 0) { @@ -295,8 +312,10 @@ export async function initializeHal(config: HalConfig): Promise { dispensed: 0, rejected: 0, })), - dispensed: false, + dispenseConfirmed: false, error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`, + errorCode: 'InsufficientInventory', + errorClass: 'inventory', } } } @@ -344,11 +363,44 @@ export async function initializeHal(config: HalConfig): Promise { } const bills = Array.from(billsByDenom.values()) - const totalRequested = amounts.reduce((s, a) => s + a.count, 0) + // ADR-005 §3: confirmation is VALUE equality — what left the bays is + // worth exactly what was asked — not a count, and not the driver's + // opinion. lamassu computed the same thing (`tx.fiat.eq(Σ denomination + // × dispensed)`); our previous count-based check was only equivalent + // while every bay dispensed its own denomination. + const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0) + const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0) const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0) + const dispenseConfirmed = requestedValue === dispensedValue if (result.error) { - return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message } + const e = result.error + const info = isDispenseError(e) + ? { errorCode: e.errorCode, rawCode: e.rawCode, errorClass: e.errorClass, human: e.human } + : { + // Unreachable by type (drivers always tag), kept as a defensive + // fallback for a driver that slips an untagged Error through. + errorCode: (e as Error).name || 'DispenseError', + rawCode: undefined, + errorClass: 'terminal' as const, + human: (e as Error).message, + } + console.error( + `[HAL] Dispense error ${info.errorCode}${info.rawCode ? ` ${info.rawCode}` : ''} (${info.errorClass}): ${info.human} — requested ${requestedValue}, dispensed ${dispensedValue}` + ) + // A dispensed value of zero WITH an error is not evidence that nothing + // left the bay — a note stopped in the transport completes neither + // counter (sintra, 2026-10-09). The store reads this combination and + // flags counts unverified; we just report faithfully here. + return { + bills, + cassettes: cassetteResults, + dispenseConfirmed: false, + error: info.human, + errorCode: info.errorCode, + rawCode: info.rawCode, + errorClass: info.errorClass, + } } // Wait for customer to take bills @@ -357,7 +409,20 @@ export async function initializeHal(config: HalConfig): Promise { console.log('[HAL] Bills removed by customer') } - return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed } + if (!dispenseConfirmed) { + // Short with no hardware error — the dispenser simply gave less. + console.warn(`[HAL] Dispense short with no error: requested ${requestedValue}, dispensed ${dispensedValue}`) + return { + bills, + cassettes: cassetteResults, + dispenseConfirmed: false, + error: `Dispensed ${dispensedValue} of ${requestedValue} with no dispenser error`, + errorCode: 'DispenseShort', + errorClass: 'inventory', + } + } + + return { bills, cassettes: cassetteResults, dispenseConfirmed: true } }, /** diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 7a87ac2..68b9f88 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -27,6 +27,13 @@ import { getCountsUncertainSince, getLastStatePublishedAt, markCountsUncertain, + getCashOutHold, + setCashOutHold, + clearCashOutHold, + type CashOutHold, + pendingDispenseReports, + markDispenseReportAcked, + noteDispenseReportAttempt, markStatePublished, resetStatePublishWatermark, resetForRepair, @@ -565,6 +572,31 @@ ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCount ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => { markCountsUncertain(unixTimestamp) }) +// Cash-out hold (ADR-005 §5) +ipcMain.handle('state:get-cash-out-hold', (): CashOutHold | null => getCashOutHold()) +ipcMain.handle('state:set-cash-out-hold', (_event, hold: CashOutHold): CashOutHold => { + if (!hold || typeof hold.reason !== 'string' || typeof hold.since !== 'number') { + throw new Error('Invalid cash-out hold') + } + return setCashOutHold(hold) +}) +ipcMain.handle('state:clear-cash-out-hold', (): boolean => clearCashOutHold()) + +// Dispense-report outbox (ADR-005 §2) — at-least-once to spirekeeper +ipcMain.handle('state:pending-dispense-reports', (_event, limit?: number) => + pendingDispenseReports(typeof limit === 'number' ? limit : 20) +) +ipcMain.handle('state:ack-dispense-report', (_event, txid: string): boolean => { + if (typeof txid !== 'string' || !txid) throw new Error('Invalid txid') + return markDispenseReportAcked(txid) +}) +ipcMain.handle( + 'state:note-dispense-report-attempt', + (_event, txid: string, error: string | null): void => { + if (typeof txid !== 'string' || !txid) throw new Error('Invalid txid') + noteDispenseReportAttempt(txid, typeof error === 'string' ? error.slice(0, 512) : null) + } +) ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => { markStatePublished(unixTimestamp) }) @@ -841,7 +873,7 @@ function startCommandPoller(): void { recordTransaction({ txid, type: 'manual_dispense', - status: result.dispensed ? 'complete' : 'dispense_error', + status: result.dispenseConfirmed ? 'complete' : 'dispense_error', fiatCents: totalFiatCents, sats: 0, feeSats: 0, @@ -860,7 +892,7 @@ function startCommandPoller(): void { // Only remediate the original tx if ALL requested bills were dispensed let refRemediated = false - if (parsed.ref_txid && result.dispensed) { + if (parsed.ref_txid && result.dispenseConfirmed) { refRemediated = remediateTransaction(parsed.ref_txid, txid) } @@ -868,7 +900,13 @@ function startCommandPoller(): void { cmd.id, JSON.stringify({ txid, - dispensed: result.dispensed, + // Wire key kept as `dispensed` — spirekeeper's command poller + // reads it. Value is the ADR-005 value-equality confirmation. + dispensed: result.dispenseConfirmed, + dispense_confirmed: result.dispenseConfirmed, + error_code: result.errorCode, + raw_code: result.rawCode, + error_class: result.errorClass, ref_remediated: refRemediated, error: result.error, }) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 8bebc13..bc75046 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -7,6 +7,24 @@ 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) @@ -114,6 +132,18 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('state:get-counts-uncertain-since'), markCountsUncertain: (unixTimestamp: number): Promise => ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp), + // Cash-out hold (ADR-005 §5) + getCashOutHold: (): Promise => ipcRenderer.invoke('state:get-cash-out-hold'), + setCashOutHold: (hold: CashOutHold): Promise => + ipcRenderer.invoke('state:set-cash-out-hold', hold), + clearCashOutHold: (): Promise => ipcRenderer.invoke('state:clear-cash-out-hold'), + // Dispense-report outbox (ADR-005 §2) + pendingDispenseReports: (limit?: number): Promise => + ipcRenderer.invoke('state:pending-dispense-reports', limit), + ackDispenseReport: (txid: string): Promise => + ipcRenderer.invoke('state:ack-dispense-report', txid), + noteDispenseReportAttempt: (txid: string, error: string | null): Promise => + ipcRenderer.invoke('state:note-dispense-report-attempt', txid, error), markStatePublished: (unixTimestamp: number): Promise => ipcRenderer.invoke('state:mark-state-published', unixTimestamp), @@ -307,6 +337,12 @@ declare global { getLastStatePublishedAt: () => Promise getCountsUncertainSince: () => Promise markCountsUncertain: (unixTimestamp: number) => Promise + getCashOutHold: () => Promise + setCashOutHold: (hold: CashOutHold) => Promise + clearCashOutHold: () => Promise + pendingDispenseReports: (limit?: number) => Promise + ackDispenseReport: (txid: string) => Promise + noteDispenseReportAttempt: (txid: string, error: string | null) => Promise markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index fa6f17f..67afd49 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -10,12 +10,13 @@ */ import Database from 'better-sqlite3' +import type { DispenseReportBody } from '@bitSpire/lnbits' import path from 'node:path' import fs from 'node:fs' let db: Database.Database | null = null -const SCHEMA_VERSION = '13' +const SCHEMA_VERSION = '14' function getDbPath(): string { const prodDir = '/var/lib/bitspire' @@ -136,6 +137,16 @@ export function initDatabase(dbPath?: string): void { relays TEXT, lnbits_server_pubkey TEXT ); + + CREATE TABLE IF NOT EXISTS dispense_reports ( + txid TEXT PRIMARY KEY REFERENCES transactions(txid), + payload TEXT NOT NULL, + created_at INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_attempt_at INTEGER, + last_error TEXT, + acked_at INTEGER + ); `) // Seed meta + cashbox if first run, or run migrations @@ -418,6 +429,32 @@ export function initDatabase(dbPath?: string): void { existing.value = '13' } + if (existing && existing.value === '13') { + // Migration v13 → v14: the dispense-report outbox (ADR-005 §2). + // + // Every cash-out's outcome — success or failure — is reported to + // spirekeeper over a kind-21000 RPC, and that report is what lets the + // server capture (distribute) the settlement or surface a customer who + // is owed cash. A relay gives the publisher no delivery guarantee, so the + // report is written here, in the SAME transaction as the transactions + // row, and resent until the server acknowledges it. Idempotent on txid + // server-side; `attempts` / `last_error` drive the resend backoff. + db.exec(` + CREATE TABLE IF NOT EXISTS dispense_reports ( + txid TEXT PRIMARY KEY REFERENCES transactions(txid), + payload TEXT NOT NULL, + created_at INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_attempt_at INTEGER, + last_error TEXT, + acked_at INTEGER + ); + `) + db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('14', 'schema_version') + console.log('[StateStore] Migrated schema v13 → v14 (added dispense_reports outbox)') + existing.value = '14' + } + // Defensive: a fresh install at SCHEMA_VERSION skips all migrations. // Seed the operator-config meta rows if they're missing (idempotent). const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)') @@ -517,6 +554,133 @@ export function clearCountsUncertain(): void { ).run('countsUncertainSince', '') } +// --------------------------------------------------------------------------- +// Cash-out hold (ADR-005 §5) +// --------------------------------------------------------------------------- +// +// A terminal dispenser fault latches cash-out off. The latch is machine +// health, so it lives in `meta` (one JSON value) and survives restarts; the +// renderer restores it into the state machine on boot and the operator +// releases it with a `recount` or `resume_cash_out` op. Re-initialising the +// dispenser never clears it — re-init does not move a stuck note. + +export interface CashOutHold { + reason: string + errorCode: string | null + rawCode: string | null + /** unix seconds of the FIRST fault — kept across repeat faults */ + since: number +} + +export function getCashOutHold(): CashOutHold | null { + if (!db) throw new Error('Database not initialized') + const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cashOutHeld') as + | { value: string } + | undefined + if (!row || row.value === '') return null + try { + const parsed = JSON.parse(row.value) as Partial + if (typeof parsed.since !== 'number' || typeof parsed.reason !== 'string') return null + return { + reason: parsed.reason, + errorCode: typeof parsed.errorCode === 'string' ? parsed.errorCode : null, + rawCode: typeof parsed.rawCode === 'string' ? parsed.rawCode : null, + since: parsed.since, + } + } catch { + return null + } +} + +/** Latch cash-out off. Idempotent: an existing hold (and its `since`) is kept. */ +export function setCashOutHold(hold: CashOutHold): CashOutHold { + if (!db) throw new Error('Database not initialized') + const existing = getCashOutHold() + if (existing) return existing + db.prepare( + 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ).run('cashOutHeld', JSON.stringify(hold)) + console.warn( + `[StateStore] Cash-out HELD: ${hold.errorCode ?? 'fault'}${hold.rawCode ? ` ${hold.rawCode}` : ''} — ${hold.reason}` + ) + return hold +} + +/** Release the latch — an operator has cleared the machine. */ +export function clearCashOutHold(): boolean { + if (!db) throw new Error('Database not initialized') + const had = getCashOutHold() !== null + db.prepare( + 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ).run('cashOutHeld', '') + if (had) console.log('[StateStore] Cash-out hold released') + return had +} + +// --------------------------------------------------------------------------- +// Dispense-report outbox (ADR-005 §2) +// --------------------------------------------------------------------------- + +export interface PendingDispenseReport { + txid: string + payload: DispenseReportBody + createdAt: number + attempts: number + lastAttemptAt: number | null + lastError: string | null +} + +/** Unacknowledged reports, oldest first. The renderer applies the backoff. */ +export function pendingDispenseReports(limit = 20): PendingDispenseReport[] { + if (!db) throw new Error('Database not initialized') + const rows = db + .prepare( + 'SELECT txid, payload, created_at, attempts, last_attempt_at, last_error FROM dispense_reports WHERE acked_at IS NULL ORDER BY created_at ASC LIMIT ?' + ) + .all(limit) as Array<{ + txid: string + payload: string + created_at: number + attempts: number + last_attempt_at: number | null + last_error: string | null + }> + const out: PendingDispenseReport[] = [] + for (const r of rows) { + try { + out.push({ + txid: r.txid, + payload: JSON.parse(r.payload) as DispenseReportBody, + createdAt: r.created_at, + attempts: r.attempts, + lastAttemptAt: r.last_attempt_at, + lastError: r.last_error, + }) + } catch { + console.error('[StateStore] dispense_reports row has unparseable payload:', r.txid) + } + } + return out +} + +/** The server acknowledged this report. Returns whether a row changed. */ +export function markDispenseReportAcked(txid: string): boolean { + if (!db) throw new Error('Database not initialized') + const res = db + .prepare('UPDATE dispense_reports SET acked_at = ? WHERE txid = ? AND acked_at IS NULL') + .run(Date.now(), txid) + if (res.changes > 0) console.log('[StateStore] Dispense report acked:', txid) + return res.changes > 0 +} + +/** A send was attempted and did not get an OK. Drives the resend backoff. */ +export function noteDispenseReportAttempt(txid: string, error: string | null): void { + if (!db) throw new Error('Database not initialized') + db.prepare( + 'UPDATE dispense_reports SET attempts = attempts + 1, last_attempt_at = ?, last_error = ? WHERE txid = ?' + ).run(Date.now(), error, txid) +} + /** * A counter bumped on every local change to a bay count, from any cause. * @@ -851,7 +1015,12 @@ export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult { // A recount is an operator opening the bay and counting it, which is // exactly what resolves an unverified count. Nothing else does: a refill // adds to a number still known to be wrong. - if (sawRecount) upsertMeta.run('countsUncertainSince', '') + if (sawRecount) { + upsertMeta.run('countsUncertainSince', '') + // ADR-005 §5: a recount is an operator at the open machine — the one + // gesture that also releases a cash-out hold. + upsertMeta.run('cashOutHeld', '') + } })() console.log( @@ -1173,6 +1342,11 @@ interface TransactionInput { rejected: number }[] error?: string | null + /** + * ADR-005 §2: dispense outcome to queue for spirekeeper. Inserted in the + * same transaction as the row so a crash between them cannot lose it. + */ + report?: DispenseReportBody } /** @@ -1194,6 +1368,12 @@ export function recordTransaction(tx: TransactionInput): void { const insertBill = db.prepare( 'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)' ) + // Outbox row (ADR-005 §2). REPLACE: a re-record of the same txid (should not + // happen, but a crash-replay could) refreshes the payload and resets the + // delivery state rather than failing the whole transaction. + const insertReport = db.prepare( + 'INSERT OR REPLACE INTO dispense_reports (txid, payload, created_at, attempts, last_attempt_at, last_error, acked_at) VALUES (?, ?, ?, 0, NULL, NULL, NULL)' + ) const insertCassetteBill = db.prepare( 'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)' ) @@ -1226,6 +1406,10 @@ export function recordTransaction(tx: TransactionInput): void { insertBill.run(t.txid, bill.denomination, bill.count) } + if (t.report) { + insertReport.run(t.txid, JSON.stringify(t.report), Date.now()) + } + // Insert per-cassette detail when available if (t.cassettes) { for (const c of t.cassettes) { diff --git a/apps/machine/src/composables/useAvailabilityBroadcast.ts b/apps/machine/src/composables/useAvailabilityBroadcast.ts index 20517e9..ce9c2e9 100644 --- a/apps/machine/src/composables/useAvailabilityBroadcast.ts +++ b/apps/machine/src/composables/useAvailabilityBroadcast.ts @@ -29,8 +29,14 @@ interface UseAvailabilityBroadcastOptions { signer: Signer /** Reactive inventory: denomination -> count */ inventory: Ref> - /** Reactive Lightning.Pub balance in sats (null = unknown) */ + /** Reactive wallet balance in sats (null = unknown) */ balanceSats: Ref + /** + * ADR-005 §5: cash-out is latched off after a terminal dispenser fault. + * A machine with full bays and a jammed transport must not advertise + * cash-out — that is exactly what sintra did for an hour on 2026-10-09. + */ + cashOutHeld?: Ref /** Fiat currency code */ fiatCode: string /** Machine model */ @@ -38,7 +44,7 @@ interface UseAvailabilityBroadcastOptions { } export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) { - const { nostrClient, signer, inventory, balanceSats, fiatCode, model } = options + const { nostrClient, signer, inventory, balanceSats, cashOutHeld, fiatCode, model } = options let lastSnapshot: AvailabilitySnapshot | null = null @@ -53,7 +59,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption function computeSnapshot(): AvailabilitySnapshot { const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0) return { - cashOut: totalBills > 0, + cashOut: totalBills > 0 && !(cashOutHeld?.value ?? false), cashIn: (balanceSats.value ?? 0) > 0, cashLevel: computeCashLevel(), } @@ -101,7 +107,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption // Watch reactive sources watch( - [inventory, balanceSats], + cashOutHeld ? [inventory, balanceSats, cashOutHeld] : [inventory, balanceSats], () => { debouncedPublish() }, diff --git a/apps/machine/src/services/hal.ts b/apps/machine/src/services/hal.ts index 723bbc3..c793aec 100644 --- a/apps/machine/src/services/hal.ts +++ b/apps/machine/src/services/hal.ts @@ -147,8 +147,10 @@ export async function initializeHalServices(config: HalConfig): Promise s + a.count, 0) + // ADR-005 §3: confirmation on VALUE. Same contract as electron/hal-service.ts. + const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0) + const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0) const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0) + const dispenseConfirmed = requestedValue === dispensedValue if (result.error) { - return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message } + const e = result.error + return { + bills, + cassettes: cassetteResults, + dispenseConfirmed: false, + error: e.human ?? e.message, + errorCode: e.errorCode ?? e.name, + rawCode: e.rawCode, + errorClass: e.errorClass ?? 'terminal', + } } // Wait for customer to take bills (only if bills were dispensed) @@ -195,7 +209,18 @@ export async function initializeHalServices(config: HalConfig): Promise { diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 8140791..10d2566 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -14,7 +14,7 @@ import { NostrClient, type Signer } from '@bitSpire/nostr-client' import { resolveSigner } from './signer-resolver.js' -import { LnbitsClient } from '@bitSpire/lnbits' +import { LnbitsClient, type DispenseReportBody, type DispenseReportAck } from '@bitSpire/lnbits' import { CLINKClient } from '@bitSpire/clink' import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink' import type { ATMServices, ATMContext } from '@bitSpire/state-machine' @@ -223,6 +223,8 @@ export interface LightningBackend { } interface LightningServices { + /** ADR-005 §2: send one cash-out's dispense outcome to spirekeeper (outbox-driven). */ + reportDispense: (body: DispenseReportBody) => Promise nostrClient: NostrClient lightningPub: LightningBackend clink: CLINKClient @@ -686,6 +688,11 @@ export async function initializeLightningServices(options?: { signer, operatorPubkeys: CONFIG.operatorPubkeys, atmServices, + /** + * ADR-005 §2: send one cash-out's dispense outcome to spirekeeper. The + * store keeps these in a durable outbox and calls this until it resolves. + */ + reportDispense: (body: DispenseReportBody) => lnbits.reportDispense(body), onOfferRequest: (callback: OfferRequestCallback) => { offerRequestCallback = callback }, @@ -742,7 +749,7 @@ export function createATMServices( subId: string | null /** Preimage seen before a consumer attached; replayed on attach. */ settled: string | null - consumer: ((preimage: string) => void) | null + consumer: ((preimage: string, paymentHash: string) => void) | null poll: ReturnType | null released: boolean } @@ -765,7 +772,7 @@ export function createATMServices( watch.settled = preimage stopInvoiceWatchPoll(watch) console.log(`[ATM Service] Invoice paid (${via})!`) - watch.consumer?.(preimage) + watch.consumer?.(preimage, watch.paymentHash) } function startInvoiceWatchPoll(watch: InvoiceWatch): void { @@ -1106,7 +1113,7 @@ export function createATMServices( dispensed: a.count, rejected: 0, })), - dispensed: true, + dispenseConfirmed: true, } }, @@ -1206,7 +1213,10 @@ export function createATMServices( * Watch a BOLT11 invoice for payment via LNbits subscribe_payments * push, filtered by payment_hash. Returns a cleanup function. */ - watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => { + watchInvoice: ( + invoice: string, + callback: (preimage: string, paymentHash?: string) => void + ): (() => void) => { if (!invoice.toLowerCase().startsWith('ln')) { console.error('[ATM Service] Invalid invoice format - expected BOLT11') return () => {} @@ -1221,7 +1231,7 @@ export function createATMServices( // on a push that has already come and gone. if (armed.settled) { const preimage = armed.settled - queueMicrotask(() => callback(preimage)) + queueMicrotask(() => callback(preimage, armed.paymentHash)) } return () => releaseInvoiceWatch(invoice) } @@ -1244,7 +1254,7 @@ export function createATMServices( const late = invoiceWatches.get(invoice) if (!late || cancelled) return late.consumer = callback - if (late.settled) callback(late.settled) + if (late.settled) callback(late.settled, late.paymentHash) } catch (e) { console.error('[ATM Service] LNbits watchInvoice failed:', e) } diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts index f81b5d5..e9f38b9 100644 --- a/apps/machine/src/services/operator-config.ts +++ b/apps/machine/src/services/operator-config.ts @@ -44,7 +44,7 @@ const KIND_NIP78 = 30078 /** The wire schema this machine speaks. Operations, not counts (ADR-004). */ const CASSETTE_SCHEMA_VERSION = 2 -/** One operator-authored operation, as it arrives on the wire. */ +/** One operator-authored cassette operation, as it arrives on the wire. */ type CassetteOp = { id: string at: number @@ -55,6 +55,18 @@ type CassetteOp = { denomination?: number } +/** + * ADR-005 §5: the operator releases a cash-out hold without touching a bay + * count. Rides the same operator event as the cassette ops (same id/at shape) + * but is NOT a cassette op: it never reaches `applyOperatorCassetteOps`, which + * would reject the type. Honoured only when stamped AFTER the hold began, so + * a re-delivered resume from before a fresh fault cannot clear that fault. + * A `recount` releases the hold too — it is the same "operator at the open + * machine" gesture and already clears counts-uncertain. + */ +type ResumeCashOutOp = { id: string; at: number; type: 'resume_cash_out' } +type OperatorOp = CassetteOp | ResumeCashOutOp + /** Accept operator events stamped up to this many seconds in the future. */ const MAX_FUTURE_SKEW_S = 60 @@ -85,6 +97,12 @@ export interface OperatorConfigServiceConfig { operatorPubkeys: string[] /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */ machineId?: string + /** + * ADR-005 §5: called when an operator op (recount, resume_cash_out) has + * released a persisted cash-out hold, so the store can lift the state + * machine's latch. The store wires this to `CASH_OUT_RELEASED`. + */ + onCashOutHoldReleased?: () => void } export interface OperatorConfigService { @@ -222,7 +240,28 @@ async function handleOperatorConfigEvent( console.error('[OperatorConfig] Payload missing `ops` array — dropped') return } - const ops = parsed.ops as CassetteOp[] + const allOps = parsed.ops as OperatorOp[] + + // 4b. ADR-005 §5 — split out resume_cash_out before the cassette apply. + // Release only if a resume is stamped after the hold began; an idempotent + // re-delivery of an older resume must not clear a newer fault. + const holdBefore = await api.getCashOutHold() + const resumeOps = allOps.filter( + (o): o is ResumeCashOutOp => !!o && o.type === 'resume_cash_out' + ) + const ops = allOps.filter((o): o is CassetteOp => !!o && o.type !== 'resume_cash_out') + if (holdBefore && resumeOps.some((o) => typeof o.at === 'number' && o.at > holdBefore.since)) { + await api.clearCashOutHold() + console.log( + `[OperatorConfig] Cash-out hold released by operator resume op ` + + `(held since ${holdBefore.since}, ${resumeOps.length} resume op(s))` + ) + } else if (resumeOps.length > 0) { + console.log( + `[OperatorConfig] ${resumeOps.length} resume_cash_out op(s) ignored — ` + + (holdBefore ? 'all stamped before the current hold began' : 'no hold in place') + ) + } // 5. Apply the ones we have not seen, in one transaction with the sequence // bump. No `created_at` watermark: each op carries an operator-minted id @@ -230,10 +269,19 @@ async function handleOperatorConfigEvent( // no-op on its own merits. The watermark would be strictly weaker and // actively harmful — an event arriving out of order can still carry an // operation this machine has never seen. - const result = await api.applyOperatorCassetteOps(ops) + const result = ops.length + ? await api.applyOperatorCassetteOps(ops) + : { applied: [] as string[], rejected: [] as { id: string; reason: string }[] } for (const bad of result.rejected) { console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`) } + + // A recount (applied in the store, which also clears the hold) or the resume + // above may have released the latch: tell the store so the state machine + // lifts its guard. The republishes below carry the cleared state up. + if (holdBefore && (await api.getCashOutHold()) === null) { + cfg.onCashOutHoldReleased?.() + } if (result.applied.length === 0) { console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`) // Still republish: the operator learns from our applied_ops echo that @@ -318,6 +366,15 @@ async function publishCassettesState( applied_ops: appliedOps, } if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince + // ADR-005 §5 — additive, same contract as counts_uncertain_since: an old + // consumer ignores it. When present, this machine is refusing cash-out + // until an operator recount or resume_cash_out op. + const hold = await api.getCashOutHold() + if (hold) { + payload.cash_out_held_since = hold.since + payload.cash_out_held_reason = hold.reason + payload.cash_out_held_code = hold.rawCode ?? hold.errorCode ?? null + } const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload)) // Force the stamp strictly above our last one. Addressable events are ordered diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 1a13349..4ed884f 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -1,4 +1,5 @@ import { defineStore } from 'pinia' +import type { DispenseReportBody } from '@bitSpire/lnbits' import { ref, computed, watch } from 'vue' import { createATMMachine, @@ -9,6 +10,7 @@ import { type SnapshotFrom, type ATMMachine, type AccessRole, + type DispenseCashResult, } from '@bitSpire/state-machine' import type { AccessControlConfig, CardSession } from '@/types/electron' import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning' @@ -126,7 +128,7 @@ async function handleManagementCommand( await persistTransaction({ txid, type: 'manual_dispense', - status: result.dispensed ? 'complete' : 'dispense_error', + status: result.dispenseConfirmed ? 'complete' : 'dispense_error', fiatCents: totalFiatCents, sats: 0, feeSats: 0, @@ -141,7 +143,7 @@ async function handleManagementCommand( // Only remediate the original tx if ALL requested bills were dispensed let refRemediated = false - if (request.ref_txid && result.dispensed && isElectron && window.electronAPI) { + if (request.ref_txid && result.dispenseConfirmed && isElectron && window.electronAPI) { refRemediated = await window.electronAPI.remediateTransaction(request.ref_txid, txid) if (refRemediated) { console.log('[ATM] Remediated failed tx:', request.ref_txid) @@ -192,6 +194,62 @@ async function loadInventoryFromDb(): Promise | null> { /** * Persist a completed transaction to SQLite via IPC. */ +/** + * ADR-005 §2: the dispense outcome spirekeeper captures on. Built from the + * machine context at the moment the terminal state is entered; written in the + * same SQLite transaction as the transactions row (see TransactionRecord.report). + * `requested` per denomination comes from the sale, `dispensed`/`rejected` from + * the hardware report; `cassettes` is the per-bay record verbatim. + */ +function buildDispenseReport( + ctx: ATMContext, + dr: DispenseCashResult | null, + countsUncertain: boolean +): DispenseReportBody { + const requestedByDenom = new Map() + for (const a of ctx.dispenseAmounts) { + requestedByDenom.set(a.denomination, (requestedByDenom.get(a.denomination) ?? 0) + a.count) + } + const seen = new Set() + const bills: DispenseReportBody['bills'] = [] + for (const b of dr?.bills ?? []) { + seen.add(b.denomination) + bills.push({ + denomination: b.denomination, + requested: requestedByDenom.get(b.denomination) ?? 0, + dispensed: b.dispensed, + rejected: b.rejected, + }) + } + // A denomination that was asked for but never appears in the report (the + // dispenser threw before reporting) still needs a row: requested, zero out. + for (const [denomination, requested] of requestedByDenom) { + if (!seen.has(denomination)) bills.push({ denomination, requested, dispensed: 0, rejected: 0 }) + } + return { + txid: ctx.txid ?? '', + payment_hash: ctx.paymentHash, + tx_type: 'cash_out', + dispense_confirmed: dr?.dispenseConfirmed === true, + error: dr?.error ?? ctx.error ?? null, + error_code: dr?.errorCode ?? (ctx.error && !dr ? 'DispenseThrew' : null), + raw_code: dr?.rawCode ?? null, + error_class: dr?.errorClass ?? (ctx.error && !dr ? 'terminal' : null), + fiat_cents: ctx.fiatCents, + currency: ctx.currency, + bills, + cassettes: (dr?.cassettes ?? []).map((c) => ({ + position: c.position, + denomination: c.denomination, + provisioned: c.provisioned, + dispensed: c.dispensed, + rejected: c.rejected, + })), + counts_uncertain: countsUncertain, + at: Math.floor(Date.now() / 1000), + } +} + async function persistTransaction(tx: TransactionRecord): Promise { if (isElectron && window.electronAPI) { try { @@ -254,7 +312,7 @@ const mockServices: ATMServices = { dispensed: a.count, rejected: 0, })), - dispensed: true, + dispenseConfirmed: true, } }, @@ -401,6 +459,12 @@ export const useAtmStore = defineStore('atm', () => { ) let stopBalanceWatch: (() => void) | null = null let pricePollingInterval: ReturnType | null = null + // ADR-005 §2 — dispense-report outbox. The function pointer is set at every + // lightning-init site; the flusher drains state.db's dispense_reports table + // to spirekeeper with backoff until each row is acked. + let reportDispenseFn: ((body: DispenseReportBody) => Promise) | null = null + let dispenseReportFlushInterval: ReturnType | null = null + let dispenseReportFlushing = false // Store reference to ATM services for direct calls let atmServicesRef: ATMServices | null = null @@ -618,13 +682,31 @@ export const useAtmStore = defineStore('atm', () => { send({ type: 'CASH_DISPENSED' }) } - // Record failed cash-out dispenses (sats debited but cash not dispensed) - if (currentNested === 'dispenseError' && prevNestedState !== 'dispenseError') { + // ADR-005 §5: persist the cash-out hold the moment the machine sets it, + // and republish the cassette state so the operator sees it. The hold is + // machine health, not transaction state — it must survive a restart. + const heldNow = newSnapshot.context.cashOutHeld + const heldBefore = prevSnapshot?.context.cashOutHeld ?? null + if (heldNow && !heldBefore && isElectron && window.electronAPI) { + void window.electronAPI + .setCashOutHold(heldNow) + .then(() => operatorConfigSvc?.publishCassettesState()) + .catch((e) => console.error('[ATM] Could not persist cash-out hold:', e)) + } + + // Record a cash-out that did not confirm (ADR-005 §3/§4). Both terminal + // states mean the customer has PAID and received less than they paid + // for — dispenseFault because the dispenser reported an error, outOfCash + // because it reported none (an inventory refusal, or simply short). + // Either way the row is dispense_error / partial and the server learns + // of it; the difference is only the customer screen and the latch. + const isDispenseTerminal = currentNested === 'dispenseFault' || currentNested === 'outOfCash' + const wasDispenseTerminal = prevNestedState === 'dispenseFault' || prevNestedState === 'outOfCash' + if (isDispenseTerminal && !wasDispenseTerminal) { const ctx = newSnapshot.context if (ctx.txid) { const dr = ctx.dispenseResult - // Determine status from dispense result (if available) let status: 'dispense_error' | 'partial' = 'dispense_error' let bills: { denomination: number; count: number }[] = [] @@ -634,6 +716,23 @@ export const useAtmStore = defineStore('atm', () => { bills = dr.bills .filter((b) => b.dispensed > 0) .map((b) => ({ denomination: b.denomination, count: b.dispensed })) + + // ADR-005 §3 — the deviation from both bitSpire-before and lamassu: + // a report of ZERO dispensed that arrives WITH a hardware error is + // not evidence that nothing left the bay. A note that stops in the + // transport path completes neither the dispensed nor the rejected + // counter (sintra, 2026-10-09: bay read 66, held 65, one in the + // transport). Flag the counts unverified so the next recount is + // what resolves them, instead of trusting a zero. + if (totalDispensed === 0 && dr.error && dr.errorClass !== 'inventory') { + console.error( + `[ATM] Dispense reported 0 notes WITH an error (${dr.errorCode ?? 'unknown'}` + + `${dr.rawCode ? ` ${dr.rawCode}` : ''}) — bay counts are unverified (txid=${ctx.txid})` + ) + void window.electronAPI + ?.markCountsUncertain(Math.floor(Date.now() / 1000)) + .catch((e) => console.warn('[ATM] Could not flag counts unverified:', e)) + } } else { // The dispenser threw, or the dispense timed out, so there is no // per-bay report. Bills may well have reached the customer, and @@ -649,6 +748,11 @@ export const useAtmStore = defineStore('atm', () => { .catch((e) => console.warn('[ATM] Could not flag counts unverified:', e)) } + const countsUncertain = + !dr || + (dr.bills.reduce((sum, b) => sum + b.dispensed, 0) === 0 && + !!dr.error && + dr.errorClass !== 'inventory') persistTransaction({ txid: ctx.txid, type: 'cash_out', @@ -662,10 +766,14 @@ export const useAtmStore = defineStore('atm', () => { bills, cassettes: dr?.cassettes, error: dr?.error ?? ctx.error, + // ADR-005 §2 — queued in the same SQLite transaction as the row. + report: buildDispenseReport(ctx, dr, countsUncertain), }) .then(() => reloadPersistedInventory()) - // Republish cassette state — a partial dispense changed counts. + // Republish cassette state — a partial dispense changed counts, and + // the payload now carries the hold / unverified flags. .then(() => operatorConfigSvc?.publishCassettesState()) + .then(() => flushDispenseReports()) } } @@ -695,11 +803,15 @@ export const useAtmStore = defineStore('atm', () => { bills, cassettes: dr?.cassettes, error: dr?.error, + // ADR-005 §2 — the SUCCESS report is what lets spirekeeper capture + // (distribute) the settlement. Cash-out only; cash-in has no dispense. + ...(isCashInTx ? {} : { report: buildDispenseReport(ctx, dr, false) }), }) .then(() => reloadPersistedInventory()) // Republish cassette state after a cash-out dispense (counts // decremented); harmless no-op echo for a cash-in complete. .then(() => (isCashInTx ? undefined : operatorConfigSvc?.publishCassettesState())) + .then(() => (isCashInTx ? undefined : flushDispenseReports())) } } @@ -742,6 +854,23 @@ export const useAtmStore = defineStore('atm', () => { setupNfcListener() setupCassettesChangedListener() console.log('[ATM] State machine initialized') + + // ADR-005 §5: a cash-out hold persisted by a previous run gates cash-out + // before any dispense — a restart must not quietly put a jammed machine + // back in service. Released only by an operator recount / resume op. + if (isElectron && window.electronAPI) { + void window.electronAPI + .getCashOutHold() + .then((hold) => { + if (!hold) return + console.warn( + `[ATM] Cash-out HELD since ${new Date(hold.since * 1000).toISOString()} ` + + `(${hold.errorCode ?? 'fault'}${hold.rawCode ? ` ${hold.rawCode}` : ''}): ${hold.reason}` + ) + send({ type: 'CASH_OUT_HELD', hold }) + }) + .catch((e) => console.warn('[ATM] Could not read cash-out hold:', e)) + } } // ── Bolt Card cash-out (NFC tap-to-pay) ─────────────────────────────────── @@ -1031,6 +1160,8 @@ export const useAtmStore = defineStore('atm', () => { try { const services = await initializeLightningServices({ strict: !allowMockFallback.value }) + reportDispenseFn = services.reportDispense + startDispenseReportFlusher() useLiveServices.value = true connectionStatus.value = 'connected' console.log('[ATM] Connected to Lightning.Pub!') @@ -1043,6 +1174,8 @@ export const useAtmStore = defineStore('atm', () => { services.nostrClient.on('connect', () => { connectionStatus.value = 'connected' console.log('[ATM] Relay reconnected') + // A report queued during the outage goes now, not at the next tick. + void flushDispenseReports() }) // Store references to clients for direct operations @@ -1135,6 +1268,7 @@ export const useAtmStore = defineStore('atm', () => { nostrClient: services.nostrClient, signer: services.signer, operatorPubkeys: services.operatorPubkeys, + onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }), }) // Start operator-fees consumer (aiolabs/lamassu-next#57) — subscribes @@ -1329,6 +1463,8 @@ export const useAtmStore = defineStore('atm', () => { // Initialize Lightning services const lightning = await initializeLightningServices({ strict: !allowMockFallback.value }) + reportDispenseFn = lightning.reportDispense + startDispenseReportFlusher() useLiveServices.value = true connectionStatus.value = 'connected' lightningPub.value = lightning.lightningPub @@ -1461,6 +1597,7 @@ export const useAtmStore = defineStore('atm', () => { nostrClient: lightning.nostrClient, signer: lightning.signer, operatorPubkeys: lightning.operatorPubkeys, + onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }), }) // Operator-fees consumer (aiolabs/lamassu-next#57) @@ -1621,6 +1758,8 @@ export const useAtmStore = defineStore('atm', () => { // Initialize Lightning services const lightning = await initializeLightningServices({ strict: !allowMockFallback.value }) + reportDispenseFn = lightning.reportDispense + startDispenseReportFlusher() useLiveServices.value = true connectionStatus.value = 'connected' lightningPub.value = lightning.lightningPub @@ -1797,6 +1936,7 @@ export const useAtmStore = defineStore('atm', () => { nostrClient: lightning.nostrClient, signer: lightning.signer, operatorPubkeys: lightning.operatorPubkeys, + onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }), }) // Operator-fees consumer (aiolabs/lamassu-next#57) @@ -1899,6 +2039,11 @@ export const useAtmStore = defineStore('atm', () => { send({ type: 'SELECT_CASH_IN' }) } + /** Customer dismisses the dispense-fault screen ("I've saved this reference"). */ + function acknowledgeFault() { + send({ type: 'ACKNOWLEDGE_FAULT' }) + } + function selectCashOut() { settlementError.value = null send({ type: 'SELECT_CASH_OUT' }) @@ -1983,7 +2128,59 @@ export const useAtmStore = defineStore('atm', () => { pricePollingInterval = setInterval(poll, 30_000) } + /** + * Drain the dispense-report outbox (ADR-005 §2). At-least-once: a row is + * acked only on an OK reply; anything else bumps `attempts` and the row is + * retried after an exponential backoff (30 s · 2^attempts, capped at 1 h). + * While spirekeeper has not registered `report_dispense` every send fails + * the same way — the backoff keeps that from being noisy, and the rows wait. + * Triggers: right after each persist, on relay (re)connect, every 60 s. + */ + async function flushDispenseReports(): Promise { + if (!isElectron || !window.electronAPI || !reportDispenseFn) return + if (dispenseReportFlushing) return + dispenseReportFlushing = true + try { + const pending = await window.electronAPI.pendingDispenseReports(20) + const now = Date.now() + for (const row of pending) { + const backoffMs = Math.min(30_000 * 2 ** row.attempts, 3_600_000) + if (row.lastAttemptAt && row.lastAttemptAt + backoffMs > now) continue + try { + await reportDispenseFn(row.payload as DispenseReportBody) + await window.electronAPI.ackDispenseReport(row.txid) + console.log(`[ATM] Dispense report delivered: ${row.txid}`) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + await window.electronAPI.noteDispenseReportAttempt(row.txid, msg) + console.warn( + `[ATM] Dispense report ${row.txid} not delivered (attempt ${row.attempts + 1}): ${msg}` + ) + } + } + } catch (e) { + console.warn('[ATM] Dispense-report flush failed:', e) + } finally { + dispenseReportFlushing = false + } + } + + function startDispenseReportFlusher() { + if (dispenseReportFlushInterval) return + dispenseReportFlushInterval = setInterval(() => void flushDispenseReports(), 60_000) + void flushDispenseReports() + } + + function stopDispenseReportFlusher() { + if (dispenseReportFlushInterval) { + clearInterval(dispenseReportFlushInterval) + dispenseReportFlushInterval = null + } + } + function stopPricePolling() { + // Both are store-lifetime intervals; whoever stops one stops the other. + stopDispenseReportFlusher() if (pricePollingInterval) { clearInterval(pricePollingInterval) pricePollingInterval = null @@ -2004,6 +2201,8 @@ export const useAtmStore = defineStore('atm', () => { signer, inventory: persistedInventory, balanceSats, + // ADR-005 §5: a latched machine must not advertise cash-out. + cashOutHeld: computed(() => snapshot.value?.context.cashOutHeld != null), fiatCode: fiatCode.value, model, }) @@ -2069,6 +2268,7 @@ export const useAtmStore = defineStore('atm', () => { send, selectCashIn, selectCashOut, + acknowledgeFault, cancel, insertBill, finishInserting, diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 8e4092e..f3b4db5 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -150,6 +150,33 @@ declare global { /** When the bay counts became unverified (a dispense that reported nothing), or null. */ getCountsUncertainSince: () => Promise markCountsUncertain: (unixTimestamp: number) => Promise + // Cash-out hold (ADR-005 §5) + getCashOutHold: () => Promise<{ + reason: string + errorCode: string | null + rawCode: string | null + since: number + } | null> + setCashOutHold: (hold: { + reason: string + errorCode: string | null + rawCode: string | null + since: number + }) => Promise<{ reason: string; errorCode: string | null; rawCode: string | null; since: number }> + clearCashOutHold: () => Promise + // Dispense-report outbox (ADR-005 §2) + pendingDispenseReports: (limit?: number) => Promise< + Array<{ + txid: string + payload: unknown + createdAt: number + attempts: number + lastAttemptAt: number | null + lastError: string | null + }> + > + ackDispenseReport: (txid: string) => Promise + noteDispenseReportAttempt: (txid: string, error: string | null) => Promise markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise diff --git a/apps/machine/src/types/state.ts b/apps/machine/src/types/state.ts index 7d0d9c3..9b780c3 100644 --- a/apps/machine/src/types/state.ts +++ b/apps/machine/src/types/state.ts @@ -34,6 +34,12 @@ export interface TransactionRecord { }[] error?: string | null remediatedBy?: string | null + /** + * ADR-005 §2: the dispense outcome to queue for spirekeeper, written in + * the same SQLite transaction as the row so a crash between the two cannot + * lose it. Cash-out only. Shape is @bitSpire/lnbits DispenseReportBody. + */ + report?: import('@bitSpire/lnbits').DispenseReportBody } export interface ATMAvailability { diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue index 896f165..8fb0bd1 100644 --- a/apps/machine/src/views/CashOutView.vue +++ b/apps/machine/src/views/CashOutView.vue @@ -63,13 +63,19 @@ watch( const nestedState = computed(() => atmStore.nestedState) const context = computed(() => atmStore.context) -// Dispense error 30s countdown +// Terminal-screen countdowns (ADR-005 §4). The machine owns the real timers +// (DISPENSE_FAULT_TIMEOUT 120 s, DISPENSE_ERROR_TIMEOUT 30 s); this mirrors +// them for display only. +const TERMINAL_SECONDS: Record = { dispenseFault: 120, outOfCash: 30 } const dispenseErrorCountdown = ref(30) let countdownTimer: ReturnType | null = null watch(nestedState, (newState, oldState) => { - if (newState === 'dispenseError' && oldState !== 'dispenseError') { - dispenseErrorCountdown.value = 30 + const entering = typeof newState === 'string' && newState in TERMINAL_SECONDS + const leaving = typeof oldState === 'string' && oldState in TERMINAL_SECONDS + if (entering && newState !== oldState) { + if (countdownTimer) clearInterval(countdownTimer) + dispenseErrorCountdown.value = TERMINAL_SECONDS[newState as string] ?? 30 countdownTimer = setInterval(() => { dispenseErrorCountdown.value-- if (dispenseErrorCountdown.value <= 0 && countdownTimer) { @@ -77,12 +83,21 @@ watch(nestedState, (newState, oldState) => { countdownTimer = null } }, 1000) - } else if (oldState === 'dispenseError' && countdownTimer) { + } else if (leaving && !entering && countdownTimer) { clearInterval(countdownTimer) countdownTimer = null } }) +function acknowledgeFault() { + atmStore.acknowledgeFault() +} + +const faultTime = computed(() => { + const t = context.value?.startedAt + return t ? new Date(t).toLocaleString() : '' +}) + // Available denominations from inventory const availableDenominations = computed(() => { if (!context.value?.inventory) return [] @@ -535,63 +550,92 @@ function formatFiat(cents: number): string { - +
- -
+
⚠️
-

Dispense Error

-

- {{ context?.error || 'Cash could not be dispensed' }} +

+ {{ nestedState === 'dispenseFault' ? 'Dispenser fault' : 'Could not dispense' }} +

+

+ Your payment went through. The cash below could not be dispensed.

- -
+
+
+ You paid + {{ atmStore.fiatSymbol }}{{ ((context?.fiatCents ?? 0) / 100).toFixed(2) }} + ({{ (context?.satsAmount ?? 0).toLocaleString() }} sats) +
{{ atmStore.fiatSymbol }}{{ bill.denomination }}{{ atmStore.fiatSymbol }}{{ bill.denomination }} notes {{ bill.dispensed }} dispensed - - ({{ bill.rejected }} rejected) -
-

- Please contact support with the transaction ID below. +

+ The operator has been notified and holds a record of this transaction. + Keep this reference — photograph it or write it down. +

+

+ Technical detail: {{ context.error }}

- +
+ + +

Returning to start in {{ dispenseErrorCountdown }}s

- -
- -
- +
+ +

Transaction

{{ context.txid }}

+ +

{{ faultTime }}

diff --git a/apps/machine/src/views/IdleView.vue b/apps/machine/src/views/IdleView.vue index c88da60..0cdc884 100644 --- a/apps/machine/src/views/IdleView.vue +++ b/apps/machine/src/views/IdleView.vue @@ -121,16 +121,32 @@ function handleCashOut() { > - +
diff --git a/docs/adr/005-cash-out-dispense-outcome.md b/docs/adr/005-cash-out-dispense-outcome.md index 58a3b1b..22bc03a 100644 --- a/docs/adr/005-cash-out-dispense-outcome.md +++ b/docs/adr/005-cash-out-dispense-outcome.md @@ -195,10 +195,16 @@ instead of a clean ledger. Two distinct terminal states replace the single `dispenseError`: -- **`outOfCash`** — the request could not be met from inventory and the dispenser reported - **no error**. Nothing was charged beyond what was dispensed. +- **`outOfCash`** — the request could not be met and the dispenser reported **no error** + (an inventory refusal, or simply short). In the cash-out flow this state is reached *after* + payment, so the customer **has paid** and is owed the shortfall exactly as below; the + difference is the cause — no hardware fault, so the machine stays in service and nothing + latches. (An earlier draft said "nothing was charged beyond what was dispensed"; that is + only true of the inventory check *before* payment, which already prevents the sale.) - **`dispenseFault`** — the dispenser reported an error. The customer **has paid** and is - owed the shortfall. + owed the shortfall, and a `terminal` class also latches cash-out off (Decision 5). + +Both screens therefore show the same evidence; the heading and the latch differ. `dispenseFault` shows: the amount paid, the amount dispensed (per denomination, as now), the txid as QR (as now) **and as text**, the first 12 characters of the payment hash, the time, diff --git a/packages/hal/src/dispensers/error-codes.ts b/packages/hal/src/dispensers/error-codes.ts new file mode 100644 index 0000000..8da360f --- /dev/null +++ b/packages/hal/src/dispensers/error-codes.ts @@ -0,0 +1,60 @@ +/** + * Dispense error taxonomy shared by every dispenser driver (ADR-005 §7). + * + * Three fields travel with a failed dispense, mirroring the shape + * lamassu-server kept on cash_out_txs — `error` (human), `error_code` + * (the error's NAME), plus the boolean the caller computes on value: + * + * errorCode the family, e.g. 'F56DispenseError' — stable, machine-readable + * rawCode the driver-native code, e.g. '78 42' — for the decode table + * errorClass how the caller should route it (below) + * human what an operator will find when they open the machine + * + * errorClass decides what the state machine does next: + * + * terminal the transport path is compromised (jam, motor, diverter, + * sensor, comm timeout). Retrying from another bay would jam + * too, and re-initialising does not move a stuck note. The + * machine latches cash-out off until an operator clears it. + * recoverable this bay or this note (pick failure, length/thickness + * reject, bill-end). The customer-facing fault screen still + * shows — they have paid — but the machine stays in service. + * inventory nothing was asked of the hardware: the request could not be + * met from the bays. Not a fault; routes to the out-of-cash + * screen, and nothing was charged beyond what was dispensed. + * + * No code here is borrowed from another layer's vocabulary. lamassu tagged + * every F56 fault with statusCode 570, which its server read as "insufficient + * funds" — a jam told the operator to refill a cassette that was not empty. + */ + +export type DispenseErrorClass = 'terminal' | 'recoverable' | 'inventory' + +export interface DispenseErrorInfo { + errorCode: string + rawCode?: string + errorClass: DispenseErrorClass + human: string +} + +/** An Error carrying the taxonomy. Drivers return these from `dispense()`. */ +export interface DispenseError extends Error, DispenseErrorInfo {} + +/** Stamp the taxonomy onto an existing Error without losing its stack. */ +export function tagDispenseError(error: Error, info: DispenseErrorInfo): DispenseError { + const tagged = error as DispenseError + tagged.name = info.errorCode + tagged.errorCode = info.errorCode + tagged.rawCode = info.rawCode + tagged.errorClass = info.errorClass + tagged.human = info.human + return tagged +} + +export function isDispenseError(err: unknown): err is DispenseError { + return ( + err instanceof Error && + typeof (err as Partial).errorCode === 'string' && + typeof (err as Partial).errorClass === 'string' + ) +} diff --git a/packages/hal/src/dispensers/f56/__tests__/bills.test.ts b/packages/hal/src/dispensers/f56/__tests__/bills.test.ts new file mode 100644 index 0000000..1ed588e --- /dev/null +++ b/packages/hal/src/dispensers/f56/__tests__/bills.test.ts @@ -0,0 +1,64 @@ +/** + * The F56 bill table is a list of [hi, lo] accept windows in millimetres per + * denomination, sent to the BDU at initialise. It was carried byte for byte + * from lamassu with nothing over it, and a wrong GTQ window produced a + * production fault (5/5 notes rejected, error 82 00) for weeks. These tests + * are the "no value table without a test" rule from ADR-005 finding 10. + */ +import { describe, it, expect } from 'vitest' +import { bills } from '../bills.js' + +/** + * Every quetzal, dollar and lempira note is 156 × 67 mm. HNL is in lamassu's + * 37-currency table but not (yet) in ours — include it only if present so the + * invariant holds the day it is added. + */ +const SAME_PHYSICAL_NOTE = (['USD', 'GTQ', 'HNL'] as const).filter((c) => c in bills) + +describe('F56 bill table', () => { + const currencies = Object.keys(bills) + + it('has entries', () => { + expect(currencies.length).toBeGreaterThan(0) + }) + + it.each(currencies)('%s: every window is [hi, lo] with hi > lo and a plausible note length', (cur) => { + const data = bills[cur]! + expect(typeof data.thickness).toBe('number') + expect(typeof data.polymer).toBe('boolean') + for (const [denom, window] of Object.entries(data.lengths)) { + const [hi, lo] = window + expect(hi, `${cur} ${denom} hi`).toBeGreaterThan(lo) + // Real banknotes are roughly 110–180 mm long; a window outside that + // means a typo, not a note. + expect(lo, `${cur} ${denom} lo`).toBeGreaterThanOrEqual(100) + expect(hi, `${cur} ${denom} hi`).toBeLessThanOrEqual(190) + // A window narrower than ±5 rejects real notes on sensor noise; wider + // than ±15 stops catching offset double-picks. + expect(hi - lo, `${cur} ${denom} width`).toBeGreaterThanOrEqual(10) + expect(hi - lo, `${cur} ${denom} width`).toBeLessThanOrEqual(30) + } + }) + + it('GTQ, USD and HNL — physically the same 156 mm note — share one window', () => { + const windows = SAME_PHYSICAL_NOTE.map((cur) => { + const lengths = bills[cur]!.lengths + const all = Object.values(lengths).map(([hi, lo]) => `${hi}-${lo}`) + return { cur, distinct: [...new Set(all)] } + }) + for (const w of windows) { + expect(w.distinct, `${w.cur} has one window for all denominations`).toHaveLength(1) + } + const usd = windows.find((w) => w.cur === 'USD')!.distinct[0] + for (const w of windows) { + expect(w.distinct[0], `${w.cur} matches USD (lamassu b1cc3622)`).toBe(usd) + } + }) + + it('the 156 mm window is 146–166 (±10)', () => { + const [hi, lo] = bills.USD!.lengths[20]! + expect(hi).toBe(166) + expect(lo).toBe(146) + expect((hi + lo) / 2).toBe(156) + }) +}) diff --git a/packages/hal/src/dispensers/f56/__tests__/error-codes.test.ts b/packages/hal/src/dispensers/f56/__tests__/error-codes.test.ts new file mode 100644 index 0000000..f7d7375 --- /dev/null +++ b/packages/hal/src/dispensers/f56/__tests__/error-codes.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { decodeF56Error, listF56ErrorCodes, normaliseF56Code, F56_ERROR_CODE } from '../error-codes.js' + +describe('F56 error-code decode (ADR-005 §7)', () => { + it("decodes sintra's exit jam as terminal", () => { + const info = decodeF56Error('78 42') + expect(info.errorCode).toBe(F56_ERROR_CODE) + expect(info.rawCode).toBe('78 42') + expect(info.errorClass).toBe('terminal') + expect(info.human).toMatch(/cassette exit/i) + }) + + it("decodes the Tejo's long-bill reject as recoverable", () => { + const info = decodeF56Error('82 00') + expect(info.errorClass).toBe('recoverable') + expect(info.human).toMatch(/length/i) + }) + + it('decodes parameterised families by first byte', () => { + expect(decodeF56Error('85 03').errorClass).toBe('recoverable') + expect(decodeF56Error('85 03').human).toMatch(/another safe/i) + expect(decodeF56Error('B5 01').errorClass).toBe('terminal') + expect(decodeF56Error('b5 7f').errorClass).toBe('terminal') + }) + + it('fails SAFE on an unknown code: terminal, named, code preserved', () => { + const info = decodeF56Error('99 99') + expect(info.errorCode).toBe(F56_ERROR_CODE) + expect(info.errorClass).toBe('terminal') + expect(info.rawCode).toBe('99 99') + expect(info.human).toContain('99 99') + }) + + it('treats a missing frame (serial timeout) as terminal', () => { + const info = decodeF56Error(undefined) + expect(info.errorClass).toBe('terminal') + expect(info.rawCode).toBeUndefined() + }) + + it('normalises spelling variants to "XX YY"', () => { + expect(normaliseF56Code('7842')).toBe('78 42') + expect(normaliseF56Code('78-42')).toBe('78 42') + expect(normaliseF56Code('78 42')).toBe('78 42') + expect(normaliseF56Code('b5 01')).toBe('B5 01') + expect(decodeF56Error('7842')).toEqual(decodeF56Error('78 42')) + }) + + it('never borrows another layer\'s code (the lamassu 570 lesson)', () => { + for (const row of listF56ErrorCodes()) { + expect(row).not.toHaveProperty('statusCode') + } + expect(decodeF56Error('78 42')).not.toHaveProperty('statusCode') + }) + + it('lists every known code for the operator glossary', () => { + const codes = listF56ErrorCodes().map((r) => r.code) + expect(codes).toContain('78 42') + expect(codes).toContain('82 00') + expect(codes).toContain('85 ..') + expect(codes).toContain('B5 ..') + for (const row of listF56ErrorCodes()) { + expect(['terminal', 'recoverable', 'inventory']).toContain(row.errorClass) + expect(row.human.length).toBeGreaterThan(10) + } + }) +}) diff --git a/packages/hal/src/dispensers/f56/error-codes.ts b/packages/hal/src/dispensers/f56/error-codes.ts new file mode 100644 index 0000000..2b6af4d --- /dev/null +++ b/packages/hal/src/dispensers/f56/error-codes.ts @@ -0,0 +1,109 @@ +/** + * Fujitsu F53/F56 BDU error-code decode table (ADR-005 §7). + * + * The BDU answers a failed bill-count with an 0xF0 frame whose bytes 3–4 are + * the error code; `f56-rs232.billCount` surfaces them as `rawCode` in the + * form prettyHex produces, e.g. '78 42'. Neither lamassu codebase ever + * decoded these — both collapsed every fault into one opaque string. + * + * Built empirically and from the Fujitsu Frontech F56-BDU Error Code List + * (K3KD03234–K3KD03236-0001, ed. E02). Entries are keyed by the full two-byte + * code, or by the first byte for families where the second byte is a + * parameter (`85 0n` = pick from another safe n; `B5 ..` = reject-box + * overflow). An unknown code fails SAFE: terminal, so an unfamiliar fault + * latches cash-out off rather than letting the next customer pay into it. + * + * Add a row when a code occurs. Each row's `observed` is the first machine + * and date we saw it, so the table doubles as the incident log. + */ + +import type { DispenseErrorClass, DispenseErrorInfo } from '../error-codes.js' + +export const F56_ERROR_CODE = 'F56DispenseError' + +interface F56ErrorEntry { + errorClass: DispenseErrorClass + human: string + /** First observed — machine, date. Empty for spec-only entries. */ + observed?: string +} + +/** Exact two-byte codes. */ +const EXACT: Record = { + '78 42': { + errorClass: 'terminal', + human: 'Note stopped at the cassette exit — open the unit and clear the transport path', + observed: 'sintra, 2026-10-09', + }, + '82 00': { + errorClass: 'recoverable', + human: 'Bill length check failed (long) — note read longer than the configured window', + observed: 'tejo (GTQ), 2026-09-26', + }, + '83 00': { + errorClass: 'recoverable', + human: 'Bill length check failed (short)', + }, + '84 00': { + errorClass: 'recoverable', + human: 'Bill thickness check failed — possible double pick or damaged note', + }, + '86 00': { + errorClass: 'recoverable', + human: 'Bill spacing error — notes too close together on the transport', + }, +} + +/** Families keyed by the first byte; the second byte is a parameter. */ +const FAMILY: Record = { + '85': { + errorClass: 'recoverable', + human: 'Pick from another safe — the note came from a different cassette than commanded', + }, + B5: { + errorClass: 'terminal', + human: 'Reject box overflow — empty the reject tray', + }, +} + +/** Normalise '78 42', '7842', '78-42', lowercase, etc. to 'XX YY'. */ +export function normaliseF56Code(raw: string): string { + const hex = raw.replace(/[^0-9a-fA-F]/g, '').toUpperCase() + if (hex.length !== 4) return raw.trim().toUpperCase() + return `${hex.slice(0, 2)} ${hex.slice(2, 4)}` +} + +export function decodeF56Error(rawCode: string | undefined): DispenseErrorInfo { + if (!rawCode) { + // No frame came back at all — serial timeout, port closed, framing error. + // The transport is in an unknown state; treat as terminal. + return { + errorCode: F56_ERROR_CODE, + errorClass: 'terminal', + human: 'Dispenser did not answer — serial timeout or framing error', + } + } + const code = normaliseF56Code(rawCode) + const exact = EXACT[code] + if (exact) { + return { errorCode: F56_ERROR_CODE, rawCode: code, errorClass: exact.errorClass, human: exact.human } + } + const family = FAMILY[code.slice(0, 2)] + if (family) { + return { errorCode: F56_ERROR_CODE, rawCode: code, errorClass: family.errorClass, human: family.human } + } + return { + errorCode: F56_ERROR_CODE, + rawCode: code, + errorClass: 'terminal', + human: `Unrecognised dispenser error ${code} — treat as a jam until decoded`, + } +} + +/** For the operator glossary: every known code with its class and meaning. */ +export function listF56ErrorCodes(): Array<{ code: string; errorClass: DispenseErrorClass; human: string; observed?: string }> { + return [ + ...Object.entries(EXACT).map(([code, e]) => ({ code, ...e })), + ...Object.entries(FAMILY).map(([code, e]) => ({ code: `${code} ..`, ...e })), + ] +} diff --git a/packages/hal/src/dispensers/f56/f56-rs232.ts b/packages/hal/src/dispensers/f56/f56-rs232.ts index 24e1d60..397f4e7 100644 --- a/packages/hal/src/dispensers/f56/f56-rs232.ts +++ b/packages/hal/src/dispensers/f56/f56-rs232.ts @@ -134,6 +134,8 @@ export async function initialize(currency: string, denominations: number[]): Pro export interface BillCountResult { bills: Array<{ dispensed: number; rejected: number }> error?: Error + /** BDU error code from bytes 3–4 of an 0xF0 frame, e.g. '78 42'. */ + rawCode?: string } export async function billCount(counts: number[]): Promise { @@ -172,9 +174,10 @@ export async function billCount(counts: number[]): Promise { if (res[0] === 0xf0) { console.log('response', res) - const errorCode = res.subarray(3, 5) - response.error = new Error(`Dispensing, code: ${prettyHex(errorCode)}`) - console.error(`found error code: ${prettyHex(errorCode)}`) + const rawCode = prettyHex(res.subarray(3, 5)) + response.rawCode = rawCode + response.error = new Error(`Dispensing, code: ${rawCode}`) + console.error(`found error code: ${rawCode}`) } return response diff --git a/packages/hal/src/dispensers/f56/index.ts b/packages/hal/src/dispensers/f56/index.ts index c64882d..52a5c03 100644 --- a/packages/hal/src/dispensers/f56/index.ts +++ b/packages/hal/src/dispensers/f56/index.ts @@ -8,6 +8,8 @@ */ import * as f56 from './f56-rs232.js' +import { decodeF56Error } from './error-codes.js' +import { tagDispenseError, type DispenseError } from '../error-codes.js' import type { BillDispenser, DispenserConfig, @@ -51,23 +53,28 @@ export class F56Dispenser implements BillDispenser { } } - async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: Error }> { + /** + * On any failure the port is closed so the next attempt re-initialises; + * the error is tagged with the ADR-005 taxonomy (class + decoded meaning) + * so the caller can route it. No statusCode: lamassu's 570 meant + * "insufficient funds" to its server and sent operators to refill full + * cassettes after jams. + */ + async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: DispenseError }> { try { - const { bills, error } = await f56.billCount(notes) + const { bills, error, rawCode } = await f56.billCount(notes) if (error) { await this.close() - ;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError' - ;(error as Error & { statusCode: number }).statusCode = 570 + return { value: bills, error: tagDispenseError(error, decodeF56Error(rawCode)) } } - return { value: bills, error } + return { value: bills } } catch (err) { await this.close() - const error = err as Error - ;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError' - ;(error as Error & { statusCode: number }).statusCode = 570 - return { value: [], error } + const error = err instanceof Error ? err : new Error(String(err)) + // No frame: serial timeout / framing. decodeF56Error(undefined) → terminal. + return { value: [], error: tagDispenseError(error, decodeF56Error(undefined)) } } } diff --git a/packages/hal/src/dispensers/puloon/index.ts b/packages/hal/src/dispensers/puloon/index.ts index f065fd4..034c3ce 100644 --- a/packages/hal/src/dispensers/puloon/index.ts +++ b/packages/hal/src/dispensers/puloon/index.ts @@ -14,6 +14,7 @@ import type { DispenserInitData, DispenseResult, } from '../../types.js' +import { tagDispenseError, type DispenseError } from '../error-codes.js' export class PuloonDispenser implements BillDispenser { public type: string = 'Puloon' @@ -46,16 +47,26 @@ export class PuloonDispenser implements BillDispenser { } } - async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: Error }> { + async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: DispenseError }> { const { bills, error } = await this.device.dispense(notes) if (error) { await this.close() - error.name = 'PuloonDispenseError' console.log('PULOON | dispense error', error) + // No decode table for the LCDM yet: every fault is terminal until one + // exists, so an unknown Puloon error latches cash-out off (ADR-005 §7). + return { + value: bills, + error: tagDispenseError(error, { + errorCode: 'PuloonDispenseError', + rawCode: (error as Error & { code?: string }).code, + errorClass: 'terminal', + human: `Puloon dispense error: ${error.message}`, + }), + } } - return { value: bills, error } + return { value: bills } } async close(): Promise { diff --git a/packages/hal/src/index.ts b/packages/hal/src/index.ts index dca91f5..46a76ed 100644 --- a/packages/hal/src/index.ts +++ b/packages/hal/src/index.ts @@ -57,5 +57,20 @@ export type { DispenserFactory, } from './types.js' +// Dispense error taxonomy (ADR-005 §7) +export { + tagDispenseError, + isDispenseError, + type DispenseError, + type DispenseErrorClass, + type DispenseErrorInfo, +} from './dispensers/error-codes.js' +export { + decodeF56Error, + listF56ErrorCodes, + normaliseF56Code, + F56_ERROR_CODE, +} from './dispensers/f56/error-codes.js' + // Utilities export { compute as computeCrc } from './utils/crc.js' diff --git a/packages/hal/src/types.ts b/packages/hal/src/types.ts index bf0615f..96c21dc 100644 --- a/packages/hal/src/types.ts +++ b/packages/hal/src/types.ts @@ -1,3 +1,5 @@ +import type { DispenseError } from './dispensers/error-codes.js' + import { EventEmitter } from 'node:events' /** @@ -176,7 +178,8 @@ export interface BillDispenser { */ dispense(notes: number[]): Promise<{ value: DispenseResult[] - error?: Error + /** Tagged with the ADR-005 taxonomy — see dispensers/error-codes.ts */ + error?: DispenseError }> /** diff --git a/packages/lnbits/src/client.ts b/packages/lnbits/src/client.ts index ad212a8..ece7bf3 100644 --- a/packages/lnbits/src/client.ts +++ b/packages/lnbits/src/client.ts @@ -48,6 +48,8 @@ import type { CreateWithdrawResult, LnbitsWithdrawLink, UniqueHashesResponse, + DispenseReportBody, + DispenseReportAck, } from './types.js' const LNBITS_KIND_RPC = 21000 @@ -234,6 +236,18 @@ export class LnbitsClient { ) } + /** + * Report a cash-out's dispense outcome (ADR-005 §2). Sent on success and on + * failure; the success report is what captures the settlement server-side. + * Idempotent on `txid` (the server upserts), so it is safe to retry — and the + * caller keeps it in a durable outbox and resends until this resolves. + * Rejects with LnbitsRpcError while spirekeeper has not registered the RPC; + * the outbox treats that like any other transient failure. + */ + async reportDispense(body: DispenseReportBody): Promise { + return this.idempotent(() => this.sendRpc('report_dispense', { body })) + } + // ============================================================================ // Invoices // ============================================================================ diff --git a/packages/lnbits/src/index.ts b/packages/lnbits/src/index.ts index e1e87c1..c37d73f 100644 --- a/packages/lnbits/src/index.ts +++ b/packages/lnbits/src/index.ts @@ -80,4 +80,8 @@ export type { UniqueHashEntry, UniqueHashesResponse, LnbitsPayLink, + DispenseReportBody, + DispenseReportAck, + DispenseReportBill, + DispenseReportCassette, } from './types.js' diff --git a/packages/lnbits/src/types.ts b/packages/lnbits/src/types.ts index fdebb95..135e0a8 100644 --- a/packages/lnbits/src/types.ts +++ b/packages/lnbits/src/types.ts @@ -310,3 +310,66 @@ export interface MachineConfigResponse { /** Freshness watermark (unix s) for the consumer's fee-config replay guard. */ created_at: number } + + +// ============================================================================ +// Dispense outcome report (ADR-005 §2) — machine → spirekeeper `report_dispense` +// ============================================================================ + +/** Per-denomination outcome. `requested` is what the sale asked for. */ +export interface DispenseReportBill { + denomination: number + requested: number + dispensed: number + rejected: number +} + +/** Per-bay outcome, verbatim from the machine's cassette_bills row. */ +export interface DispenseReportCassette { + position: number + denomination: number + provisioned: number + dispensed: number + rejected: number +} + +/** + * One cash-out's dispense outcome, sent on SUCCESS as well as failure — the + * success report is what captures the settlement server-side. Field names + * follow lamassu-server's cash_out_txs / cash_out_actions (dispense_confirmed, + * error, error_code) so the server's model lines up with ten years of prior + * art. Idempotent on `txid`: the machine resends until acked, the server + * upserts. + */ +export interface DispenseReportBody { + txid: string + /** Hash of the invoice the customer paid — the join key to the LNbits payment. */ + payment_hash: string | null + tx_type: 'cash_out' + /** Σ(denomination × dispensed) === requested fiat value (computed on value). */ + dispense_confirmed: boolean + /** Human message; null on success. */ + error: string | null + /** The error's NAME, e.g. 'F56DispenseError'; null on success. */ + error_code: string | null + /** Driver-native code, e.g. '78 42'; null when none. */ + raw_code: string | null + error_class: 'terminal' | 'recoverable' | 'inventory' | null + fiat_cents: number + currency: string + bills: DispenseReportBill[] + cassettes: DispenseReportCassette[] + /** The machine could not vouch for its bay counts after this dispense. */ + counts_uncertain: boolean + /** Set when this report closes an earlier failed txid via manual dispense. */ + remediates_txid?: string + /** unix seconds the outcome was recorded on the machine */ + at: number +} + +/** Server acknowledgement. `settlement_status` is what the server moved the settlement to. */ +export interface DispenseReportAck { + txid: string + received: boolean + settlement_status?: string +} diff --git a/packages/state-machine/src/__tests__/machine.test.ts b/packages/state-machine/src/__tests__/machine.test.ts index 9b9bf91..e56b2b7 100644 --- a/packages/state-machine/src/__tests__/machine.test.ts +++ b/packages/state-machine/src/__tests__/machine.test.ts @@ -12,7 +12,7 @@ describe('ATM State Machine', () => { sendNostrReceipt: vi.fn().mockResolvedValue(undefined), dispenseCash: vi.fn().mockResolvedValue({ bills: [{ denomination: 20, dispensed: 1, rejected: 0 }], - dispensed: true, + dispenseConfirmed: true, } satisfies DispenseCashResult), getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available @@ -397,127 +397,224 @@ describe('ATM State Machine', () => { }) }) - describe('dispense error handling', () => { - it('should route to waitingForCashTaken when dispenseCash returns dispensed: true', async () => { - const machine = createATMMachine(mockServices) - const actor = createActor(machine) + describe('dispense outcome (ADR-005 §3–§5)', () => { + /** Drive a 1 × $20 cash-out to the dispense and return the actor. */ + async function dispenseWith(result: DispenseCashResult, fake = false) { + const services: ATMServices = { ...mockServices, dispenseCash: vi.fn().mockResolvedValue(result) } + const actor = createActor(createATMMachine(services)) actor.start() - + const tick = async (ms: number) => + fake ? vi.advanceTimersByTimeAsync(ms) : new Promise((r) => setTimeout(r, ms)) actor.send({ type: 'SELECT_CASH_OUT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - + await tick(100) actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) actor.send({ type: 'CONFIRM_AMOUNT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - + await tick(100) actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) - await new Promise((resolve) => setTimeout(resolve, 100)) + await tick(100) + return actor + } + it('completes only on dispenseConfirmed (value equality), never on a driver boolean', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 1, rejected: 0 }], + dispenseConfirmed: true, + }) const state = actor.getSnapshot() - // dispenseCash mock returns { dispensed: true }, so should go to waitingForCashTaken expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' }) expect(state.context.cashDispensed).toBe(true) - expect(state.context.dispenseResult?.dispensed).toBe(true) + expect(state.context.dispenseResult?.dispenseConfirmed).toBe(true) + expect(state.context.cashOutHeld).toBeNull() }) - it('should route to dispenseError when dispenseCash returns dispensed: false', async () => { - const failDispenseServices: ATMServices = { - ...mockServices, - dispenseCash: vi.fn().mockResolvedValue({ - bills: [{ denomination: 20, dispensed: 0, rejected: 1 }], - dispensed: false, - error: 'Cassette jam', - } satisfies DispenseCashResult), - } - - const machine = createATMMachine(failDispenseServices) - const actor = createActor(machine) - actor.start() - - actor.send({ type: 'SELECT_CASH_OUT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) - actor.send({ type: 'CONFIRM_AMOUNT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - + it('routes a hardware error to dispenseFault — the customer has paid and is owed', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'Note stopped at the cassette exit', + errorCode: 'F56DispenseError', + rawCode: '78 42', + errorClass: 'terminal', + }) const state = actor.getSnapshot() - expect(state.value).toMatchObject({ cashOut: 'dispenseError' }) + expect(state.value).toMatchObject({ cashOut: 'dispenseFault' }) expect(state.context.cashDispensed).toBe(false) - expect(state.context.dispenseResult?.dispensed).toBe(false) - expect(state.context.dispenseResult?.error).toBe('Cassette jam') - expect(state.context.error).toBe('Cassette jam') + expect(state.context.error).toBe('Note stopped at the cassette exit') + expect(state.context.dispenseResult?.rawCode).toBe('78 42') }) - it('should auto-idle after 30s in dispenseError state', async () => { - vi.useFakeTimers() - - const failDispenseServices: ATMServices = { - ...mockServices, - dispenseCash: vi.fn().mockResolvedValue({ - bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], - dispensed: false, - error: 'Out of cash', - } satisfies DispenseCashResult), - } - - const machine = createATMMachine(failDispenseServices) - const actor = createActor(machine) - actor.start() - - actor.send({ type: 'SELECT_CASH_OUT' }) - await vi.advanceTimersByTimeAsync(100) - - actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) - actor.send({ type: 'CONFIRM_AMOUNT' }) - await vi.advanceTimersByTimeAsync(100) - - actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) - await vi.advanceTimersByTimeAsync(100) - - // Should be in dispenseError - expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseError' }) - - // Advance 30s - await vi.advanceTimersByTimeAsync(30000) - - // Should have auto-idled - expect(actor.getSnapshot().value).toBe('idle') - - vi.useRealTimers() - }) - - it('should allow CANCEL from dispenseError to go to idle immediately', async () => { - const failDispenseServices: ATMServices = { - ...mockServices, - dispenseCash: vi.fn().mockResolvedValue({ - bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], - dispensed: false, - error: 'Jam', - } satisfies DispenseCashResult), - } - - const machine = createATMMachine(failDispenseServices) - const actor = createActor(machine) - actor.start() - - actor.send({ type: 'SELECT_CASH_OUT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) - actor.send({ type: 'CONFIRM_AMOUNT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseError' }) + it('a terminal fault latches cash-out off; idle refuses SELECT_CASH_OUT until released', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'jam', + errorCode: 'F56DispenseError', + rawCode: '78 42', + errorClass: 'terminal', + }) + const hold = actor.getSnapshot().context.cashOutHeld + expect(hold).not.toBeNull() + expect(hold?.errorCode).toBe('F56DispenseError') + expect(hold?.rawCode).toBe('78 42') + expect(typeof hold?.since).toBe('number') actor.send({ type: 'CANCEL' }) expect(actor.getSnapshot().value).toBe('idle') + // The hold survives resetContext — it is machine health, not transaction state. + expect(actor.getSnapshot().context.cashOutHeld).not.toBeNull() + + actor.send({ type: 'SELECT_CASH_OUT' }) + expect(actor.getSnapshot().value).toBe('idle') + + // Only an operator op releases it (the store sends this on recount / resume_cash_out). + actor.send({ type: 'CASH_OUT_RELEASED' }) + expect(actor.getSnapshot().context.cashOutHeld).toBeNull() + actor.send({ type: 'SELECT_CASH_OUT' }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: expect.anything() }) + }) + + it('a hold gates cash-out only — cash-in is unaffected by a dispenser fault', () => { + const actor = createActor(createATMMachine(mockServices)) + actor.start() + actor.send({ + type: 'CASH_OUT_HELD', + hold: { reason: 'jam', errorCode: 'F56DispenseError', rawCode: '78 42', since: 1 }, + }) + actor.send({ type: 'SELECT_CASH_IN' }) + expect(actor.getSnapshot().value).toMatchObject({ cashIn: expect.anything() }) + }) + + it('a recoverable fault shows the fault screen but does NOT latch', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 1 }], + dispenseConfirmed: false, + error: 'Bill length check failed (long)', + errorCode: 'F56DispenseError', + rawCode: '82 00', + errorClass: 'recoverable', + }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' }) + expect(actor.getSnapshot().context.cashOutHeld).toBeNull() + }) + + it('a partial WITH an error is a fault (owed the shortfall), not out-of-cash', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 1, rejected: 1 }], + dispenseConfirmed: false, + error: 'jam after first note', + errorCode: 'F56DispenseError', + rawCode: '78 42', + errorClass: 'terminal', + }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' }) + }) + + it('an inventory refusal (nothing asked of the hardware) is outOfCash, and does not latch', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'Insufficient inventory for denomination 20: short 1', + errorCode: 'InsufficientInventory', + errorClass: 'inventory', + }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' }) + expect(actor.getSnapshot().context.cashOutHeld).toBeNull() + }) + + it('a shortfall with NO error is outOfCash', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' }) + }) + + it('a persisted hold restored on boot gates cash-out before any dispense', () => { + const actor = createActor(createATMMachine(mockServices)) + actor.start() + actor.send({ + type: 'CASH_OUT_HELD', + hold: { reason: 'jam', errorCode: 'F56DispenseError', rawCode: '78 42', since: 1791529353 }, + }) + actor.send({ type: 'SELECT_CASH_OUT' }) + expect(actor.getSnapshot().value).toBe('idle') + expect(actor.getSnapshot().context.cashOutHeld?.since).toBe(1791529353) + }) + + it('outOfCash auto-returns after 30s; dispenseFault gives the customer 120s', async () => { + vi.useFakeTimers() + try { + const ooc = await dispenseWith( + { bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], dispenseConfirmed: false }, + true + ) + expect(ooc.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' }) + await vi.advanceTimersByTimeAsync(30000) + expect(ooc.getSnapshot().value).toBe('idle') + + const fault = await dispenseWith( + { + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'jam', + errorCode: 'F56DispenseError', + errorClass: 'recoverable', + }, + true + ) + expect(fault.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' }) + await vi.advanceTimersByTimeAsync(30000) + expect(fault.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' }) + await vi.advanceTimersByTimeAsync(90000) + expect(fault.getSnapshot().value).toBe('idle') + } finally { + vi.useRealTimers() + } + }) + + it('the customer can acknowledge the fault screen or cancel; both return to idle', async () => { + const a = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'jam', + errorClass: 'recoverable', + }) + a.send({ type: 'ACKNOWLEDGE_FAULT' }) + expect(a.getSnapshot().value).toBe('idle') + + const b = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'jam', + errorClass: 'recoverable', + }) + b.send({ type: 'CANCEL' }) + expect(b.getSnapshot().value).toBe('idle') + }) + + it('a hung dispense (timeout) is a terminal fault and latches', async () => { + vi.useFakeTimers() + try { + const hang: ATMServices = { + ...mockServices, + dispenseCash: vi.fn().mockReturnValue(new Promise(() => {})), + } + const actor = createActor(createATMMachine(hang)) + actor.start() + actor.send({ type: 'SELECT_CASH_OUT' }) + await vi.advanceTimersByTimeAsync(100) + actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) + actor.send({ type: 'CONFIRM_AMOUNT' }) + await vi.advanceTimersByTimeAsync(100) + actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'p' }) + await vi.advanceTimersByTimeAsync(120000 + 10) + const s = actor.getSnapshot() + expect(s.value).toMatchObject({ cashOut: 'dispenseFault' }) + expect(s.context.dispenseResult?.errorCode).toBe('DispenseTimeout') + expect(s.context.cashOutHeld).not.toBeNull() + } finally { + vi.useRealTimers() + } }) }) diff --git a/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts index c5d4c1a..c64a87d 100644 --- a/packages/state-machine/src/machine.ts +++ b/packages/state-machine/src/machine.ts @@ -10,6 +10,7 @@ import { type ATMContext, type ATMEvent, type DispenseCashResult, + type CashOutHold, initialContext, type ATMServices, type OfferRequestEvent, @@ -148,8 +149,8 @@ export function createATMMachine( } // Extract payment hash from invoice (simplified - real impl would decode BOLT11) // The service handles the actual extraction - const cleanup = services.watchInvoice(input.invoice, (preimage: string) => { - sendBack({ type: 'PAYMENT_RECEIVED', preimage }) + const cleanup = services.watchInvoice(input.invoice, (preimage, paymentHash) => { + sendBack({ type: 'PAYMENT_RECEIVED', preimage, paymentHash }) }) return cleanup }), @@ -184,6 +185,9 @@ export function createATMMachine( // without this the just-granted session would be wiped. The session is // cleared instead on re-lock (locked's entry), i.e. when access ends. accessSession: context.accessSession, + // The cash-out latch is machine health, not transaction state — it + // survives every reset until an operator op releases it. + cashOutHeld: context.cashOutHeld, cashInSessionId: null, dispenseResult: null, })), @@ -315,6 +319,10 @@ export function createATMMachine( if (event.type !== 'PAYMENT_RECEIVED') return null return event.preimage }, + paymentHash: ({ event }) => { + if (event.type !== 'PAYMENT_RECEIVED') return null + return event.paymentHash ?? null + }, }), setPaymentFailed: assign({ paymentStatus: () => 'failed' as const, @@ -332,6 +340,37 @@ export function createATMMachine( return output?.error ?? null }, }), + // ADR-005 §5: a terminal fault latches cash-out off. Idempotent — an + // existing hold is kept (its `since` is the first fault, which is what + // the operator wants to know). + latchCashOutIfTerminal: assign({ + cashOutHeld: ({ context }) => { + if (context.cashOutHeld) return context.cashOutHeld + const dr = context.dispenseResult + if (dr?.errorClass !== 'terminal') return null + return { + reason: dr.error ?? 'terminal dispenser fault', + errorCode: dr.errorCode ?? null, + rawCode: dr.rawCode ?? null, + since: Math.floor(Date.now() / 1000), + } satisfies CashOutHold + }, + }), + setCashOutHeld: assign({ + cashOutHeld: ({ event }) => (event.type === 'CASH_OUT_HELD' ? event.hold : null), + }), + clearCashOutHeld: assign({ cashOutHeld: null }), + setDispenseTimeoutError: assign({ + error: () => 'Dispense timed out — hardware may be jammed', + dispenseResult: ({ context }) => + context.dispenseResult ?? { + bills: [], + dispenseConfirmed: false, + error: 'Dispense timed out — hardware may be jammed', + errorCode: 'DispenseTimeout', + errorClass: 'terminal' as const, + }, + }), setAmount: assign({ fiatCents: ({ event }) => { if (event.type !== 'SELECT_AMOUNT') return 0 @@ -458,6 +497,18 @@ export function createATMMachine( return principalSats - fee <= context.availableBalance }, // Cash-out guards + // ADR-005 §5: no cash-out while latched. The idle screen shows why. + cashOutAvailable: ({ context }) => context.cashOutHeld === null, + // ADR-005 §3/§4: only value equality completes a dispense. + dispenseConfirmed: ({ event }) => + (event as unknown as { output?: DispenseCashResult }).output?.dispenseConfirmed === true, + // A shortfall WITH a hardware error is a fault (customer paid, is owed); + // a shortfall with none, or an inventory refusal, is out-of-cash. + dispenseHadFault: ({ event }) => { + const out = (event as unknown as { output?: DispenseCashResult }).output + if (!out?.error) return false + return out.errorClass !== 'inventory' + }, hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0, canAddDenomination: ({ context, event }) => { if (event.type !== 'ADD_DENOMINATION') return false @@ -477,7 +528,10 @@ export function createATMMachine( INVOICE_TIMEOUT: 300000, // 5 minutes — waiting for payment COMPLETE_DELAY: 60000, DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond - DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState + DISPENSE_ERROR_TIMEOUT: 30000, // out-of-cash: 30s like brain.js _timedState + // Fault screen: the customer has paid and is owed money; give them time + // to photograph/write down the reference (ADR-005 §4). + DISPENSE_FAULT_TIMEOUT: 120000, // NOTE: idle inactivity re-lock + hard session cap are enforced at the // DOM layer (useSessionSecurity), not as XState `after` delays — see the // idle state comment. No IDLE_LOCK_TIMEOUT delay here by design. @@ -513,6 +567,11 @@ export function createATMMachine( cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction, }), }, + // ADR-005 §5 — the store restores a persisted hold on boot and + // releases it when an operator op lands. Root-level so it applies in + // any state; it only gates entry to cashOut, never an in-flight sale. + CASH_OUT_HELD: { actions: 'setCashOutHeld' }, + CASH_OUT_RELEASED: { actions: 'clearCashOutHeld' }, }, states: { // === ACCESS GATE (ADR-003) === @@ -557,6 +616,7 @@ export function createATMMachine( actions: ['setStartTime', 'setCashInFee'], }, SELECT_CASH_OUT: { + guard: 'cashOutAvailable', target: 'cashOut', actions: ['setStartTime', 'setCashOutFee'], }, @@ -861,13 +921,12 @@ export function createATMMachine( }, dispensingCash: { // Safety timeout: if dispenseCash promise hangs (hardware jam, - // serial port freeze), don't stay here forever. + // serial port freeze), don't stay here forever. A hang is a + // terminal fault — the transport state is unknown. after: { DISPENSE_TIMEOUT: { - target: 'dispenseError', - actions: assign({ - error: () => 'Dispense timed out — hardware may be jammed', - }), + target: 'dispenseFault', + actions: ['setDispenseTimeoutError', 'latchCashOutIfTerminal'], }, }, invoke: { @@ -875,21 +934,31 @@ export function createATMMachine( input: ({ context }) => context.dispenseAmounts, onDone: [ { - guard: ({ event }) => - (event.output as unknown as DispenseCashResult | undefined)?.dispensed === true, + // ADR-005 §3: value equality, nothing else, completes. + guard: 'dispenseConfirmed', target: 'waitingForCashTaken', actions: ['setCashDispensed', 'setDispenseResult'], }, { - // Partial or failed dispense - target: 'dispenseError', + // ADR-005 §4: a hardware error means the customer has paid + // and is owed — the fault screen, with evidence. A terminal + // class also latches cash-out off (§5). + guard: 'dispenseHadFault', + target: 'dispenseFault', + actions: ['setDispenseResult', 'latchCashOutIfTerminal'], + }, + { + // Shortfall with no hardware error, or an inventory refusal + // before anything was asked of the dispenser. + target: 'outOfCash', actions: 'setDispenseResult', }, ], onError: { - // Unexpected crash (not a dispense failure) - target: 'dispenseError', - actions: 'setError', + // The service threw (not a reported dispense failure). No + // per-bay report exists — the store flags counts unverified. + target: 'dispenseFault', + actions: ['setError', 'latchCashOutIfTerminal'], }, }, }, @@ -930,9 +999,26 @@ export function createATMMachine( CANCEL: '#atm.locked', }, }, - dispenseError: { - // Payment received but cash not (fully) dispensed. - // Show error + txid for 30s, then auto-idle (matches brain.js _timedState). + // ADR-005 §4 — two terminal states replace the old dispenseError. + // + // dispenseFault: the dispenser reported an error. The customer HAS + // PAID and is owed the shortfall. The screen carries the txid, the + // payment hash, the amounts and a statement that the operator has + // been notified — this is lamassu's fiatTransactionError ("the + // right prompt when they have paid and are owed money"), not the + // out-of-cash screen a jam used to show. + dispenseFault: { + after: { + DISPENSE_FAULT_TIMEOUT: '#atm.locked', + }, + on: { + ACKNOWLEDGE_FAULT: '#atm.locked', + CANCEL: '#atm.locked', + }, + }, + // outOfCash: the request could not be met and the dispenser + // reported NO error — nothing was charged beyond what was dispensed. + outOfCash: { after: { DISPENSE_ERROR_TIMEOUT: '#atm.locked', }, diff --git a/packages/state-machine/src/types.ts b/packages/state-machine/src/types.ts index fc8d4a7..6935a95 100644 --- a/packages/state-machine/src/types.ts +++ b/packages/state-machine/src/types.ts @@ -21,18 +21,48 @@ export interface CassetteBillResult { rejected: number } -/** Result of a dispense operation (always resolves, never throws) */ +/** How a dispense error should be routed — see @bitSpire/hal dispensers/error-codes.ts */ +export type DispenseErrorClass = 'terminal' | 'recoverable' | 'inventory' + +/** Result of a dispense operation (always resolves, never throws) — ADR-005 §3 */ export interface DispenseCashResult { /** Per-denomination results (what was actually dispensed) */ bills: { denomination: number; dispensed: number; rejected: number }[] - /** Whether the full requested amount was dispensed */ - dispensed: boolean - /** Error message if dispense failed or was partial */ + /** + * Σ(denomination × dispensed) equals the requested fiat value. Computed by + * the HAL on VALUE, never taken from a driver boolean. This is lamassu's + * `dispenseConfirmed` and it is the only thing that routes to `complete`. + */ + dispenseConfirmed: boolean + /** Human-readable message if dispense failed or was partial */ error?: string + /** The error's NAME, machine-readable — e.g. 'F56DispenseError' */ + errorCode?: string + /** Driver-native code for the decode table — e.g. '78 42' */ + rawCode?: string + /** + * terminal → dispenseFault + latch cash-out; recoverable → dispenseFault; + * inventory → outOfCash (nothing was asked of the hardware). + */ + errorClass?: DispenseErrorClass /** Per-cassette detail (position-aware, produced by HAL) */ cassettes?: CassetteBillResult[] } +/** + * Cash-out is latched off after a terminal dispenser fault (ADR-005 §5). + * Set by the machine when a fault lands; persisted and restored by the + * store; cleared only by an operator `recount` or `resume_cash_out` op — + * never by re-initialising the dispenser, which does not move a stuck note. + */ +export interface CashOutHold { + reason: string + errorCode: string | null + rawCode: string | null + /** unix seconds */ + since: number +} + /** Payment methods supported */ export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu' @@ -122,6 +152,12 @@ export interface ATMContext { paymentStatus: PaymentStatus /** Payment preimage (proof of payment) */ preimage: string | null + /** + * Payment hash of the settled invoice — the join key to the LNbits payment + * the operator sees. Shown on the dispense-fault screen (ADR-005 §4) so a + * customer who is owed money leaves with the reference the server indexes. + */ + paymentHash: string | null /** Payment method used */ paymentMethod: PaymentMethod | null /** Pending offer request (Kind 21001 from user's wallet) */ @@ -157,6 +193,8 @@ export interface ATMContext { retryCount: number /** Result from the last dispense operation */ dispenseResult: DispenseCashResult | null + /** Cash-out latched off after a terminal fault; null = available (ADR-005 §5) */ + cashOutHeld: CashOutHold | null // Transaction metadata /** Unique transaction ID */ @@ -199,8 +237,14 @@ export type ATMEvent = | { type: 'BILL_REJECTED'; reason: string } | { type: 'CASH_DISPENSED' } | { type: 'DISPENSE_ERROR'; error: string } + // Customer acknowledges the fault screen ("I've saved this reference") + | { type: 'ACKNOWLEDGE_FAULT' } + // Cash-out latch (ADR-005 §5): the store restores a persisted hold on boot + // and releases it when an operator recount / resume_cash_out op lands. + | { type: 'CASH_OUT_HELD'; hold: CashOutHold } + | { type: 'CASH_OUT_RELEASED' } // Payment events - | { type: 'PAYMENT_RECEIVED'; preimage: string } + | { type: 'PAYMENT_RECEIVED'; preimage: string; paymentHash?: string } | { type: 'PAYMENT_FAILED'; error: string } | { type: 'INVOICE_GENERATED'; invoice: string } | { type: 'OFFER_GENERATED'; offer: string } @@ -245,6 +289,7 @@ export const initialContext: ATMContext = { lnurlWithdraw: null, paymentStatus: null, preimage: null, + paymentHash: null, paymentMethod: null, pendingOfferRequest: null, billsInserted: [], @@ -258,6 +303,7 @@ export const initialContext: ATMContext = { error: null, retryCount: 0, dispenseResult: null, + cashOutHeld: null, txid: null, startedAt: null, cashInSessionId: null, @@ -306,7 +352,10 @@ export interface ATMServices { * Watch an invoice for payment (polling-based) * Calls the callback when paid, returns cleanup function */ - watchInvoice: (paymentHash: string, callback: (preimage: string) => void) => () => void + watchInvoice: ( + invoice: string, + callback: (preimage: string, paymentHash?: string) => void + ) => () => void /** * Get available inventory: denomination -> count */