From c70d43523c9246519e7f7e3f5a1ef7e2b2dffa6e Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:25:51 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(hal):=20dispense=20error=20taxonomy,?= =?UTF-8?q?=20F56=20decode=20table,=20and=20the=20first=20HAL=20tests=20(A?= =?UTF-8?q?DR-005=20=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every dispenser now returns a tagged DispenseError: errorCode (the family name, e.g. F56DispenseError), rawCode (driver-native, '78 42'), errorClass (terminal | recoverable | inventory) and a human decode. The class is what the state machine routes on: terminal latches cash-out off, recoverable shows the fault screen but stays in service, inventory means nothing was asked of the hardware. The F56 table is built empirically and from the Fujitsu F56-BDU Error Code List, seeded with sintra's 78 42 (note stopped at the cassette exit, terminal) and the Tejo's 82 00 (long-bill reject, recoverable), plus the 83/84/86 00 checks and the 85 0n / B5 .. families. An unknown code fails SAFE — terminal — so an unfamiliar fault latches rather than letting the next customer pay into it. f56-rs232 surfaces the raw code structurally instead of only inside the message string. Drops the borrowed statusCode 570 from the F56 driver: lamassu-server read 570 as "insufficient funds", so a jam told operators to refill full cassettes (their 34ba9203 fix). Puloon gets the same contract with every fault terminal until it has a decode table. packages/hal had no tests at all (ADR-005 finding 10). Adds the first two: the decode table, and the bill-length table — every window [hi, lo] sane, and GTQ/USD(/HNL when added) sharing one window for what is physically the same 156 mm note. That second test would have caught the GTQ fault months ago. --- packages/hal/src/dispensers/error-codes.ts | 60 ++++++++++ .../dispensers/f56/__tests__/bills.test.ts | 64 ++++++++++ .../f56/__tests__/error-codes.test.ts | 66 +++++++++++ .../hal/src/dispensers/f56/error-codes.ts | 109 ++++++++++++++++++ packages/hal/src/dispensers/f56/f56-rs232.ts | 9 +- packages/hal/src/dispensers/f56/index.ts | 25 ++-- packages/hal/src/dispensers/puloon/index.ts | 17 ++- packages/hal/src/index.ts | 15 +++ packages/hal/src/types.ts | 5 +- 9 files changed, 354 insertions(+), 16 deletions(-) create mode 100644 packages/hal/src/dispensers/error-codes.ts create mode 100644 packages/hal/src/dispensers/f56/__tests__/bills.test.ts create mode 100644 packages/hal/src/dispensers/f56/__tests__/error-codes.test.ts create mode 100644 packages/hal/src/dispensers/f56/error-codes.ts 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 }> /** -- 2.55.0 From 3ff86de4edb7e5f50930f04e9788d011c3dd0c0a Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:25:51 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(state-machine):=20dispenseConfirmed=20?= =?UTF-8?q?on=20value,=20dispenseFault=20vs=20outOfCash,=20cash-out=20latc?= =?UTF-8?q?h=20(ADR-005=20=C2=A73=E2=80=93=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DispenseCashResult.dispensed (a driver boolean) is replaced by dispenseConfirmed — Σ(denomination × dispensed) equals the requested value, computed by the HAL — plus errorCode / rawCode / errorClass. dispensingCash.onDone guards on dispenseConfirmed and nothing else. The single dispenseError state becomes two. dispenseFault: the dispenser reported an error, the customer has paid and is owed — 120 s screen with evidence, ACKNOWLEDGE_FAULT to dismiss. outOfCash: a shortfall with no hardware error or an inventory refusal — 30 s. A hung dispense is a terminal fault. A terminal errorClass latches cash-out off: context.cashOutHeld, set by latchCashOutIfTerminal, preserved across resetContext (it is machine health, not transaction state), guarding idle's SELECT_CASH_OUT. Cash-in is unaffected. Only CASH_OUT_RELEASED clears it — the store sends that when an operator recount or resume_cash_out op lands; re-initialising the dispenser never does, because re-init does not move a stuck note. CASH_OUT_HELD lets the store restore a persisted hold on boot. PAYMENT_RECEIVED now carries the payment hash into context.paymentHash so the fault screen can show the reference the server indexes. Tests: the dispense section is rewritten around outcomes — value confirmation, fault vs out-of-cash routing, terminal latch + release, recoverable does not latch, partial-with-error is a fault, inventory refusal is out-of-cash, boot-restored hold gates, 30 s vs 120 s timers, acknowledge/cancel, timeout latches. 46/46. --- .../src/__tests__/machine.test.ts | 305 ++++++++++++------ packages/state-machine/src/machine.ts | 122 +++++-- packages/state-machine/src/types.ts | 61 +++- 3 files changed, 360 insertions(+), 128 deletions(-) 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 */ -- 2.55.0 From 888870d01ac73dbde6973036e1d981981af6a74e Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:25:51 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat(machine):=20value-confirmed=20dispense?= =?UTF-8?q?,=20cash-out=20hold,=20fault=20screens,=20counts-uncertain=20on?= =?UTF-8?q?=20zero-with-error=20(ADR-005=20=C2=A73=E2=80=93=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HAL glue (electron/hal-service.ts and the renderer-side services/hal.ts): dispenseConfirmed is Σ(denomination × dispensed) === Σ(denomination × requested), computed on value. The driver's tagged error is carried through as errorCode / rawCode / errorClass / human; pre-dispense inventory refusals are errorClass 'inventory' so they route to outOfCash rather than the fault screen. The manual-dispense command result keeps its wire key `dispensed` (spirekeeper's poller reads it) and gains the new fields alongside. Cash-out hold: state-store persists it in meta as one JSON value beside countsUncertainSince, idempotent on set (the first fault's `since` is kept); IPC get/set/clear through preload. The store persists the hold the moment the machine sets it and restores it into the machine on boot. A recount clears it in the store (same gesture that clears counts- uncertain); operator-config also honours a new resume_cash_out op — not a cassette op, split off before applyOperatorCassetteOps, and honoured only when stamped after the hold began so a re-delivered old resume cannot clear a fresh fault. Either release calls back into the store, which sends CASH_OUT_RELEASED. The cassettes-state document carries cash_out_held_since / _reason / _code (additive, like counts_uncertain_since); the availability beacon reports cash_out false while held; the idle Sell button is disabled with the reason. Store watcher: dispenseFault and outOfCash both record dispense_error / partial (the customer has paid either way). A report of zero dispensed WITH a hardware error now sets countsUncertainSince instead of being trusted as zero — a note stopped in the transport completes neither counter (sintra 2026-10-09: bay read 66, held 65, one in the transport). Fault screen: both terminal states show "your payment went through", amount paid, per-denomination dispensed, the txid as QR and text, the payment hash (threaded from the settlement watch through PAYMENT_RECEIVED) and the time, with "keep this reference" and an acknowledge button. The raw dispenser code is not shown; it travels in the report. --- apps/machine/electron/hal-service.ts | 79 +++++++++++-- apps/machine/electron/main.ts | 25 ++++- apps/machine/electron/preload.ts | 16 +++ apps/machine/electron/state-store.ts | 70 +++++++++++- .../composables/useAvailabilityBroadcast.ts | 14 ++- apps/machine/src/services/hal.ts | 33 +++++- apps/machine/src/services/lightning.ts | 15 ++- apps/machine/src/services/operator-config.ts | 63 ++++++++++- apps/machine/src/stores/atm.ts | 78 +++++++++++-- apps/machine/src/types/electron.d.ts | 14 +++ apps/machine/src/views/CashOutView.vue | 106 +++++++++++++----- apps/machine/src/views/IdleView.vue | 28 ++++- 12 files changed, 469 insertions(+), 72 deletions(-) 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() { > - +
-- 2.55.0 From 7f055cd4c69cdeb7be977accf532426655490b62 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:26:53 +0200 Subject: [PATCH 4/6] =?UTF-8?q?docs(adr):=20ADR-005=20=C2=A74=20=E2=80=94?= =?UTF-8?q?=20outOfCash=20after=20payment=20is=20owed=20too;=20only=20the?= =?UTF-8?q?=20cause=20and=20the=20latch=20differ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/005-cash-out-dispense-outcome.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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, -- 2.55.0 From 7120f306b6886648273b8ff45d9a62870bee4c28 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:31:26 +0200 Subject: [PATCH 5/6] =?UTF-8?q?feat(lnbits):=20report=5Fdispense=20RPC=20a?= =?UTF-8?q?nd=20the=20dispense-report=20wire=20types=20(ADR-005=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) with raw_code and error_class alongside, per-denomination bills with `requested`, per-bay cassettes verbatim, the payment hash as the join key, and counts_uncertain. Idempotent on txid (the server upserts), so the call is wrapped in idempotent() and safe for the machine's outbox to retry. Until spirekeeper registers the RPC it rejects with LnbitsRpcError, which the outbox treats like any other transient failure. --- packages/lnbits/src/client.ts | 14 ++++++++ packages/lnbits/src/index.ts | 4 +++ packages/lnbits/src/types.ts | 63 +++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) 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 +} -- 2.55.0 From e8106b665a3f38d28720fd1466a759886b4758b9 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:37:17 +0200 Subject: [PATCH 6/6] =?UTF-8?q?feat(machine):=20durable=20dispense-report?= =?UTF-8?q?=20outbox=20to=20spirekeeper=20(ADR-005=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every cash-out now produces one report_dispense — on success as well as failure — and the machine does not stop sending it until spirekeeper acknowledges it. state.db gains a dispense_reports table (migration v13 → v14): the report is written INSIDE recordTransaction's SQLite transaction, alongside the transactions row, so a crash between the two cannot lose it. Rows carry attempts / last_attempt_at / last_error / acked_at. Three IPC calls (pending / ack / note-attempt) expose it to the renderer. The store builds the report when a cash-out reaches complete, dispenseFault or outOfCash: txid, payment hash, dispense_confirmed, error / error_code / raw_code / error_class, per-denomination requested vs dispensed vs rejected, the per-bay cassette record verbatim, and counts_uncertain. The success report is what lets the server capture (distribute) the settlement; the failure report is what puts a customer on the owed-cash worklist instead of leaving the only record on the ATM. Delivery is at-least-once: a flusher drains pending rows after each persist, on relay (re)connect, and every 60 s, acking only on an OK reply and backing off 30 s · 2^attempts (capped 1 h) otherwise. While spirekeeper has not registered the RPC every send fails the same way; the backoff keeps that quiet and the rows wait — this half ships first. The lightning service exposes reportDispense; the function pointer is set at all three lightning-init sites so the flusher works on every path. --- apps/machine/electron/main.ts | 19 ++++ apps/machine/electron/preload.ts | 20 ++++ apps/machine/electron/state-store.ts | 118 ++++++++++++++++++++- apps/machine/src/services/lightning.ts | 9 +- apps/machine/src/stores/atm.ts | 136 +++++++++++++++++++++++++ apps/machine/src/types/electron.d.ts | 13 +++ apps/machine/src/types/state.ts | 6 ++ 7 files changed, 319 insertions(+), 2 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 0295894..68b9f88 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -31,6 +31,9 @@ import { setCashOutHold, clearCashOutHold, type CashOutHold, + pendingDispenseReports, + markDispenseReportAcked, + noteDispenseReportAttempt, markStatePublished, resetStatePublishWatermark, resetForRepair, @@ -578,6 +581,22 @@ ipcMain.handle('state:set-cash-out-hold', (_event, hold: CashOutHold): CashOutHo 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) }) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 83ab409..bc75046 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -15,6 +15,16 @@ interface CashOutHold { 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) @@ -127,6 +137,13 @@ contextBridge.exposeInMainWorld('electronAPI', { 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), @@ -323,6 +340,9 @@ declare global { 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 ec4d682..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 (?, ?)') @@ -580,6 +617,70 @@ export function clearCashOutHold(): boolean { 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. * @@ -1241,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 } /** @@ -1262,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 (?, ?, ?, ?, ?, ?, ?)' ) @@ -1294,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/services/lightning.ts b/apps/machine/src/services/lightning.ts index 3a72f63..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 }, diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 8131c85..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' @@ -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 { @@ -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 @@ -684,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', @@ -697,11 +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, and // the payload now carries the hold / unverified flags. .then(() => operatorConfigSvc?.publishCassettesState()) + .then(() => flushDispenseReports()) } } @@ -731,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())) } } @@ -1084,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!') @@ -1096,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 @@ -1383,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 @@ -1676,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 @@ -2044,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 diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 9c54e75..f3b4db5 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -164,6 +164,19 @@ declare global { 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 { -- 2.55.0