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.
This commit is contained in:
Padreug 2026-10-10 21:25:51 +02:00
commit 888870d01a
12 changed files with 469 additions and 72 deletions

View file

@ -6,7 +6,8 @@
* so it's available at runtime (unlike src/ which is only for Vite).
*/
import type { BillValidator, BillDispenser } from '@bitSpire/hal'
import type { BillValidator, BillDispenser, DispenseErrorClass } from '@bitSpire/hal'
import { isDispenseError } from '@bitSpire/hal'
export interface CassetteConfig {
/**
@ -48,8 +49,20 @@ export interface ValidatorCallbacks {
export interface DispenseResult {
bills: { denomination: number; dispensed: number; rejected: number }[]
dispensed: boolean
/**
* Σ(denomination × dispensed) === Σ(denomination × requested). Computed
* here on VALUE (ADR-005 §3) — never a driver boolean. Only this routes
* the state machine to `complete`.
*/
dispenseConfirmed: boolean
/** Human message when not confirmed */
error?: string
/** The error's NAME — 'F56DispenseError', 'InsufficientInventory', … */
errorCode?: string
/** Driver-native code, e.g. '78 42' */
rawCode?: string
/** terminal | recoverable | inventory — see @bitSpire/hal error-codes */
errorClass?: DispenseErrorClass
cassettes?: {
name: string
position: number
@ -277,6 +290,8 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
notes[i] = (notes[i] ?? 0) + take
remaining -= take
}
// Nothing has been asked of the hardware in either refusal below:
// errorClass 'inventory' routes to outOfCash, not the fault screen.
if (!matched) {
return {
bills: amounts.map((a) => ({
@ -284,8 +299,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
dispensed: 0,
rejected: 0,
})),
dispensed: false,
dispenseConfirmed: false,
error: `No cassette loaded with denomination: ${denomination}`,
errorCode: 'NoCassetteForDenomination',
errorClass: 'inventory',
}
}
if (remaining > 0) {
@ -295,8 +312,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
dispensed: 0,
rejected: 0,
})),
dispensed: false,
dispenseConfirmed: false,
error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`,
errorCode: 'InsufficientInventory',
errorClass: 'inventory',
}
}
}
@ -344,11 +363,44 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
}
const bills = Array.from(billsByDenom.values())
const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
// ADR-005 §3: confirmation is VALUE equality — what left the bays is
// worth exactly what was asked — not a count, and not the driver's
// opinion. lamassu computed the same thing (`tx.fiat.eq(Σ denomination
// × dispensed)`); our previous count-based check was only equivalent
// while every bay dispensed its own denomination.
const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0)
const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0)
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
const dispenseConfirmed = requestedValue === dispensedValue
if (result.error) {
return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message }
const e = result.error
const info = isDispenseError(e)
? { errorCode: e.errorCode, rawCode: e.rawCode, errorClass: e.errorClass, human: e.human }
: {
// Unreachable by type (drivers always tag), kept as a defensive
// fallback for a driver that slips an untagged Error through.
errorCode: (e as Error).name || 'DispenseError',
rawCode: undefined,
errorClass: 'terminal' as const,
human: (e as Error).message,
}
console.error(
`[HAL] Dispense error ${info.errorCode}${info.rawCode ? ` ${info.rawCode}` : ''} (${info.errorClass}): ${info.human} — requested ${requestedValue}, dispensed ${dispensedValue}`
)
// A dispensed value of zero WITH an error is not evidence that nothing
// left the bay — a note stopped in the transport completes neither
// counter (sintra, 2026-10-09). The store reads this combination and
// flags counts unverified; we just report faithfully here.
return {
bills,
cassettes: cassetteResults,
dispenseConfirmed: false,
error: info.human,
errorCode: info.errorCode,
rawCode: info.rawCode,
errorClass: info.errorClass,
}
}
// Wait for customer to take bills
@ -357,7 +409,20 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
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,10 @@ import {
getCountsUncertainSince,
getLastStatePublishedAt,
markCountsUncertain,
getCashOutHold,
setCashOutHold,
clearCashOutHold,
type CashOutHold,
markStatePublished,
resetStatePublishWatermark,
resetForRepair,
@ -565,6 +569,15 @@ ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCount
ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => {
markCountsUncertain(unixTimestamp)
})
// Cash-out hold (ADR-005 §5)
ipcMain.handle('state:get-cash-out-hold', (): CashOutHold | null => getCashOutHold())
ipcMain.handle('state:set-cash-out-hold', (_event, hold: CashOutHold): CashOutHold => {
if (!hold || typeof hold.reason !== 'string' || typeof hold.since !== 'number') {
throw new Error('Invalid cash-out hold')
}
return setCashOutHold(hold)
})
ipcMain.handle('state:clear-cash-out-hold', (): boolean => clearCashOutHold())
ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
markStatePublished(unixTimestamp)
})
@ -841,7 +854,7 @@ function startCommandPoller(): void {
recordTransaction({
txid,
type: 'manual_dispense',
status: result.dispensed ? 'complete' : 'dispense_error',
status: result.dispenseConfirmed ? 'complete' : 'dispense_error',
fiatCents: totalFiatCents,
sats: 0,
feeSats: 0,
@ -860,7 +873,7 @@ function startCommandPoller(): void {
// Only remediate the original tx if ALL requested bills were dispensed
let refRemediated = false
if (parsed.ref_txid && result.dispensed) {
if (parsed.ref_txid && result.dispenseConfirmed) {
refRemediated = remediateTransaction(parsed.ref_txid, txid)
}
@ -868,7 +881,13 @@ function startCommandPoller(): void {
cmd.id,
JSON.stringify({
txid,
dispensed: result.dispensed,
// Wire key kept as `dispensed` — spirekeeper's command poller
// reads it. Value is the ADR-005 value-equality confirmation.
dispensed: result.dispenseConfirmed,
dispense_confirmed: result.dispenseConfirmed,
error_code: result.errorCode,
raw_code: result.rawCode,
error_class: result.errorClass,
ref_remediated: refRemediated,
error: result.error,
})

View file

@ -7,6 +7,14 @@
import { contextBridge, ipcRenderer } from 'electron'
/** Mirrors state-store.CashOutHold (ADR-005 §5) — preload can't import main-process modules. */
interface CashOutHold {
reason: string
errorCode: string | null
rawCode: string | null
since: number
}
/**
* Runtime configuration interface (public info only)
* These values are read from environment variables at runtime (not build time)
@ -114,6 +122,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('state:get-counts-uncertain-since'),
markCountsUncertain: (unixTimestamp: number): Promise<void> =>
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'),
markStatePublished: (unixTimestamp: number): Promise<void> =>
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
@ -307,6 +320,9 @@ declare global {
getLastStatePublishedAt: () => Promise<number | null>
getCountsUncertainSince: () => Promise<number | null>
markCountsUncertain: (unixTimestamp: number) => Promise<void>
getCashOutHold: () => Promise<CashOutHold | null>
setCashOutHold: (hold: CashOutHold) => Promise<CashOutHold>
clearCashOutHold: () => Promise<boolean>
markStatePublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void>

View file

@ -517,6 +517,69 @@ export function clearCountsUncertain(): void {
).run('countsUncertainSince', '')
}
// ---------------------------------------------------------------------------
// Cash-out hold (ADR-005 §5)
// ---------------------------------------------------------------------------
//
// A terminal dispenser fault latches cash-out off. The latch is machine
// health, so it lives in `meta` (one JSON value) and survives restarts; the
// renderer restores it into the state machine on boot and the operator
// releases it with a `recount` or `resume_cash_out` op. Re-initialising the
// dispenser never clears it — re-init does not move a stuck note.
export interface CashOutHold {
reason: string
errorCode: string | null
rawCode: string | null
/** unix seconds of the FIRST fault — kept across repeat faults */
since: number
}
export function getCashOutHold(): CashOutHold | null {
if (!db) throw new Error('Database not initialized')
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cashOutHeld') as
| { value: string }
| undefined
if (!row || row.value === '') return null
try {
const parsed = JSON.parse(row.value) as Partial<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
}
/**
* A counter bumped on every local change to a bay count, from any cause.
*
@ -851,7 +914,12 @@ export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult {
// A recount is an operator opening the bay and counting it, which is
// exactly what resolves an unverified count. Nothing else does: a refill
// adds to a number still known to be wrong.
if (sawRecount) upsertMeta.run('countsUncertainSince', '')
if (sawRecount) {
upsertMeta.run('countsUncertainSince', '')
// ADR-005 §5: a recount is an operator at the open machine — the one
// gesture that also releases a cash-out hold.
upsertMeta.run('cashOutHeld', '')
}
})()
console.log(