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..0295894 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -27,6 +27,10 @@ import { getCountsUncertainSince, getLastStatePublishedAt, markCountsUncertain, + getCashOutHold, + setCashOutHold, + clearCashOutHold, + type CashOutHold, markStatePublished, resetStatePublishWatermark, resetForRepair, @@ -565,6 +569,15 @@ 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()) ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => { markStatePublished(unixTimestamp) }) @@ -841,7 +854,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 +873,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 +881,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..83ab409 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -7,6 +7,14 @@ 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 +} + /** * Runtime configuration interface (public info only) * These values are read from environment variables at runtime (not build time) @@ -114,6 +122,11 @@ 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'), markStatePublished: (unixTimestamp: number): Promise => ipcRenderer.invoke('state:mark-state-published', unixTimestamp), @@ -307,6 +320,9 @@ declare global { getLastStatePublishedAt: () => Promise getCountsUncertainSince: () => Promise markCountsUncertain: (unixTimestamp: number) => Promise + getCashOutHold: () => Promise + setCashOutHold: (hold: CashOutHold) => Promise + clearCashOutHold: () => 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..ec4d682 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -517,6 +517,69 @@ 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 +} + /** * A counter bumped on every local change to a bay count, from any cause. * @@ -851,7 +914,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( 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..3a72f63 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -742,7 +742,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 +765,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 +1106,7 @@ export function createATMServices( dispensed: a.count, rejected: 0, })), - dispensed: true, + dispenseConfirmed: true, } }, @@ -1206,7 +1206,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 +1224,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 +1247,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..8131c85 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -126,7 +126,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 +141,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) @@ -254,7 +254,7 @@ const mockServices: ATMServices = { dispensed: a.count, rejected: 0, })), - dispensed: true, + dispenseConfirmed: true, } }, @@ -618,13 +618,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 +652,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 @@ -664,7 +699,8 @@ export const useAtmStore = defineStore('atm', () => { error: dr?.error ?? ctx.error, }) .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()) } } @@ -742,6 +778,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) ─────────────────────────────────── @@ -1135,6 +1188,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 @@ -1461,6 +1515,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) @@ -1797,6 +1852,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 +1955,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' }) @@ -2004,6 +2065,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 +2132,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..9c54e75 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -150,6 +150,20 @@ 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 markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise 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() { > - +