Compare commits

..

7 commits

Author SHA1 Message Date
0204514ed2 Merge pull request 'ADR-005 rollout step 1: value-confirmed dispense, fault screens, cash-out latch, dispense-report outbox' (#123) from feat/dispense-outcome into dev
Reviewed-on: #123
2026-10-10 19:54:55 +00:00
e8106b665a feat(machine): durable dispense-report outbox to spirekeeper (ADR-005 §2)
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.
2026-10-10 21:37:47 +02:00
7120f306b6 feat(lnbits): report_dispense RPC and the dispense-report wire types (ADR-005 §2)
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.
2026-10-10 21:37:47 +02:00
7f055cd4c6 docs(adr): ADR-005 §4 — outOfCash after payment is owed too; only the cause and the latch differ 2026-10-10 21:37:47 +02:00
888870d01a feat(machine): value-confirmed dispense, cash-out hold, fault screens, counts-uncertain on zero-with-error (ADR-005 §3–§5)
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.
2026-10-10 21:37:47 +02:00
3ff86de4ed feat(state-machine): dispenseConfirmed on value, dispenseFault vs outOfCash, cash-out latch (ADR-005 §3–§5)
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.
2026-10-10 21:37:47 +02:00
c70d43523c feat(hal): dispense error taxonomy, F56 decode table, and the first HAL tests (ADR-005 §7)
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.
2026-10-10 21:37:47 +02:00
29 changed files with 1579 additions and 208 deletions

View file

@ -6,7 +6,8 @@
* so it's available at runtime (unlike src/ which is only for Vite). * 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 { export interface CassetteConfig {
/** /**
@ -48,8 +49,20 @@ export interface ValidatorCallbacks {
export interface DispenseResult { export interface DispenseResult {
bills: { denomination: number; dispensed: number; rejected: number }[] 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 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?: { cassettes?: {
name: string name: string
position: number position: number
@ -277,6 +290,8 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
notes[i] = (notes[i] ?? 0) + take notes[i] = (notes[i] ?? 0) + take
remaining -= 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) { if (!matched) {
return { return {
bills: amounts.map((a) => ({ bills: amounts.map((a) => ({
@ -284,8 +299,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
dispensed: 0, dispensed: 0,
rejected: 0, rejected: 0,
})), })),
dispensed: false, dispenseConfirmed: false,
error: `No cassette loaded with denomination: ${denomination}`, error: `No cassette loaded with denomination: ${denomination}`,
errorCode: 'NoCassetteForDenomination',
errorClass: 'inventory',
} }
} }
if (remaining > 0) { if (remaining > 0) {
@ -295,8 +312,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
dispensed: 0, dispensed: 0,
rejected: 0, rejected: 0,
})), })),
dispensed: false, dispenseConfirmed: false,
error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`, error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`,
errorCode: 'InsufficientInventory',
errorClass: 'inventory',
} }
} }
} }
@ -344,11 +363,44 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
} }
const bills = Array.from(billsByDenom.values()) 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 totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
const dispenseConfirmed = requestedValue === dispensedValue
if (result.error) { 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 // Wait for customer to take bills
@ -357,7 +409,20 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
console.log('[HAL] Bills removed by customer') 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 }
}, },
/** /**

View file

@ -27,6 +27,13 @@ import {
getCountsUncertainSince, getCountsUncertainSince,
getLastStatePublishedAt, getLastStatePublishedAt,
markCountsUncertain, markCountsUncertain,
getCashOutHold,
setCashOutHold,
clearCashOutHold,
type CashOutHold,
pendingDispenseReports,
markDispenseReportAcked,
noteDispenseReportAttempt,
markStatePublished, markStatePublished,
resetStatePublishWatermark, resetStatePublishWatermark,
resetForRepair, resetForRepair,
@ -565,6 +572,31 @@ ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCount
ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => { ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => {
markCountsUncertain(unixTimestamp) markCountsUncertain(unixTimestamp)
}) })
// Cash-out hold (ADR-005 §5)
ipcMain.handle('state:get-cash-out-hold', (): CashOutHold | null => getCashOutHold())
ipcMain.handle('state:set-cash-out-hold', (_event, hold: CashOutHold): CashOutHold => {
if (!hold || typeof hold.reason !== 'string' || typeof hold.since !== 'number') {
throw new Error('Invalid cash-out hold')
}
return setCashOutHold(hold)
})
ipcMain.handle('state:clear-cash-out-hold', (): boolean => clearCashOutHold())
// Dispense-report outbox (ADR-005 §2) — at-least-once to spirekeeper
ipcMain.handle('state:pending-dispense-reports', (_event, limit?: number) =>
pendingDispenseReports(typeof limit === 'number' ? limit : 20)
)
ipcMain.handle('state:ack-dispense-report', (_event, txid: string): boolean => {
if (typeof txid !== 'string' || !txid) throw new Error('Invalid txid')
return markDispenseReportAcked(txid)
})
ipcMain.handle(
'state:note-dispense-report-attempt',
(_event, txid: string, error: string | null): void => {
if (typeof txid !== 'string' || !txid) throw new Error('Invalid txid')
noteDispenseReportAttempt(txid, typeof error === 'string' ? error.slice(0, 512) : null)
}
)
ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => { ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
markStatePublished(unixTimestamp) markStatePublished(unixTimestamp)
}) })
@ -841,7 +873,7 @@ function startCommandPoller(): void {
recordTransaction({ recordTransaction({
txid, txid,
type: 'manual_dispense', type: 'manual_dispense',
status: result.dispensed ? 'complete' : 'dispense_error', status: result.dispenseConfirmed ? 'complete' : 'dispense_error',
fiatCents: totalFiatCents, fiatCents: totalFiatCents,
sats: 0, sats: 0,
feeSats: 0, feeSats: 0,
@ -860,7 +892,7 @@ function startCommandPoller(): void {
// Only remediate the original tx if ALL requested bills were dispensed // Only remediate the original tx if ALL requested bills were dispensed
let refRemediated = false let refRemediated = false
if (parsed.ref_txid && result.dispensed) { if (parsed.ref_txid && result.dispenseConfirmed) {
refRemediated = remediateTransaction(parsed.ref_txid, txid) refRemediated = remediateTransaction(parsed.ref_txid, txid)
} }
@ -868,7 +900,13 @@ function startCommandPoller(): void {
cmd.id, cmd.id,
JSON.stringify({ JSON.stringify({
txid, 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, ref_remediated: refRemediated,
error: result.error, error: result.error,
}) })

View file

@ -7,6 +7,24 @@
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
/** Mirrors state-store.CashOutHold (ADR-005 §5) — preload can't import main-process modules. */
interface CashOutHold {
reason: string
errorCode: string | null
rawCode: string | null
since: number
}
/** Mirrors state-store.PendingDispenseReport (ADR-005 §2). */
interface PendingDispenseReport {
txid: string
payload: unknown
createdAt: number
attempts: number
lastAttemptAt: number | null
lastError: string | null
}
/** /**
* Runtime configuration interface (public info only) * Runtime configuration interface (public info only)
* These values are read from environment variables at runtime (not build time) * These values are read from environment variables at runtime (not build time)
@ -114,6 +132,18 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('state:get-counts-uncertain-since'), ipcRenderer.invoke('state:get-counts-uncertain-since'),
markCountsUncertain: (unixTimestamp: number): Promise<void> => markCountsUncertain: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp), ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp),
// Cash-out hold (ADR-005 §5)
getCashOutHold: (): Promise<CashOutHold | null> => ipcRenderer.invoke('state:get-cash-out-hold'),
setCashOutHold: (hold: CashOutHold): Promise<CashOutHold> =>
ipcRenderer.invoke('state:set-cash-out-hold', hold),
clearCashOutHold: (): Promise<boolean> => ipcRenderer.invoke('state:clear-cash-out-hold'),
// Dispense-report outbox (ADR-005 §2)
pendingDispenseReports: (limit?: number): Promise<PendingDispenseReport[]> =>
ipcRenderer.invoke('state:pending-dispense-reports', limit),
ackDispenseReport: (txid: string): Promise<boolean> =>
ipcRenderer.invoke('state:ack-dispense-report', txid),
noteDispenseReportAttempt: (txid: string, error: string | null): Promise<void> =>
ipcRenderer.invoke('state:note-dispense-report-attempt', txid, error),
markStatePublished: (unixTimestamp: number): Promise<void> => markStatePublished: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-state-published', unixTimestamp), ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
@ -307,6 +337,12 @@ declare global {
getLastStatePublishedAt: () => Promise<number | null> getLastStatePublishedAt: () => Promise<number | null>
getCountsUncertainSince: () => Promise<number | null> getCountsUncertainSince: () => Promise<number | null>
markCountsUncertain: (unixTimestamp: number) => Promise<void> markCountsUncertain: (unixTimestamp: number) => Promise<void>
getCashOutHold: () => Promise<CashOutHold | null>
setCashOutHold: (hold: CashOutHold) => Promise<CashOutHold>
clearCashOutHold: () => Promise<boolean>
pendingDispenseReports: (limit?: number) => Promise<PendingDispenseReport[]>
ackDispenseReport: (txid: string) => Promise<boolean>
noteDispenseReportAttempt: (txid: string, error: string | null) => Promise<void>
markStatePublished: (unixTimestamp: number) => Promise<void> markStatePublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void> saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void> clearBunkerBinding: () => Promise<void>

View file

@ -10,12 +10,13 @@
*/ */
import Database from 'better-sqlite3' import Database from 'better-sqlite3'
import type { DispenseReportBody } from '@bitSpire/lnbits'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs' import fs from 'node:fs'
let db: Database.Database | null = null let db: Database.Database | null = null
const SCHEMA_VERSION = '13' const SCHEMA_VERSION = '14'
function getDbPath(): string { function getDbPath(): string {
const prodDir = '/var/lib/bitspire' const prodDir = '/var/lib/bitspire'
@ -136,6 +137,16 @@ export function initDatabase(dbPath?: string): void {
relays TEXT, relays TEXT,
lnbits_server_pubkey 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 // Seed meta + cashbox if first run, or run migrations
@ -418,6 +429,32 @@ export function initDatabase(dbPath?: string): void {
existing.value = '13' 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. // Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
// Seed the operator-config meta rows if they're missing (idempotent). // Seed the operator-config meta rows if they're missing (idempotent).
const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)') const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)')
@ -517,6 +554,133 @@ export function clearCountsUncertain(): void {
).run('countsUncertainSince', '') ).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<CashOutHold>
if (typeof parsed.since !== 'number' || typeof parsed.reason !== 'string') return null
return {
reason: parsed.reason,
errorCode: typeof parsed.errorCode === 'string' ? parsed.errorCode : null,
rawCode: typeof parsed.rawCode === 'string' ? parsed.rawCode : null,
since: parsed.since,
}
} catch {
return null
}
}
/** Latch cash-out off. Idempotent: an existing hold (and its `since`) is kept. */
export function setCashOutHold(hold: CashOutHold): CashOutHold {
if (!db) throw new Error('Database not initialized')
const existing = getCashOutHold()
if (existing) return existing
db.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run('cashOutHeld', JSON.stringify(hold))
console.warn(
`[StateStore] Cash-out HELD: ${hold.errorCode ?? 'fault'}${hold.rawCode ? ` ${hold.rawCode}` : ''} — ${hold.reason}`
)
return hold
}
/** Release the latch — an operator has cleared the machine. */
export function clearCashOutHold(): boolean {
if (!db) throw new Error('Database not initialized')
const had = getCashOutHold() !== null
db.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run('cashOutHeld', '')
if (had) console.log('[StateStore] Cash-out hold released')
return had
}
// ---------------------------------------------------------------------------
// Dispense-report outbox (ADR-005 §2)
// ---------------------------------------------------------------------------
export interface PendingDispenseReport {
txid: string
payload: DispenseReportBody
createdAt: number
attempts: number
lastAttemptAt: number | null
lastError: string | null
}
/** Unacknowledged reports, oldest first. The renderer applies the backoff. */
export function pendingDispenseReports(limit = 20): PendingDispenseReport[] {
if (!db) throw new Error('Database not initialized')
const rows = db
.prepare(
'SELECT txid, payload, created_at, attempts, last_attempt_at, last_error FROM dispense_reports WHERE acked_at IS NULL ORDER BY created_at ASC LIMIT ?'
)
.all(limit) as Array<{
txid: string
payload: string
created_at: number
attempts: number
last_attempt_at: number | null
last_error: string | null
}>
const out: PendingDispenseReport[] = []
for (const r of rows) {
try {
out.push({
txid: r.txid,
payload: JSON.parse(r.payload) as DispenseReportBody,
createdAt: r.created_at,
attempts: r.attempts,
lastAttemptAt: r.last_attempt_at,
lastError: r.last_error,
})
} catch {
console.error('[StateStore] dispense_reports row has unparseable payload:', r.txid)
}
}
return out
}
/** The server acknowledged this report. Returns whether a row changed. */
export function markDispenseReportAcked(txid: string): boolean {
if (!db) throw new Error('Database not initialized')
const res = db
.prepare('UPDATE dispense_reports SET acked_at = ? WHERE txid = ? AND acked_at IS NULL')
.run(Date.now(), txid)
if (res.changes > 0) console.log('[StateStore] Dispense report acked:', txid)
return res.changes > 0
}
/** A send was attempted and did not get an OK. Drives the resend backoff. */
export function noteDispenseReportAttempt(txid: string, error: string | null): void {
if (!db) throw new Error('Database not initialized')
db.prepare(
'UPDATE dispense_reports SET attempts = attempts + 1, last_attempt_at = ?, last_error = ? WHERE txid = ?'
).run(Date.now(), error, txid)
}
/** /**
* A counter bumped on every local change to a bay count, from any cause. * A counter bumped on every local change to a bay count, from any cause.
* *
@ -851,7 +1015,12 @@ export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult {
// A recount is an operator opening the bay and counting it, which is // A recount is an operator opening the bay and counting it, which is
// exactly what resolves an unverified count. Nothing else does: a refill // exactly what resolves an unverified count. Nothing else does: a refill
// adds to a number still known to be wrong. // 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( console.log(
@ -1173,6 +1342,11 @@ interface TransactionInput {
rejected: number rejected: number
}[] }[]
error?: string | null error?: string | null
/**
* ADR-005 §2: dispense outcome to queue for spirekeeper. Inserted in the
* same transaction as the row so a crash between them cannot lose it.
*/
report?: DispenseReportBody
} }
/** /**
@ -1194,6 +1368,12 @@ export function recordTransaction(tx: TransactionInput): void {
const insertBill = db.prepare( const insertBill = db.prepare(
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)' '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( const insertCassetteBill = db.prepare(
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
) )
@ -1226,6 +1406,10 @@ export function recordTransaction(tx: TransactionInput): void {
insertBill.run(t.txid, bill.denomination, bill.count) 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 // Insert per-cassette detail when available
if (t.cassettes) { if (t.cassettes) {
for (const c of t.cassettes) { for (const c of t.cassettes) {

View file

@ -29,8 +29,14 @@ interface UseAvailabilityBroadcastOptions {
signer: Signer signer: Signer
/** Reactive inventory: denomination -> count */ /** Reactive inventory: denomination -> count */
inventory: Ref<Record<number, number>> inventory: Ref<Record<number, number>>
/** Reactive Lightning.Pub balance in sats (null = unknown) */ /** Reactive wallet balance in sats (null = unknown) */
balanceSats: Ref<number | null> balanceSats: Ref<number | null>
/**
* 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<boolean>
/** Fiat currency code */ /** Fiat currency code */
fiatCode: string fiatCode: string
/** Machine model */ /** Machine model */
@ -38,7 +44,7 @@ interface UseAvailabilityBroadcastOptions {
} }
export function useAvailabilityBroadcast(options: 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 let lastSnapshot: AvailabilitySnapshot | null = null
@ -53,7 +59,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
function computeSnapshot(): AvailabilitySnapshot { function computeSnapshot(): AvailabilitySnapshot {
const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0) const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0)
return { return {
cashOut: totalBills > 0, cashOut: totalBills > 0 && !(cashOutHeld?.value ?? false),
cashIn: (balanceSats.value ?? 0) > 0, cashIn: (balanceSats.value ?? 0) > 0,
cashLevel: computeCashLevel(), cashLevel: computeCashLevel(),
} }
@ -101,7 +107,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
// Watch reactive sources // Watch reactive sources
watch( watch(
[inventory, balanceSats], cashOutHeld ? [inventory, balanceSats, cashOutHeld] : [inventory, balanceSats],
() => { () => {
debouncedPublish() debouncedPublish()
}, },

View file

@ -147,8 +147,10 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
dispensed: 0, dispensed: 0,
rejected: 0, rejected: 0,
})), })),
dispensed: false, dispenseConfirmed: false,
error: `No cassette loaded with denomination: ${denomination}`, error: `No cassette loaded with denomination: ${denomination}`,
errorCode: 'NoCassetteForDenomination',
errorClass: 'inventory',
} }
} }
notes[idx] = count notes[idx] = count
@ -182,11 +184,23 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
rejected: c.rejected, rejected: c.rejected,
})) }))
const totalRequested = amounts.reduce((s, a) => 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 totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
const dispenseConfirmed = requestedValue === dispensedValue
if (result.error) { 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) // Wait for customer to take bills (only if bills were dispensed)
@ -195,7 +209,18 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
console.log('[HAL] Bills removed by customer') console.log('[HAL] Bills removed by customer')
} }
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed } if (!dispenseConfirmed) {
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 }
}, },
getInventory: async () => { getInventory: async () => {

View file

@ -14,7 +14,7 @@
import { NostrClient, type Signer } from '@bitSpire/nostr-client' import { NostrClient, type Signer } from '@bitSpire/nostr-client'
import { resolveSigner } from './signer-resolver.js' 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 { CLINKClient } from '@bitSpire/clink'
import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink' import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink'
import type { ATMServices, ATMContext } from '@bitSpire/state-machine' import type { ATMServices, ATMContext } from '@bitSpire/state-machine'
@ -223,6 +223,8 @@ export interface LightningBackend {
} }
interface LightningServices { interface LightningServices {
/** ADR-005 §2: send one cash-out's dispense outcome to spirekeeper (outbox-driven). */
reportDispense: (body: DispenseReportBody) => Promise<DispenseReportAck>
nostrClient: NostrClient nostrClient: NostrClient
lightningPub: LightningBackend lightningPub: LightningBackend
clink: CLINKClient clink: CLINKClient
@ -686,6 +688,11 @@ export async function initializeLightningServices(options?: {
signer, signer,
operatorPubkeys: CONFIG.operatorPubkeys, operatorPubkeys: CONFIG.operatorPubkeys,
atmServices, 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) => { onOfferRequest: (callback: OfferRequestCallback) => {
offerRequestCallback = callback offerRequestCallback = callback
}, },
@ -742,7 +749,7 @@ export function createATMServices(
subId: string | null subId: string | null
/** Preimage seen before a consumer attached; replayed on attach. */ /** Preimage seen before a consumer attached; replayed on attach. */
settled: string | null settled: string | null
consumer: ((preimage: string) => void) | null consumer: ((preimage: string, paymentHash: string) => void) | null
poll: ReturnType<typeof setInterval> | null poll: ReturnType<typeof setInterval> | null
released: boolean released: boolean
} }
@ -765,7 +772,7 @@ export function createATMServices(
watch.settled = preimage watch.settled = preimage
stopInvoiceWatchPoll(watch) stopInvoiceWatchPoll(watch)
console.log(`[ATM Service] Invoice paid (${via})!`) console.log(`[ATM Service] Invoice paid (${via})!`)
watch.consumer?.(preimage) watch.consumer?.(preimage, watch.paymentHash)
} }
function startInvoiceWatchPoll(watch: InvoiceWatch): void { function startInvoiceWatchPoll(watch: InvoiceWatch): void {
@ -1106,7 +1113,7 @@ export function createATMServices(
dispensed: a.count, dispensed: a.count,
rejected: 0, rejected: 0,
})), })),
dispensed: true, dispenseConfirmed: true,
} }
}, },
@ -1206,7 +1213,10 @@ export function createATMServices(
* Watch a BOLT11 invoice for payment via LNbits subscribe_payments * Watch a BOLT11 invoice for payment via LNbits subscribe_payments
* push, filtered by payment_hash. Returns a cleanup function. * 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')) { if (!invoice.toLowerCase().startsWith('ln')) {
console.error('[ATM Service] Invalid invoice format - expected BOLT11') console.error('[ATM Service] Invalid invoice format - expected BOLT11')
return () => {} return () => {}
@ -1221,7 +1231,7 @@ export function createATMServices(
// on a push that has already come and gone. // on a push that has already come and gone.
if (armed.settled) { if (armed.settled) {
const preimage = armed.settled const preimage = armed.settled
queueMicrotask(() => callback(preimage)) queueMicrotask(() => callback(preimage, armed.paymentHash))
} }
return () => releaseInvoiceWatch(invoice) return () => releaseInvoiceWatch(invoice)
} }
@ -1244,7 +1254,7 @@ export function createATMServices(
const late = invoiceWatches.get(invoice) const late = invoiceWatches.get(invoice)
if (!late || cancelled) return if (!late || cancelled) return
late.consumer = callback late.consumer = callback
if (late.settled) callback(late.settled) if (late.settled) callback(late.settled, late.paymentHash)
} catch (e) { } catch (e) {
console.error('[ATM Service] LNbits watchInvoice failed:', e) console.error('[ATM Service] LNbits watchInvoice failed:', e)
} }

View file

@ -44,7 +44,7 @@ const KIND_NIP78 = 30078
/** The wire schema this machine speaks. Operations, not counts (ADR-004). */ /** The wire schema this machine speaks. Operations, not counts (ADR-004). */
const CASSETTE_SCHEMA_VERSION = 2 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 = { type CassetteOp = {
id: string id: string
at: number at: number
@ -55,6 +55,18 @@ type CassetteOp = {
denomination?: number 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. */ /** Accept operator events stamped up to this many seconds in the future. */
const MAX_FUTURE_SKEW_S = 60 const MAX_FUTURE_SKEW_S = 60
@ -85,6 +97,12 @@ export interface OperatorConfigServiceConfig {
operatorPubkeys: string[] operatorPubkeys: string[]
/** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */ /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
machineId?: string 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 { export interface OperatorConfigService {
@ -222,7 +240,28 @@ async function handleOperatorConfigEvent(
console.error('[OperatorConfig] Payload missing `ops` array — dropped') console.error('[OperatorConfig] Payload missing `ops` array — dropped')
return 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 // 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 // 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 // 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 // actively harmful — an event arriving out of order can still carry an
// operation this machine has never seen. // 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) { for (const bad of result.rejected) {
console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`) 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) { if (result.applied.length === 0) {
console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`) console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`)
// Still republish: the operator learns from our applied_ops echo that // Still republish: the operator learns from our applied_ops echo that
@ -318,6 +366,15 @@ async function publishCassettesState(
applied_ops: appliedOps, applied_ops: appliedOps,
} }
if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince 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)) const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload))
// Force the stamp strictly above our last one. Addressable events are ordered // Force the stamp strictly above our last one. Addressable events are ordered

View file

@ -1,4 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type { DispenseReportBody } from '@bitSpire/lnbits'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { import {
createATMMachine, createATMMachine,
@ -9,6 +10,7 @@ import {
type SnapshotFrom, type SnapshotFrom,
type ATMMachine, type ATMMachine,
type AccessRole, type AccessRole,
type DispenseCashResult,
} from '@bitSpire/state-machine' } from '@bitSpire/state-machine'
import type { AccessControlConfig, CardSession } from '@/types/electron' import type { AccessControlConfig, CardSession } from '@/types/electron'
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning' import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
@ -126,7 +128,7 @@ async function handleManagementCommand(
await persistTransaction({ await persistTransaction({
txid, txid,
type: 'manual_dispense', type: 'manual_dispense',
status: result.dispensed ? 'complete' : 'dispense_error', status: result.dispenseConfirmed ? 'complete' : 'dispense_error',
fiatCents: totalFiatCents, fiatCents: totalFiatCents,
sats: 0, sats: 0,
feeSats: 0, feeSats: 0,
@ -141,7 +143,7 @@ async function handleManagementCommand(
// Only remediate the original tx if ALL requested bills were dispensed // Only remediate the original tx if ALL requested bills were dispensed
let refRemediated = false 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) refRemediated = await window.electronAPI.remediateTransaction(request.ref_txid, txid)
if (refRemediated) { if (refRemediated) {
console.log('[ATM] Remediated failed tx:', request.ref_txid) console.log('[ATM] Remediated failed tx:', request.ref_txid)
@ -192,6 +194,62 @@ async function loadInventoryFromDb(): Promise<Record<number, number> | null> {
/** /**
* Persist a completed transaction to SQLite via IPC. * 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<number, number>()
for (const a of ctx.dispenseAmounts) {
requestedByDenom.set(a.denomination, (requestedByDenom.get(a.denomination) ?? 0) + a.count)
}
const seen = new Set<number>()
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<void> { async function persistTransaction(tx: TransactionRecord): Promise<void> {
if (isElectron && window.electronAPI) { if (isElectron && window.electronAPI) {
try { try {
@ -254,7 +312,7 @@ const mockServices: ATMServices = {
dispensed: a.count, dispensed: a.count,
rejected: 0, rejected: 0,
})), })),
dispensed: true, dispenseConfirmed: true,
} }
}, },
@ -401,6 +459,12 @@ export const useAtmStore = defineStore('atm', () => {
) )
let stopBalanceWatch: (() => void) | null = null let stopBalanceWatch: (() => void) | null = null
let pricePollingInterval: ReturnType<typeof setInterval> | null = null let pricePollingInterval: ReturnType<typeof setInterval> | 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<unknown>) | null = null
let dispenseReportFlushInterval: ReturnType<typeof setInterval> | null = null
let dispenseReportFlushing = false
// Store reference to ATM services for direct calls // Store reference to ATM services for direct calls
let atmServicesRef: ATMServices | null = null let atmServicesRef: ATMServices | null = null
@ -618,13 +682,31 @@ export const useAtmStore = defineStore('atm', () => {
send({ type: 'CASH_DISPENSED' }) send({ type: 'CASH_DISPENSED' })
} }
// Record failed cash-out dispenses (sats debited but cash not dispensed) // ADR-005 §5: persist the cash-out hold the moment the machine sets it,
if (currentNested === 'dispenseError' && prevNestedState !== 'dispenseError') { // 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 const ctx = newSnapshot.context
if (ctx.txid) { if (ctx.txid) {
const dr = ctx.dispenseResult const dr = ctx.dispenseResult
// Determine status from dispense result (if available)
let status: 'dispense_error' | 'partial' = 'dispense_error' let status: 'dispense_error' | 'partial' = 'dispense_error'
let bills: { denomination: number; count: number }[] = [] let bills: { denomination: number; count: number }[] = []
@ -634,6 +716,23 @@ export const useAtmStore = defineStore('atm', () => {
bills = dr.bills bills = dr.bills
.filter((b) => b.dispensed > 0) .filter((b) => b.dispensed > 0)
.map((b) => ({ denomination: b.denomination, count: b.dispensed })) .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 { } else {
// The dispenser threw, or the dispense timed out, so there is no // The dispenser threw, or the dispense timed out, so there is no
// per-bay report. Bills may well have reached the customer, and // per-bay report. Bills may well have reached the customer, and
@ -649,6 +748,11 @@ export const useAtmStore = defineStore('atm', () => {
.catch((e) => console.warn('[ATM] Could not flag counts unverified:', e)) .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({ persistTransaction({
txid: ctx.txid, txid: ctx.txid,
type: 'cash_out', type: 'cash_out',
@ -662,10 +766,14 @@ export const useAtmStore = defineStore('atm', () => {
bills, bills,
cassettes: dr?.cassettes, cassettes: dr?.cassettes,
error: dr?.error ?? ctx.error, error: dr?.error ?? ctx.error,
// ADR-005 §2 — queued in the same SQLite transaction as the row.
report: buildDispenseReport(ctx, dr, countsUncertain),
}) })
.then(() => reloadPersistedInventory()) .then(() => reloadPersistedInventory())
// Republish cassette state — a partial dispense changed counts. // Republish cassette state — a partial dispense changed counts, and
// the payload now carries the hold / unverified flags.
.then(() => operatorConfigSvc?.publishCassettesState()) .then(() => operatorConfigSvc?.publishCassettesState())
.then(() => flushDispenseReports())
} }
} }
@ -695,11 +803,15 @@ export const useAtmStore = defineStore('atm', () => {
bills, bills,
cassettes: dr?.cassettes, cassettes: dr?.cassettes,
error: dr?.error, 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()) .then(() => reloadPersistedInventory())
// Republish cassette state after a cash-out dispense (counts // Republish cassette state after a cash-out dispense (counts
// decremented); harmless no-op echo for a cash-in complete. // decremented); harmless no-op echo for a cash-in complete.
.then(() => (isCashInTx ? undefined : operatorConfigSvc?.publishCassettesState())) .then(() => (isCashInTx ? undefined : operatorConfigSvc?.publishCassettesState()))
.then(() => (isCashInTx ? undefined : flushDispenseReports()))
} }
} }
@ -742,6 +854,23 @@ export const useAtmStore = defineStore('atm', () => {
setupNfcListener() setupNfcListener()
setupCassettesChangedListener() setupCassettesChangedListener()
console.log('[ATM] State machine initialized') 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) ─────────────────────────────────── // ── Bolt Card cash-out (NFC tap-to-pay) ───────────────────────────────────
@ -1031,6 +1160,8 @@ export const useAtmStore = defineStore('atm', () => {
try { try {
const services = await initializeLightningServices({ strict: !allowMockFallback.value }) const services = await initializeLightningServices({ strict: !allowMockFallback.value })
reportDispenseFn = services.reportDispense
startDispenseReportFlusher()
useLiveServices.value = true useLiveServices.value = true
connectionStatus.value = 'connected' connectionStatus.value = 'connected'
console.log('[ATM] Connected to Lightning.Pub!') console.log('[ATM] Connected to Lightning.Pub!')
@ -1043,6 +1174,8 @@ export const useAtmStore = defineStore('atm', () => {
services.nostrClient.on('connect', () => { services.nostrClient.on('connect', () => {
connectionStatus.value = 'connected' connectionStatus.value = 'connected'
console.log('[ATM] Relay reconnected') 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 // Store references to clients for direct operations
@ -1135,6 +1268,7 @@ export const useAtmStore = defineStore('atm', () => {
nostrClient: services.nostrClient, nostrClient: services.nostrClient,
signer: services.signer, signer: services.signer,
operatorPubkeys: services.operatorPubkeys, operatorPubkeys: services.operatorPubkeys,
onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }),
}) })
// Start operator-fees consumer (aiolabs/lamassu-next#57) — subscribes // Start operator-fees consumer (aiolabs/lamassu-next#57) — subscribes
@ -1329,6 +1463,8 @@ export const useAtmStore = defineStore('atm', () => {
// Initialize Lightning services // Initialize Lightning services
const lightning = await initializeLightningServices({ strict: !allowMockFallback.value }) const lightning = await initializeLightningServices({ strict: !allowMockFallback.value })
reportDispenseFn = lightning.reportDispense
startDispenseReportFlusher()
useLiveServices.value = true useLiveServices.value = true
connectionStatus.value = 'connected' connectionStatus.value = 'connected'
lightningPub.value = lightning.lightningPub lightningPub.value = lightning.lightningPub
@ -1461,6 +1597,7 @@ export const useAtmStore = defineStore('atm', () => {
nostrClient: lightning.nostrClient, nostrClient: lightning.nostrClient,
signer: lightning.signer, signer: lightning.signer,
operatorPubkeys: lightning.operatorPubkeys, operatorPubkeys: lightning.operatorPubkeys,
onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }),
}) })
// Operator-fees consumer (aiolabs/lamassu-next#57) // Operator-fees consumer (aiolabs/lamassu-next#57)
@ -1621,6 +1758,8 @@ export const useAtmStore = defineStore('atm', () => {
// Initialize Lightning services // Initialize Lightning services
const lightning = await initializeLightningServices({ strict: !allowMockFallback.value }) const lightning = await initializeLightningServices({ strict: !allowMockFallback.value })
reportDispenseFn = lightning.reportDispense
startDispenseReportFlusher()
useLiveServices.value = true useLiveServices.value = true
connectionStatus.value = 'connected' connectionStatus.value = 'connected'
lightningPub.value = lightning.lightningPub lightningPub.value = lightning.lightningPub
@ -1797,6 +1936,7 @@ export const useAtmStore = defineStore('atm', () => {
nostrClient: lightning.nostrClient, nostrClient: lightning.nostrClient,
signer: lightning.signer, signer: lightning.signer,
operatorPubkeys: lightning.operatorPubkeys, operatorPubkeys: lightning.operatorPubkeys,
onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }),
}) })
// Operator-fees consumer (aiolabs/lamassu-next#57) // Operator-fees consumer (aiolabs/lamassu-next#57)
@ -1899,6 +2039,11 @@ export const useAtmStore = defineStore('atm', () => {
send({ type: 'SELECT_CASH_IN' }) send({ type: 'SELECT_CASH_IN' })
} }
/** Customer dismisses the dispense-fault screen ("I've saved this reference"). */
function acknowledgeFault() {
send({ type: 'ACKNOWLEDGE_FAULT' })
}
function selectCashOut() { function selectCashOut() {
settlementError.value = null settlementError.value = null
send({ type: 'SELECT_CASH_OUT' }) send({ type: 'SELECT_CASH_OUT' })
@ -1983,7 +2128,59 @@ export const useAtmStore = defineStore('atm', () => {
pricePollingInterval = setInterval(poll, 30_000) 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<void> {
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() { function stopPricePolling() {
// Both are store-lifetime intervals; whoever stops one stops the other.
stopDispenseReportFlusher()
if (pricePollingInterval) { if (pricePollingInterval) {
clearInterval(pricePollingInterval) clearInterval(pricePollingInterval)
pricePollingInterval = null pricePollingInterval = null
@ -2004,6 +2201,8 @@ export const useAtmStore = defineStore('atm', () => {
signer, signer,
inventory: persistedInventory, inventory: persistedInventory,
balanceSats, balanceSats,
// ADR-005 §5: a latched machine must not advertise cash-out.
cashOutHeld: computed(() => snapshot.value?.context.cashOutHeld != null),
fiatCode: fiatCode.value, fiatCode: fiatCode.value,
model, model,
}) })
@ -2069,6 +2268,7 @@ export const useAtmStore = defineStore('atm', () => {
send, send,
selectCashIn, selectCashIn,
selectCashOut, selectCashOut,
acknowledgeFault,
cancel, cancel,
insertBill, insertBill,
finishInserting, finishInserting,

View file

@ -150,6 +150,33 @@ declare global {
/** When the bay counts became unverified (a dispense that reported nothing), or null. */ /** When the bay counts became unverified (a dispense that reported nothing), or null. */
getCountsUncertainSince: () => Promise<number | null> getCountsUncertainSince: () => Promise<number | null>
markCountsUncertain: (unixTimestamp: number) => Promise<void> markCountsUncertain: (unixTimestamp: number) => Promise<void>
// 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<boolean>
// 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<boolean>
noteDispenseReportAttempt: (txid: string, error: string | null) => Promise<void>
markStatePublished: (unixTimestamp: number) => Promise<void> markStatePublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void> saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void> clearBunkerBinding: () => Promise<void>

View file

@ -34,6 +34,12 @@ export interface TransactionRecord {
}[] }[]
error?: string | null error?: string | null
remediatedBy?: 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 { export interface ATMAvailability {

View file

@ -63,13 +63,19 @@ watch(
const nestedState = computed(() => atmStore.nestedState) const nestedState = computed(() => atmStore.nestedState)
const context = computed(() => atmStore.context) 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<string, number> = { dispenseFault: 120, outOfCash: 30 }
const dispenseErrorCountdown = ref(30) const dispenseErrorCountdown = ref(30)
let countdownTimer: ReturnType<typeof setInterval> | null = null let countdownTimer: ReturnType<typeof setInterval> | null = null
watch(nestedState, (newState, oldState) => { watch(nestedState, (newState, oldState) => {
if (newState === 'dispenseError' && oldState !== 'dispenseError') { const entering = typeof newState === 'string' && newState in TERMINAL_SECONDS
dispenseErrorCountdown.value = 30 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(() => { countdownTimer = setInterval(() => {
dispenseErrorCountdown.value-- dispenseErrorCountdown.value--
if (dispenseErrorCountdown.value <= 0 && countdownTimer) { if (dispenseErrorCountdown.value <= 0 && countdownTimer) {
@ -77,12 +83,21 @@ watch(nestedState, (newState, oldState) => {
countdownTimer = null countdownTimer = null
} }
}, 1000) }, 1000)
} else if (oldState === 'dispenseError' && countdownTimer) { } else if (leaving && !entering && countdownTimer) {
clearInterval(countdownTimer) clearInterval(countdownTimer)
countdownTimer = null countdownTimer = null
} }
}) })
function acknowledgeFault() {
atmStore.acknowledgeFault()
}
const faultTime = computed(() => {
const t = context.value?.startedAt
return t ? new Date(t).toLocaleString() : ''
})
// Available denominations from inventory // Available denominations from inventory
const availableDenominations = computed(() => { const availableDenominations = computed(() => {
if (!context.value?.inventory) return [] if (!context.value?.inventory) return []
@ -535,63 +550,92 @@ function formatFiat(cents: number): string {
</div> </div>
</div> </div>
<!-- Dispense Error (timed, 30s → idle) --> <!-- Dispense fault / could-not-dispense (ADR-005 §4).
Both reach here AFTER payment: the customer has paid and received
less than they paid for. dispenseFault = the dispenser reported an
error (and may have latched cash-out off); outOfCash = it reported
none. Either way: evidence on screen, operator notified. The raw
dispenser code is deliberately NOT shown — it travels in the report. -->
<div <div
v-else-if="nestedState === 'dispenseError'" v-else-if="nestedState === 'dispenseFault' || nestedState === 'outOfCash'"
key="dispenseError" key="dispenseTerminal"
class="flex flex-1 flex-col lg:flex-row items-center justify-center gap-6 lg:gap-10 p-4 lg:p-8" class="flex flex-1 flex-col lg:flex-row items-center justify-center gap-6 lg:gap-10 p-4 lg:p-8"
style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))" style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
> >
<!-- Left side — error details --> <div class="flex flex-col items-center gap-3 lg:gap-5 max-w-xl">
<div class="flex flex-col items-center gap-3 lg:gap-5">
<div class="text-5xl lg:text-[8vh]">⚠️</div> <div class="text-5xl lg:text-[8vh]">⚠️</div>
<h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">Dispense Error</h3> <h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">
<p class="text-base lg:text-2xl text-muted-foreground"> {{ nestedState === 'dispenseFault' ? 'Dispenser fault' : 'Could not dispense' }}
{{ context?.error || 'Cash could not be dispensed' }} </h3>
<p class="text-base lg:text-2xl text-foreground text-center font-semibold">
Your payment went through. The cash below could not be dispensed.
</p> </p>
<!-- Partial dispense info --> <div class="w-full rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2">
<div <div class="flex justify-between text-base lg:text-2xl">
v-if="context?.dispenseResult?.bills?.length" <span class="text-muted-foreground">You paid</span>
class="w-full max-w-md rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2" <span class="font-semibold"
> >{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents ?? 0) / 100).toFixed(2) }}
<span class="text-muted-foreground text-sm lg:text-lg"
>({{ (context?.satsAmount ?? 0).toLocaleString() }} sats)</span
></span
>
</div>
<div <div
v-for="bill in context.dispenseResult.bills" v-for="bill in context?.dispenseResult?.bills ?? []"
:key="bill.denomination" :key="bill.denomination"
class="flex justify-between text-base lg:text-2xl" class="flex justify-between text-base lg:text-2xl"
> >
<span class="text-muted-foreground" <span class="text-muted-foreground"
>{{ atmStore.fiatSymbol }}{{ bill.denomination }}</span >{{ atmStore.fiatSymbol }}{{ bill.denomination }} notes</span
> >
<span :class="bill.dispensed > 0 ? 'text-success' : 'text-destructive'"> <span :class="bill.dispensed > 0 ? 'text-success' : 'text-destructive'">
{{ bill.dispensed }} dispensed {{ bill.dispensed }} dispensed
<span v-if="bill.rejected > 0" class="text-destructive">
({{ bill.rejected }} rejected)
</span>
</span> </span>
</div> </div>
</div> </div>
<p class="text-sm lg:text-lg text-muted-foreground"> <p class="text-base lg:text-xl text-foreground text-center">
Please contact support with the transaction ID below. The operator has been notified and holds a record of this transaction.
<strong>Keep this reference</strong> — photograph it or write it down.
</p>
<p v-if="context?.error" class="text-xs lg:text-sm text-muted-foreground text-center">
Technical detail: {{ context.error }}
</p> </p>
<!-- Countdown --> <div class="flex flex-wrap items-center justify-center gap-3">
<Button
v-if="nestedState === 'dispenseFault'"
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black"
size="kiosk"
@click="acknowledgeFault"
>
I've saved this
</Button>
<Button variant="outline" size="kiosk" @click="cancel"> Return to Start </Button>
</div>
<p class="text-sm lg:text-base text-muted-foreground"> <p class="text-sm lg:text-base text-muted-foreground">
Returning to start in {{ dispenseErrorCountdown }}s Returning to start in {{ dispenseErrorCountdown }}s
</p> </p>
<Button variant="outline" size="kiosk" @click="cancel"> Return to Start </Button>
</div> </div>
<!-- Right side — txid QR --> <div v-if="context?.txid" class="flex flex-col items-center gap-3">
<div v-if="context?.txid" class="flex flex-col items-center gap-4"> <QRCode :value="context.txid" :size="260" />
<QRCode :value="context.txid" :size="280" /> <p class="text-xs lg:text-sm text-muted-foreground">Transaction</p>
<p <p
class="font-mono-code text-sm text-muted-foreground max-w-[300px] text-center break-all" class="font-mono-code text-sm lg:text-base text-foreground max-w-[320px] text-center break-all"
> >
{{ context.txid }} {{ context.txid }}
</p> </p>
<template v-if="context?.paymentHash">
<p class="text-xs lg:text-sm text-muted-foreground">Payment hash</p>
<p
class="font-mono-code text-xs lg:text-sm text-foreground max-w-[320px] text-center break-all"
>
{{ context.paymentHash }}
</p>
</template>
<p v-if="faultTime" class="text-xs lg:text-sm text-muted-foreground">{{ faultTime }}</p>
</div> </div>
</div> </div>

View file

@ -121,16 +121,32 @@ function handleCashOut() {
> >
</button> </button>
<!-- Sell Bitcoin --> <!-- Sell Bitcoin — disabled while cash-out is held after a terminal
dispenser fault (ADR-005 §5). The state machine refuses
SELECT_CASH_OUT regardless; this just tells the customer why. -->
<button <button
class="flex aspect-square w-40 lg:w-[36vh] flex-col items-center justify-center gap-1.5 lg:gap-4 rounded-full border-2 border-success/50 bg-success/10 transition-all active:scale-[0.97]" class="flex aspect-square w-40 lg:w-[36vh] flex-col items-center justify-center gap-1.5 lg:gap-4 rounded-full border-2 transition-all"
:class="
atmStore.context?.cashOutHeld
? 'border-muted-foreground/30 bg-muted/20 opacity-60 cursor-not-allowed'
: 'border-success/50 bg-success/10 active:scale-[0.97]'
"
:disabled="!!atmStore.context?.cashOutHeld"
@click="handleCashOut" @click="handleCashOut"
> >
<span class="text-4xl lg:text-[8vh] leading-none">💵</span> <span class="text-4xl lg:text-[8vh] leading-none">{{
<span class="text-base lg:text-[3.5vh] font-bold text-success">Sell Bitcoin</span> atmStore.context?.cashOutHeld ? '🔧' : '💵'
<span class="text-[10px] lg:text-[1.8vh] text-foreground/70" }}</span>
>Pay invoice, receive cash</span <span
class="text-base lg:text-[3.5vh] font-bold"
:class="atmStore.context?.cashOutHeld ? 'text-muted-foreground' : 'text-success'"
>Sell Bitcoin</span
> >
<span class="text-[10px] lg:text-[1.8vh] text-foreground/70 text-center px-3">{{
atmStore.context?.cashOutHeld
? 'Temporarily unavailable — operator notified'
: 'Pay invoice, receive cash'
}}</span>
</button> </button>
</div> </div>

View file

@ -195,10 +195,16 @@ instead of a clean ledger.
Two distinct terminal states replace the single `dispenseError`: Two distinct terminal states replace the single `dispenseError`:
- **`outOfCash`** — the request could not be met from inventory and the dispenser reported - **`outOfCash`** — the request could not be met and the dispenser reported **no error**
**no error**. Nothing was charged beyond what was dispensed. (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 - **`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 `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, txid as QR (as now) **and as text**, the first 12 characters of the payment hash, the time,

View file

@ -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<DispenseError>).errorCode === 'string' &&
typeof (err as Partial<DispenseError>).errorClass === 'string'
)
}

View file

@ -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)
})
})

View file

@ -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)
}
})
})

View file

@ -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<string, F56ErrorEntry> = {
'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<string, F56ErrorEntry> = {
'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 })),
]
}

View file

@ -134,6 +134,8 @@ export async function initialize(currency: string, denominations: number[]): Pro
export interface BillCountResult { export interface BillCountResult {
bills: Array<{ dispensed: number; rejected: number }> bills: Array<{ dispensed: number; rejected: number }>
error?: Error 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<BillCountResult> { export async function billCount(counts: number[]): Promise<BillCountResult> {
@ -172,9 +174,10 @@ export async function billCount(counts: number[]): Promise<BillCountResult> {
if (res[0] === 0xf0) { if (res[0] === 0xf0) {
console.log('response', res) console.log('response', res)
const errorCode = res.subarray(3, 5) const rawCode = prettyHex(res.subarray(3, 5))
response.error = new Error(`Dispensing, code: ${prettyHex(errorCode)}`) response.rawCode = rawCode
console.error(`found error code: ${prettyHex(errorCode)}`) response.error = new Error(`Dispensing, code: ${rawCode}`)
console.error(`found error code: ${rawCode}`)
} }
return response return response

View file

@ -8,6 +8,8 @@
*/ */
import * as f56 from './f56-rs232.js' import * as f56 from './f56-rs232.js'
import { decodeF56Error } from './error-codes.js'
import { tagDispenseError, type DispenseError } from '../error-codes.js'
import type { import type {
BillDispenser, BillDispenser,
DispenserConfig, 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 { try {
const { bills, error } = await f56.billCount(notes) const { bills, error, rawCode } = await f56.billCount(notes)
if (error) { if (error) {
await this.close() await this.close()
;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError' return { value: bills, error: tagDispenseError(error, decodeF56Error(rawCode)) }
;(error as Error & { statusCode: number }).statusCode = 570
} }
return { value: bills, error } return { value: bills }
} catch (err) { } catch (err) {
await this.close() await this.close()
const error = err as Error const error = err instanceof Error ? err : new Error(String(err))
;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError' // No frame: serial timeout / framing. decodeF56Error(undefined) → terminal.
;(error as Error & { statusCode: number }).statusCode = 570 return { value: [], error: tagDispenseError(error, decodeF56Error(undefined)) }
return { value: [], error }
} }
} }

View file

@ -14,6 +14,7 @@ import type {
DispenserInitData, DispenserInitData,
DispenseResult, DispenseResult,
} from '../../types.js' } from '../../types.js'
import { tagDispenseError, type DispenseError } from '../error-codes.js'
export class PuloonDispenser implements BillDispenser { export class PuloonDispenser implements BillDispenser {
public type: string = 'Puloon' 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) const { bills, error } = await this.device.dispense(notes)
if (error) { if (error) {
await this.close() await this.close()
error.name = 'PuloonDispenseError'
console.log('PULOON | dispense error', error) 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<void> { async close(): Promise<void> {

View file

@ -57,5 +57,20 @@ export type {
DispenserFactory, DispenserFactory,
} from './types.js' } 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 // Utilities
export { compute as computeCrc } from './utils/crc.js' export { compute as computeCrc } from './utils/crc.js'

View file

@ -1,3 +1,5 @@
import type { DispenseError } from './dispensers/error-codes.js'
import { EventEmitter } from 'node:events' import { EventEmitter } from 'node:events'
/** /**
@ -176,7 +178,8 @@ export interface BillDispenser {
*/ */
dispense(notes: number[]): Promise<{ dispense(notes: number[]): Promise<{
value: DispenseResult[] value: DispenseResult[]
error?: Error /** Tagged with the ADR-005 taxonomy — see dispensers/error-codes.ts */
error?: DispenseError
}> }>
/** /**

View file

@ -48,6 +48,8 @@ import type {
CreateWithdrawResult, CreateWithdrawResult,
LnbitsWithdrawLink, LnbitsWithdrawLink,
UniqueHashesResponse, UniqueHashesResponse,
DispenseReportBody,
DispenseReportAck,
} from './types.js' } from './types.js'
const LNBITS_KIND_RPC = 21000 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<DispenseReportAck> {
return this.idempotent(() => this.sendRpc<DispenseReportAck>('report_dispense', { body }))
}
// ============================================================================ // ============================================================================
// Invoices // Invoices
// ============================================================================ // ============================================================================

View file

@ -80,4 +80,8 @@ export type {
UniqueHashEntry, UniqueHashEntry,
UniqueHashesResponse, UniqueHashesResponse,
LnbitsPayLink, LnbitsPayLink,
DispenseReportBody,
DispenseReportAck,
DispenseReportBill,
DispenseReportCassette,
} from './types.js' } from './types.js'

View file

@ -310,3 +310,66 @@ export interface MachineConfigResponse {
/** Freshness watermark (unix s) for the consumer's fee-config replay guard. */ /** Freshness watermark (unix s) for the consumer's fee-config replay guard. */
created_at: number 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
}

View file

@ -12,7 +12,7 @@ describe('ATM State Machine', () => {
sendNostrReceipt: vi.fn().mockResolvedValue(undefined), sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
dispenseCash: vi.fn().mockResolvedValue({ dispenseCash: vi.fn().mockResolvedValue({
bills: [{ denomination: 20, dispensed: 1, rejected: 0 }], bills: [{ denomination: 20, dispensed: 1, rejected: 0 }],
dispensed: true, dispenseConfirmed: true,
} satisfies DispenseCashResult), } satisfies DispenseCashResult),
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available
@ -397,127 +397,224 @@ describe('ATM State Machine', () => {
}) })
}) })
describe('dispense error handling', () => { describe('dispense outcome (ADR-005 §3–§5)', () => {
it('should route to waitingForCashTaken when dispenseCash returns dispensed: true', async () => { /** Drive a 1 × $20 cash-out to the dispense and return the actor. */
const machine = createATMMachine(mockServices) async function dispenseWith(result: DispenseCashResult, fake = false) {
const actor = createActor(machine) const services: ATMServices = { ...mockServices, dispenseCash: vi.fn().mockResolvedValue(result) }
const actor = createActor(createATMMachine(services))
actor.start() actor.start()
const tick = async (ms: number) =>
fake ? vi.advanceTimersByTimeAsync(ms) : new Promise((r) => setTimeout(r, ms))
actor.send({ type: 'SELECT_CASH_OUT' }) 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: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'CONFIRM_AMOUNT' }) actor.send({ type: 'CONFIRM_AMOUNT' })
await new Promise((resolve) => setTimeout(resolve, 100)) await tick(100)
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) 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() const state = actor.getSnapshot()
// dispenseCash mock returns { dispensed: true }, so should go to waitingForCashTaken
expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' }) expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' })
expect(state.context.cashDispensed).toBe(true) 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 () => { it('routes a hardware error to dispenseFault — the customer has paid and is owed', async () => {
const failDispenseServices: ATMServices = { const actor = await dispenseWith({
...mockServices, bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseCash: vi.fn().mockResolvedValue({ dispenseConfirmed: false,
bills: [{ denomination: 20, dispensed: 0, rejected: 1 }], error: 'Note stopped at the cassette exit',
dispensed: false, errorCode: 'F56DispenseError',
error: 'Cassette jam', rawCode: '78 42',
} satisfies DispenseCashResult), errorClass: 'terminal',
} })
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))
const state = actor.getSnapshot() 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.cashDispensed).toBe(false)
expect(state.context.dispenseResult?.dispensed).toBe(false) expect(state.context.error).toBe('Note stopped at the cassette exit')
expect(state.context.dispenseResult?.error).toBe('Cassette jam') expect(state.context.dispenseResult?.rawCode).toBe('78 42')
expect(state.context.error).toBe('Cassette jam')
}) })
it('should auto-idle after 30s in dispenseError state', async () => { it('a terminal fault latches cash-out off; idle refuses SELECT_CASH_OUT until released', async () => {
vi.useFakeTimers() const actor = await dispenseWith({
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
const failDispenseServices: ATMServices = { dispenseConfirmed: false,
...mockServices, error: 'jam',
dispenseCash: vi.fn().mockResolvedValue({ errorCode: 'F56DispenseError',
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], rawCode: '78 42',
dispensed: false, errorClass: 'terminal',
error: 'Out of cash', })
} satisfies DispenseCashResult), const hold = actor.getSnapshot().context.cashOutHeld
} expect(hold).not.toBeNull()
expect(hold?.errorCode).toBe('F56DispenseError')
const machine = createATMMachine(failDispenseServices) expect(hold?.rawCode).toBe('78 42')
const actor = createActor(machine) expect(typeof hold?.since).toBe('number')
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' })
actor.send({ type: 'CANCEL' }) actor.send({ type: 'CANCEL' })
expect(actor.getSnapshot().value).toBe('idle') 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()
}
}) })
}) })

View file

@ -10,6 +10,7 @@ import {
type ATMContext, type ATMContext,
type ATMEvent, type ATMEvent,
type DispenseCashResult, type DispenseCashResult,
type CashOutHold,
initialContext, initialContext,
type ATMServices, type ATMServices,
type OfferRequestEvent, type OfferRequestEvent,
@ -148,8 +149,8 @@ export function createATMMachine(
} }
// Extract payment hash from invoice (simplified - real impl would decode BOLT11) // Extract payment hash from invoice (simplified - real impl would decode BOLT11)
// The service handles the actual extraction // The service handles the actual extraction
const cleanup = services.watchInvoice(input.invoice, (preimage: string) => { const cleanup = services.watchInvoice(input.invoice, (preimage, paymentHash) => {
sendBack({ type: 'PAYMENT_RECEIVED', preimage }) sendBack({ type: 'PAYMENT_RECEIVED', preimage, paymentHash })
}) })
return cleanup return cleanup
}), }),
@ -184,6 +185,9 @@ export function createATMMachine(
// without this the just-granted session would be wiped. The session is // 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. // cleared instead on re-lock (locked's entry), i.e. when access ends.
accessSession: context.accessSession, 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, cashInSessionId: null,
dispenseResult: null, dispenseResult: null,
})), })),
@ -315,6 +319,10 @@ export function createATMMachine(
if (event.type !== 'PAYMENT_RECEIVED') return null if (event.type !== 'PAYMENT_RECEIVED') return null
return event.preimage return event.preimage
}, },
paymentHash: ({ event }) => {
if (event.type !== 'PAYMENT_RECEIVED') return null
return event.paymentHash ?? null
},
}), }),
setPaymentFailed: assign({ setPaymentFailed: assign({
paymentStatus: () => 'failed' as const, paymentStatus: () => 'failed' as const,
@ -332,6 +340,37 @@ export function createATMMachine(
return output?.error ?? null 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({ setAmount: assign({
fiatCents: ({ event }) => { fiatCents: ({ event }) => {
if (event.type !== 'SELECT_AMOUNT') return 0 if (event.type !== 'SELECT_AMOUNT') return 0
@ -458,6 +497,18 @@ export function createATMMachine(
return principalSats - fee <= context.availableBalance return principalSats - fee <= context.availableBalance
}, },
// Cash-out guards // 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, hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0,
canAddDenomination: ({ context, event }) => { canAddDenomination: ({ context, event }) => {
if (event.type !== 'ADD_DENOMINATION') return false if (event.type !== 'ADD_DENOMINATION') return false
@ -477,7 +528,10 @@ export function createATMMachine(
INVOICE_TIMEOUT: 300000, // 5 minutes — waiting for payment INVOICE_TIMEOUT: 300000, // 5 minutes — waiting for payment
COMPLETE_DELAY: 60000, COMPLETE_DELAY: 60000,
DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond 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 // NOTE: idle inactivity re-lock + hard session cap are enforced at the
// DOM layer (useSessionSecurity), not as XState `after` delays — see the // DOM layer (useSessionSecurity), not as XState `after` delays — see the
// idle state comment. No IDLE_LOCK_TIMEOUT delay here by design. // idle state comment. No IDLE_LOCK_TIMEOUT delay here by design.
@ -513,6 +567,11 @@ export function createATMMachine(
cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction, 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: { states: {
// === ACCESS GATE (ADR-003) === // === ACCESS GATE (ADR-003) ===
@ -557,6 +616,7 @@ export function createATMMachine(
actions: ['setStartTime', 'setCashInFee'], actions: ['setStartTime', 'setCashInFee'],
}, },
SELECT_CASH_OUT: { SELECT_CASH_OUT: {
guard: 'cashOutAvailable',
target: 'cashOut', target: 'cashOut',
actions: ['setStartTime', 'setCashOutFee'], actions: ['setStartTime', 'setCashOutFee'],
}, },
@ -861,13 +921,12 @@ export function createATMMachine(
}, },
dispensingCash: { dispensingCash: {
// Safety timeout: if dispenseCash promise hangs (hardware jam, // 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: { after: {
DISPENSE_TIMEOUT: { DISPENSE_TIMEOUT: {
target: 'dispenseError', target: 'dispenseFault',
actions: assign({ actions: ['setDispenseTimeoutError', 'latchCashOutIfTerminal'],
error: () => 'Dispense timed out — hardware may be jammed',
}),
}, },
}, },
invoke: { invoke: {
@ -875,21 +934,31 @@ export function createATMMachine(
input: ({ context }) => context.dispenseAmounts, input: ({ context }) => context.dispenseAmounts,
onDone: [ onDone: [
{ {
guard: ({ event }) => // ADR-005 §3: value equality, nothing else, completes.
(event.output as unknown as DispenseCashResult | undefined)?.dispensed === true, guard: 'dispenseConfirmed',
target: 'waitingForCashTaken', target: 'waitingForCashTaken',
actions: ['setCashDispensed', 'setDispenseResult'], actions: ['setCashDispensed', 'setDispenseResult'],
}, },
{ {
// Partial or failed dispense // ADR-005 §4: a hardware error means the customer has paid
target: 'dispenseError', // 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', actions: 'setDispenseResult',
}, },
], ],
onError: { onError: {
// Unexpected crash (not a dispense failure) // The service threw (not a reported dispense failure). No
target: 'dispenseError', // per-bay report exists — the store flags counts unverified.
actions: 'setError', target: 'dispenseFault',
actions: ['setError', 'latchCashOutIfTerminal'],
}, },
}, },
}, },
@ -930,9 +999,26 @@ export function createATMMachine(
CANCEL: '#atm.locked', CANCEL: '#atm.locked',
}, },
}, },
dispenseError: { // ADR-005 §4 — two terminal states replace the old dispenseError.
// Payment received but cash not (fully) dispensed. //
// Show error + txid for 30s, then auto-idle (matches brain.js _timedState). // 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: { after: {
DISPENSE_ERROR_TIMEOUT: '#atm.locked', DISPENSE_ERROR_TIMEOUT: '#atm.locked',
}, },

View file

@ -21,18 +21,48 @@ export interface CassetteBillResult {
rejected: number 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 { export interface DispenseCashResult {
/** Per-denomination results (what was actually dispensed) */ /** Per-denomination results (what was actually dispensed) */
bills: { denomination: number; dispensed: number; rejected: number }[] bills: { denomination: number; dispensed: number; rejected: number }[]
/** Whether the full requested amount was dispensed */ /**
dispensed: boolean * Σ(denomination × dispensed) equals the requested fiat value. Computed by
/** Error message if dispense failed or was partial */ * 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 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) */ /** Per-cassette detail (position-aware, produced by HAL) */
cassettes?: CassetteBillResult[] 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 */ /** Payment methods supported */
export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu' export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu'
@ -122,6 +152,12 @@ export interface ATMContext {
paymentStatus: PaymentStatus paymentStatus: PaymentStatus
/** Payment preimage (proof of payment) */ /** Payment preimage (proof of payment) */
preimage: string | null 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 */ /** Payment method used */
paymentMethod: PaymentMethod | null paymentMethod: PaymentMethod | null
/** Pending offer request (Kind 21001 from user's wallet) */ /** Pending offer request (Kind 21001 from user's wallet) */
@ -157,6 +193,8 @@ export interface ATMContext {
retryCount: number retryCount: number
/** Result from the last dispense operation */ /** Result from the last dispense operation */
dispenseResult: DispenseCashResult | null dispenseResult: DispenseCashResult | null
/** Cash-out latched off after a terminal fault; null = available (ADR-005 §5) */
cashOutHeld: CashOutHold | null
// Transaction metadata // Transaction metadata
/** Unique transaction ID */ /** Unique transaction ID */
@ -199,8 +237,14 @@ export type ATMEvent =
| { type: 'BILL_REJECTED'; reason: string } | { type: 'BILL_REJECTED'; reason: string }
| { type: 'CASH_DISPENSED' } | { type: 'CASH_DISPENSED' }
| { type: 'DISPENSE_ERROR'; error: string } | { 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 // Payment events
| { type: 'PAYMENT_RECEIVED'; preimage: string } | { type: 'PAYMENT_RECEIVED'; preimage: string; paymentHash?: string }
| { type: 'PAYMENT_FAILED'; error: string } | { type: 'PAYMENT_FAILED'; error: string }
| { type: 'INVOICE_GENERATED'; invoice: string } | { type: 'INVOICE_GENERATED'; invoice: string }
| { type: 'OFFER_GENERATED'; offer: string } | { type: 'OFFER_GENERATED'; offer: string }
@ -245,6 +289,7 @@ export const initialContext: ATMContext = {
lnurlWithdraw: null, lnurlWithdraw: null,
paymentStatus: null, paymentStatus: null,
preimage: null, preimage: null,
paymentHash: null,
paymentMethod: null, paymentMethod: null,
pendingOfferRequest: null, pendingOfferRequest: null,
billsInserted: [], billsInserted: [],
@ -258,6 +303,7 @@ export const initialContext: ATMContext = {
error: null, error: null,
retryCount: 0, retryCount: 0,
dispenseResult: null, dispenseResult: null,
cashOutHeld: null,
txid: null, txid: null,
startedAt: null, startedAt: null,
cashInSessionId: null, cashInSessionId: null,
@ -306,7 +352,10 @@ export interface ATMServices {
* Watch an invoice for payment (polling-based) * Watch an invoice for payment (polling-based)
* Calls the callback when paid, returns cleanup function * 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 * Get available inventory: denomination -> count
*/ */