Compare commits

..

No commits in common. "bacf6f4e7784f7fcb83799c026bd1440fc0ba201" and "f3c333c0bb71b0cc57112c89d338b52fe100fd66" have entirely different histories.

31 changed files with 209 additions and 1735 deletions

View file

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

View file

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

View file

@ -7,24 +7,6 @@
import { contextBridge, ipcRenderer } from 'electron'
/** Mirrors state-store.CashOutHold (ADR-005 §5) — preload can't import main-process modules. */
interface CashOutHold {
reason: string
errorCode: string | null
rawCode: string | null
since: number
}
/** Mirrors state-store.PendingDispenseReport (ADR-005 §2). */
interface PendingDispenseReport {
txid: string
payload: unknown
createdAt: number
attempts: number
lastAttemptAt: number | null
lastError: string | null
}
/**
* Runtime configuration interface (public info only)
* These values are read from environment variables at runtime (not build time)
@ -132,18 +114,6 @@ 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'),
// 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> =>
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
@ -337,12 +307,6 @@ 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>
pendingDispenseReports: (limit?: number) => Promise<PendingDispenseReport[]>
ackDispenseReport: (txid: string) => Promise<boolean>
noteDispenseReportAttempt: (txid: string, error: string | null) => Promise<void>
markStatePublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void>

View file

@ -10,13 +10,12 @@
*/
import Database from 'better-sqlite3'
import type { DispenseReportBody } from '@bitSpire/lnbits'
import path from 'node:path'
import fs from 'node:fs'
let db: Database.Database | null = null
const SCHEMA_VERSION = '14'
const SCHEMA_VERSION = '13'
function getDbPath(): string {
const prodDir = '/var/lib/bitspire'
@ -137,16 +136,6 @@ export function initDatabase(dbPath?: string): void {
relays TEXT,
lnbits_server_pubkey TEXT
);
CREATE TABLE IF NOT EXISTS dispense_reports (
txid TEXT PRIMARY KEY REFERENCES transactions(txid),
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at INTEGER,
last_error TEXT,
acked_at INTEGER
);
`)
// Seed meta + cashbox if first run, or run migrations
@ -429,32 +418,6 @@ export function initDatabase(dbPath?: string): void {
existing.value = '13'
}
if (existing && existing.value === '13') {
// Migration v13 → v14: the dispense-report outbox (ADR-005 §2).
//
// Every cash-out's outcome — success or failure — is reported to
// spirekeeper over a kind-21000 RPC, and that report is what lets the
// server capture (distribute) the settlement or surface a customer who
// is owed cash. A relay gives the publisher no delivery guarantee, so the
// report is written here, in the SAME transaction as the transactions
// row, and resent until the server acknowledges it. Idempotent on txid
// server-side; `attempts` / `last_error` drive the resend backoff.
db.exec(`
CREATE TABLE IF NOT EXISTS dispense_reports (
txid TEXT PRIMARY KEY REFERENCES transactions(txid),
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at INTEGER,
last_error TEXT,
acked_at INTEGER
);
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('14', 'schema_version')
console.log('[StateStore] Migrated schema v13 → v14 (added dispense_reports outbox)')
existing.value = '14'
}
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
// Seed the operator-config meta rows if they're missing (idempotent).
const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)')
@ -554,133 +517,6 @@ 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
}
// ---------------------------------------------------------------------------
// 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.
*
@ -1015,12 +851,7 @@ 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', '')
// 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', '')
}
if (sawRecount) upsertMeta.run('countsUncertainSince', '')
})()
console.log(
@ -1342,11 +1173,6 @@ interface TransactionInput {
rejected: number
}[]
error?: string | null
/**
* ADR-005 §2: dispense outcome to queue for spirekeeper. Inserted in the
* same transaction as the row so a crash between them cannot lose it.
*/
report?: DispenseReportBody
}
/**
@ -1368,12 +1194,6 @@ export function recordTransaction(tx: TransactionInput): void {
const insertBill = db.prepare(
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
)
// Outbox row (ADR-005 §2). REPLACE: a re-record of the same txid (should not
// happen, but a crash-replay could) refreshes the payload and resets the
// delivery state rather than failing the whole transaction.
const insertReport = db.prepare(
'INSERT OR REPLACE INTO dispense_reports (txid, payload, created_at, attempts, last_attempt_at, last_error, acked_at) VALUES (?, ?, ?, 0, NULL, NULL, NULL)'
)
const insertCassetteBill = db.prepare(
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
)
@ -1406,10 +1226,6 @@ export function recordTransaction(tx: TransactionInput): void {
insertBill.run(t.txid, bill.denomination, bill.count)
}
if (t.report) {
insertReport.run(t.txid, JSON.stringify(t.report), Date.now())
}
// Insert per-cassette detail when available
if (t.cassettes) {
for (const c of t.cassettes) {

View file

@ -50,13 +50,11 @@
"@tailwindcss/vite": "^4.0.0",
"@types/better-sqlite3": "^7.0.0",
"@types/node": "^22.0.0",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^5.2.0",
"concurrently": "^9.0.0",
"electron": "^33.0.0",
"electron-builder": "^25.0.0",
"esbuild": "^0.27.4",
"qrcode": "^1.5.4",
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.0",

View file

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

View file

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

View file

@ -14,7 +14,7 @@
import { NostrClient, type Signer } from '@bitSpire/nostr-client'
import { resolveSigner } from './signer-resolver.js'
import { LnbitsClient, type DispenseReportBody, type DispenseReportAck } from '@bitSpire/lnbits'
import { LnbitsClient } from '@bitSpire/lnbits'
import { CLINKClient } from '@bitSpire/clink'
import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink'
import type { ATMServices, ATMContext } from '@bitSpire/state-machine'
@ -223,8 +223,6 @@ export interface LightningBackend {
}
interface LightningServices {
/** ADR-005 §2: send one cash-out's dispense outcome to spirekeeper (outbox-driven). */
reportDispense: (body: DispenseReportBody) => Promise<DispenseReportAck>
nostrClient: NostrClient
lightningPub: LightningBackend
clink: CLINKClient
@ -688,11 +686,6 @@ export async function initializeLightningServices(options?: {
signer,
operatorPubkeys: CONFIG.operatorPubkeys,
atmServices,
/**
* ADR-005 §2: send one cash-out's dispense outcome to spirekeeper. The
* store keeps these in a durable outbox and calls this until it resolves.
*/
reportDispense: (body: DispenseReportBody) => lnbits.reportDispense(body),
onOfferRequest: (callback: OfferRequestCallback) => {
offerRequestCallback = callback
},
@ -749,7 +742,7 @@ export function createATMServices(
subId: string | null
/** Preimage seen before a consumer attached; replayed on attach. */
settled: string | null
consumer: ((preimage: string, paymentHash: string) => void) | null
consumer: ((preimage: string) => void) | null
poll: ReturnType<typeof setInterval> | null
released: boolean
}
@ -772,7 +765,7 @@ export function createATMServices(
watch.settled = preimage
stopInvoiceWatchPoll(watch)
console.log(`[ATM Service] Invoice paid (${via})!`)
watch.consumer?.(preimage, watch.paymentHash)
watch.consumer?.(preimage)
}
function startInvoiceWatchPoll(watch: InvoiceWatch): void {
@ -1113,7 +1106,7 @@ export function createATMServices(
dispensed: a.count,
rejected: 0,
})),
dispenseConfirmed: true,
dispensed: true,
}
},
@ -1213,10 +1206,7 @@ export function createATMServices(
* Watch a BOLT11 invoice for payment via LNbits subscribe_payments
* push, filtered by payment_hash. Returns a cleanup function.
*/
watchInvoice: (
invoice: string,
callback: (preimage: string, paymentHash?: string) => void
): (() => void) => {
watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => {
if (!invoice.toLowerCase().startsWith('ln')) {
console.error('[ATM Service] Invalid invoice format - expected BOLT11')
return () => {}
@ -1231,7 +1221,7 @@ export function createATMServices(
// on a push that has already come and gone.
if (armed.settled) {
const preimage = armed.settled
queueMicrotask(() => callback(preimage, armed.paymentHash))
queueMicrotask(() => callback(preimage))
}
return () => releaseInvoiceWatch(invoice)
}
@ -1254,7 +1244,7 @@ export function createATMServices(
const late = invoiceWatches.get(invoice)
if (!late || cancelled) return
late.consumer = callback
if (late.settled) callback(late.settled, late.paymentHash)
if (late.settled) callback(late.settled)
} catch (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). */
const CASSETTE_SCHEMA_VERSION = 2
/** One operator-authored cassette operation, as it arrives on the wire. */
/** One operator-authored operation, as it arrives on the wire. */
type CassetteOp = {
id: string
at: number
@ -55,18 +55,6 @@ type CassetteOp = {
denomination?: number
}
/**
* ADR-005 §5: the operator releases a cash-out hold without touching a bay
* count. Rides the same operator event as the cassette ops (same id/at shape)
* but is NOT a cassette op: it never reaches `applyOperatorCassetteOps`, which
* would reject the type. Honoured only when stamped AFTER the hold began, so
* a re-delivered resume from before a fresh fault cannot clear that fault.
* A `recount` releases the hold too — it is the same "operator at the open
* machine" gesture and already clears counts-uncertain.
*/
type ResumeCashOutOp = { id: string; at: number; type: 'resume_cash_out' }
type OperatorOp = CassetteOp | ResumeCashOutOp
/** Accept operator events stamped up to this many seconds in the future. */
const MAX_FUTURE_SKEW_S = 60
@ -97,12 +85,6 @@ export interface OperatorConfigServiceConfig {
operatorPubkeys: string[]
/** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
machineId?: string
/**
* ADR-005 §5: called when an operator op (recount, resume_cash_out) has
* released a persisted cash-out hold, so the store can lift the state
* machine's latch. The store wires this to `CASH_OUT_RELEASED`.
*/
onCashOutHoldReleased?: () => void
}
export interface OperatorConfigService {
@ -240,28 +222,7 @@ async function handleOperatorConfigEvent(
console.error('[OperatorConfig] Payload missing `ops` array — dropped')
return
}
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')
)
}
const ops = parsed.ops as CassetteOp[]
// 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
@ -269,19 +230,10 @@ async function handleOperatorConfigEvent(
// no-op on its own merits. The watermark would be strictly weaker and
// actively harmful — an event arriving out of order can still carry an
// operation this machine has never seen.
const result = ops.length
? await api.applyOperatorCassetteOps(ops)
: { applied: [] as string[], rejected: [] as { id: string; reason: string }[] }
const result = await api.applyOperatorCassetteOps(ops)
for (const bad of result.rejected) {
console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`)
}
// A recount (applied in the store, which also clears the hold) or the resume
// above may have released the latch: tell the store so the state machine
// lifts its guard. The republishes below carry the cleared state up.
if (holdBefore && (await api.getCashOutHold()) === null) {
cfg.onCashOutHoldReleased?.()
}
if (result.applied.length === 0) {
console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`)
// Still republish: the operator learns from our applied_ops echo that
@ -366,15 +318,6 @@ async function publishCassettesState(
applied_ops: appliedOps,
}
if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince
// ADR-005 §5 — additive, same contract as counts_uncertain_since: an old
// consumer ignores it. When present, this machine is refusing cash-out
// until an operator recount or resume_cash_out op.
const hold = await api.getCashOutHold()
if (hold) {
payload.cash_out_held_since = hold.since
payload.cash_out_held_reason = hold.reason
payload.cash_out_held_code = hold.rawCode ?? hold.errorCode ?? null
}
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload))
// Force the stamp strictly above our last one. Addressable events are ordered

View file

@ -1,5 +1,4 @@
import { defineStore } from 'pinia'
import type { DispenseReportBody } from '@bitSpire/lnbits'
import { ref, computed, watch } from 'vue'
import {
createATMMachine,
@ -10,7 +9,6 @@ import {
type SnapshotFrom,
type ATMMachine,
type AccessRole,
type DispenseCashResult,
} from '@bitSpire/state-machine'
import type { AccessControlConfig, CardSession } from '@/types/electron'
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
@ -128,7 +126,7 @@ async function handleManagementCommand(
await persistTransaction({
txid,
type: 'manual_dispense',
status: result.dispenseConfirmed ? 'complete' : 'dispense_error',
status: result.dispensed ? 'complete' : 'dispense_error',
fiatCents: totalFiatCents,
sats: 0,
feeSats: 0,
@ -143,7 +141,7 @@ async function handleManagementCommand(
// Only remediate the original tx if ALL requested bills were dispensed
let refRemediated = false
if (request.ref_txid && result.dispenseConfirmed && isElectron && window.electronAPI) {
if (request.ref_txid && result.dispensed && isElectron && window.electronAPI) {
refRemediated = await window.electronAPI.remediateTransaction(request.ref_txid, txid)
if (refRemediated) {
console.log('[ATM] Remediated failed tx:', request.ref_txid)
@ -194,62 +192,6 @@ async function loadInventoryFromDb(): Promise<Record<number, number> | null> {
/**
* Persist a completed transaction to SQLite via IPC.
*/
/**
* ADR-005 §2: the dispense outcome spirekeeper captures on. Built from the
* machine context at the moment the terminal state is entered; written in the
* same SQLite transaction as the transactions row (see TransactionRecord.report).
* `requested` per denomination comes from the sale, `dispensed`/`rejected` from
* the hardware report; `cassettes` is the per-bay record verbatim.
*/
function buildDispenseReport(
ctx: ATMContext,
dr: DispenseCashResult | null,
countsUncertain: boolean
): DispenseReportBody {
const requestedByDenom = new Map<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> {
if (isElectron && window.electronAPI) {
try {
@ -312,7 +254,7 @@ const mockServices: ATMServices = {
dispensed: a.count,
rejected: 0,
})),
dispenseConfirmed: true,
dispensed: true,
}
},
@ -459,12 +401,6 @@ export const useAtmStore = defineStore('atm', () => {
)
let stopBalanceWatch: (() => void) | 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
let atmServicesRef: ATMServices | null = null
@ -682,31 +618,13 @@ export const useAtmStore = defineStore('atm', () => {
send({ type: 'CASH_DISPENSED' })
}
// ADR-005 §5: persist the cash-out hold the moment the machine sets it,
// and republish the cassette state so the operator sees it. The hold is
// machine health, not transaction state — it must survive a restart.
const heldNow = newSnapshot.context.cashOutHeld
const heldBefore = prevSnapshot?.context.cashOutHeld ?? null
if (heldNow && !heldBefore && isElectron && window.electronAPI) {
void window.electronAPI
.setCashOutHold(heldNow)
.then(() => operatorConfigSvc?.publishCassettesState())
.catch((e) => console.error('[ATM] Could not persist cash-out hold:', e))
}
// Record a cash-out that did not confirm (ADR-005 §3/§4). Both terminal
// states mean the customer has PAID and received less than they paid
// for — dispenseFault because the dispenser reported an error, outOfCash
// because it reported none (an inventory refusal, or simply short).
// Either way the row is dispense_error / partial and the server learns
// of it; the difference is only the customer screen and the latch.
const isDispenseTerminal = currentNested === 'dispenseFault' || currentNested === 'outOfCash'
const wasDispenseTerminal = prevNestedState === 'dispenseFault' || prevNestedState === 'outOfCash'
if (isDispenseTerminal && !wasDispenseTerminal) {
// Record failed cash-out dispenses (sats debited but cash not dispensed)
if (currentNested === 'dispenseError' && prevNestedState !== 'dispenseError') {
const ctx = newSnapshot.context
if (ctx.txid) {
const dr = ctx.dispenseResult
// Determine status from dispense result (if available)
let status: 'dispense_error' | 'partial' = 'dispense_error'
let bills: { denomination: number; count: number }[] = []
@ -716,23 +634,6 @@ export const useAtmStore = defineStore('atm', () => {
bills = dr.bills
.filter((b) => b.dispensed > 0)
.map((b) => ({ denomination: b.denomination, count: b.dispensed }))
// ADR-005 §3 — the deviation from both bitSpire-before and lamassu:
// a report of ZERO dispensed that arrives WITH a hardware error is
// not evidence that nothing left the bay. A note that stops in the
// transport path completes neither the dispensed nor the rejected
// counter (sintra, 2026-10-09: bay read 66, held 65, one in the
// transport). Flag the counts unverified so the next recount is
// what resolves them, instead of trusting a zero.
if (totalDispensed === 0 && dr.error && dr.errorClass !== 'inventory') {
console.error(
`[ATM] Dispense reported 0 notes WITH an error (${dr.errorCode ?? 'unknown'}` +
`${dr.rawCode ? ` ${dr.rawCode}` : ''}) — bay counts are unverified (txid=${ctx.txid})`
)
void window.electronAPI
?.markCountsUncertain(Math.floor(Date.now() / 1000))
.catch((e) => console.warn('[ATM] Could not flag counts unverified:', e))
}
} else {
// The dispenser threw, or the dispense timed out, so there is no
// per-bay report. Bills may well have reached the customer, and
@ -748,11 +649,6 @@ export const useAtmStore = defineStore('atm', () => {
.catch((e) => console.warn('[ATM] Could not flag counts unverified:', e))
}
const countsUncertain =
!dr ||
(dr.bills.reduce((sum, b) => sum + b.dispensed, 0) === 0 &&
!!dr.error &&
dr.errorClass !== 'inventory')
persistTransaction({
txid: ctx.txid,
type: 'cash_out',
@ -766,14 +662,10 @@ export const useAtmStore = defineStore('atm', () => {
bills,
cassettes: dr?.cassettes,
error: dr?.error ?? ctx.error,
// ADR-005 §2 — queued in the same SQLite transaction as the row.
report: buildDispenseReport(ctx, dr, countsUncertain),
})
.then(() => reloadPersistedInventory())
// Republish cassette state — a partial dispense changed counts, and
// the payload now carries the hold / unverified flags.
// Republish cassette state — a partial dispense changed counts.
.then(() => operatorConfigSvc?.publishCassettesState())
.then(() => flushDispenseReports())
}
}
@ -803,15 +695,11 @@ export const useAtmStore = defineStore('atm', () => {
bills,
cassettes: dr?.cassettes,
error: dr?.error,
// ADR-005 §2 — the SUCCESS report is what lets spirekeeper capture
// (distribute) the settlement. Cash-out only; cash-in has no dispense.
...(isCashInTx ? {} : { report: buildDispenseReport(ctx, dr, false) }),
})
.then(() => reloadPersistedInventory())
// Republish cassette state after a cash-out dispense (counts
// decremented); harmless no-op echo for a cash-in complete.
.then(() => (isCashInTx ? undefined : operatorConfigSvc?.publishCassettesState()))
.then(() => (isCashInTx ? undefined : flushDispenseReports()))
}
}
@ -854,23 +742,6 @@ export const useAtmStore = defineStore('atm', () => {
setupNfcListener()
setupCassettesChangedListener()
console.log('[ATM] State machine initialized')
// ADR-005 §5: a cash-out hold persisted by a previous run gates cash-out
// before any dispense — a restart must not quietly put a jammed machine
// back in service. Released only by an operator recount / resume op.
if (isElectron && window.electronAPI) {
void window.electronAPI
.getCashOutHold()
.then((hold) => {
if (!hold) return
console.warn(
`[ATM] Cash-out HELD since ${new Date(hold.since * 1000).toISOString()} ` +
`(${hold.errorCode ?? 'fault'}${hold.rawCode ? ` ${hold.rawCode}` : ''}): ${hold.reason}`
)
send({ type: 'CASH_OUT_HELD', hold })
})
.catch((e) => console.warn('[ATM] Could not read cash-out hold:', e))
}
}
// ── Bolt Card cash-out (NFC tap-to-pay) ───────────────────────────────────
@ -1160,8 +1031,6 @@ export const useAtmStore = defineStore('atm', () => {
try {
const services = await initializeLightningServices({ strict: !allowMockFallback.value })
reportDispenseFn = services.reportDispense
startDispenseReportFlusher()
useLiveServices.value = true
connectionStatus.value = 'connected'
console.log('[ATM] Connected to Lightning.Pub!')
@ -1174,8 +1043,6 @@ export const useAtmStore = defineStore('atm', () => {
services.nostrClient.on('connect', () => {
connectionStatus.value = 'connected'
console.log('[ATM] Relay reconnected')
// A report queued during the outage goes now, not at the next tick.
void flushDispenseReports()
})
// Store references to clients for direct operations
@ -1268,7 +1135,6 @@ export const useAtmStore = defineStore('atm', () => {
nostrClient: services.nostrClient,
signer: services.signer,
operatorPubkeys: services.operatorPubkeys,
onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }),
})
// Start operator-fees consumer (aiolabs/lamassu-next#57) — subscribes
@ -1463,8 +1329,6 @@ export const useAtmStore = defineStore('atm', () => {
// Initialize Lightning services
const lightning = await initializeLightningServices({ strict: !allowMockFallback.value })
reportDispenseFn = lightning.reportDispense
startDispenseReportFlusher()
useLiveServices.value = true
connectionStatus.value = 'connected'
lightningPub.value = lightning.lightningPub
@ -1597,7 +1461,6 @@ export const useAtmStore = defineStore('atm', () => {
nostrClient: lightning.nostrClient,
signer: lightning.signer,
operatorPubkeys: lightning.operatorPubkeys,
onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }),
})
// Operator-fees consumer (aiolabs/lamassu-next#57)
@ -1758,8 +1621,6 @@ export const useAtmStore = defineStore('atm', () => {
// Initialize Lightning services
const lightning = await initializeLightningServices({ strict: !allowMockFallback.value })
reportDispenseFn = lightning.reportDispense
startDispenseReportFlusher()
useLiveServices.value = true
connectionStatus.value = 'connected'
lightningPub.value = lightning.lightningPub
@ -1936,7 +1797,6 @@ export const useAtmStore = defineStore('atm', () => {
nostrClient: lightning.nostrClient,
signer: lightning.signer,
operatorPubkeys: lightning.operatorPubkeys,
onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }),
})
// Operator-fees consumer (aiolabs/lamassu-next#57)
@ -2039,11 +1899,6 @@ export const useAtmStore = defineStore('atm', () => {
send({ type: 'SELECT_CASH_IN' })
}
/** Customer dismisses the dispense-fault screen ("I've saved this reference"). */
function acknowledgeFault() {
send({ type: 'ACKNOWLEDGE_FAULT' })
}
function selectCashOut() {
settlementError.value = null
send({ type: 'SELECT_CASH_OUT' })
@ -2128,59 +1983,7 @@ export const useAtmStore = defineStore('atm', () => {
pricePollingInterval = setInterval(poll, 30_000)
}
/**
* Drain the dispense-report outbox (ADR-005 §2). At-least-once: a row is
* acked only on an OK reply; anything else bumps `attempts` and the row is
* retried after an exponential backoff (30 s · 2^attempts, capped at 1 h).
* While spirekeeper has not registered `report_dispense` every send fails
* the same way — the backoff keeps that from being noisy, and the rows wait.
* Triggers: right after each persist, on relay (re)connect, every 60 s.
*/
async function flushDispenseReports(): Promise<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() {
// Both are store-lifetime intervals; whoever stops one stops the other.
stopDispenseReportFlusher()
if (pricePollingInterval) {
clearInterval(pricePollingInterval)
pricePollingInterval = null
@ -2201,8 +2004,6 @@ export const useAtmStore = defineStore('atm', () => {
signer,
inventory: persistedInventory,
balanceSats,
// ADR-005 §5: a latched machine must not advertise cash-out.
cashOutHeld: computed(() => snapshot.value?.context.cashOutHeld != null),
fiatCode: fiatCode.value,
model,
})
@ -2268,7 +2069,6 @@ export const useAtmStore = defineStore('atm', () => {
send,
selectCashIn,
selectCashOut,
acknowledgeFault,
cancel,
insertBill,
finishInserting,

View file

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

View file

@ -34,12 +34,6 @@ export interface TransactionRecord {
}[]
error?: string | null
remediatedBy?: string | null
/**
* ADR-005 §2: the dispense outcome to queue for spirekeeper, written in
* the same SQLite transaction as the row so a crash between the two cannot
* lose it. Cash-out only. Shape is @bitSpire/lnbits DispenseReportBody.
*/
report?: import('@bitSpire/lnbits').DispenseReportBody
}
export interface ATMAvailability {

View file

@ -63,19 +63,13 @@ watch(
const nestedState = computed(() => atmStore.nestedState)
const context = computed(() => atmStore.context)
// 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 }
// Dispense error 30s countdown
const dispenseErrorCountdown = ref(30)
let countdownTimer: ReturnType<typeof setInterval> | null = null
watch(nestedState, (newState, oldState) => {
const entering = typeof newState === 'string' && newState in TERMINAL_SECONDS
const leaving = typeof oldState === 'string' && oldState in TERMINAL_SECONDS
if (entering && newState !== oldState) {
if (countdownTimer) clearInterval(countdownTimer)
dispenseErrorCountdown.value = TERMINAL_SECONDS[newState as string] ?? 30
if (newState === 'dispenseError' && oldState !== 'dispenseError') {
dispenseErrorCountdown.value = 30
countdownTimer = setInterval(() => {
dispenseErrorCountdown.value--
if (dispenseErrorCountdown.value <= 0 && countdownTimer) {
@ -83,21 +77,12 @@ watch(nestedState, (newState, oldState) => {
countdownTimer = null
}
}, 1000)
} else if (leaving && !entering && countdownTimer) {
} else if (oldState === 'dispenseError' && countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
})
function acknowledgeFault() {
atmStore.acknowledgeFault()
}
const faultTime = computed(() => {
const t = context.value?.startedAt
return t ? new Date(t).toLocaleString() : ''
})
// Available denominations from inventory
const availableDenominations = computed(() => {
if (!context.value?.inventory) return []
@ -550,92 +535,63 @@ function formatFiat(cents: number): string {
</div>
</div>
<!-- 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. -->
<!-- Dispense Error (timed, 30s → idle) -->
<div
v-else-if="nestedState === 'dispenseFault' || nestedState === 'outOfCash'"
key="dispenseTerminal"
v-else-if="nestedState === 'dispenseError'"
key="dispenseError"
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))"
>
<div class="flex flex-col items-center gap-3 lg:gap-5 max-w-xl">
<!-- Left side — error details -->
<div class="flex flex-col items-center gap-3 lg:gap-5">
<div class="text-5xl lg:text-[8vh]">⚠️</div>
<h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">
{{ nestedState === 'dispenseFault' ? 'Dispenser fault' : 'Could not dispense' }}
</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.
<h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">Dispense Error</h3>
<p class="text-base lg:text-2xl text-muted-foreground">
{{ context?.error || 'Cash could not be dispensed' }}
</p>
<div class="w-full rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2">
<div class="flex justify-between text-base lg:text-2xl">
<span class="text-muted-foreground">You paid</span>
<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>
<!-- Partial dispense info -->
<div
v-for="bill in context?.dispenseResult?.bills ?? []"
v-if="context?.dispenseResult?.bills?.length"
class="w-full max-w-md rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2"
>
<div
v-for="bill in context.dispenseResult.bills"
:key="bill.denomination"
class="flex justify-between text-base lg:text-2xl"
>
<span class="text-muted-foreground"
>{{ atmStore.fiatSymbol }}{{ bill.denomination }} notes</span
>{{ atmStore.fiatSymbol }}{{ bill.denomination }}</span
>
<span :class="bill.dispensed > 0 ? 'text-success' : 'text-destructive'">
{{ bill.dispensed }} dispensed
<span v-if="bill.rejected > 0" class="text-destructive">
({{ bill.rejected }} rejected)
</span>
</span>
</div>
</div>
<p class="text-base lg:text-xl text-foreground text-center">
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 class="text-sm lg:text-lg text-muted-foreground">
Please contact support with the transaction ID below.
</p>
<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>
<!-- Countdown -->
<p class="text-sm lg:text-base text-muted-foreground">
Returning to start in {{ dispenseErrorCountdown }}s
</p>
<Button variant="outline" size="kiosk" @click="cancel"> Return to Start </Button>
</div>
<div v-if="context?.txid" class="flex flex-col items-center gap-3">
<QRCode :value="context.txid" :size="260" />
<p class="text-xs lg:text-sm text-muted-foreground">Transaction</p>
<!-- Right side — txid QR -->
<div v-if="context?.txid" class="flex flex-col items-center gap-4">
<QRCode :value="context.txid" :size="280" />
<p
class="font-mono-code text-sm lg:text-base text-foreground max-w-[320px] text-center break-all"
class="font-mono-code text-sm text-muted-foreground max-w-[300px] text-center break-all"
>
{{ context.txid }}
</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>

View file

@ -121,32 +121,16 @@ function handleCashOut() {
>
</button>
<!-- 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. -->
<!-- Sell Bitcoin -->
<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 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"
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]"
@click="handleCashOut"
>
<span class="text-4xl lg:text-[8vh] leading-none">{{
atmStore.context?.cashOutHeld ? '🔧' : '💵'
}}</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-4xl lg:text-[8vh] leading-none">💵</span>
<span class="text-base lg:text-[3.5vh] font-bold text-success">Sell Bitcoin</span>
<span class="text-[10px] lg:text-[1.8vh] text-foreground/70"
>Pay invoice, receive cash</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>
</div>

View file

@ -195,16 +195,10 @@ instead of a clean ledger.
Two distinct terminal states replace the single `dispenseError`:
- **`outOfCash`** — the request could not be met and the dispenser reported **no error**
(an inventory refusal, or simply short). In the cash-out flow this state is reached *after*
payment, so the customer **has paid** and is owed the shortfall exactly as below; the
difference is the cause — no hardware fault, so the machine stays in service and nothing
latches. (An earlier draft said "nothing was charged beyond what was dispensed"; that is
only true of the inventory check *before* payment, which already prevents the sale.)
- **`outOfCash`** — the request could not be met from inventory and the dispenser reported
**no error**. Nothing was charged beyond what was dispensed.
- **`dispenseFault`** — the dispenser reported an error. The customer **has paid** and is
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.
owed the shortfall.
`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,

View file

@ -1,60 +0,0 @@
/**
* 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

@ -1,64 +0,0 @@
/**
* 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

@ -1,66 +0,0 @@
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

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

View file

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

View file

@ -14,7 +14,6 @@ import type {
DispenserInitData,
DispenseResult,
} from '../../types.js'
import { tagDispenseError, type DispenseError } from '../error-codes.js'
export class PuloonDispenser implements BillDispenser {
public type: string = 'Puloon'
@ -47,26 +46,16 @@ export class PuloonDispenser implements BillDispenser {
}
}
async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: DispenseError }> {
async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: Error }> {
const { bills, error } = await this.device.dispense(notes)
if (error) {
await this.close()
error.name = 'PuloonDispenseError'
console.log('PULOON | dispense error', error)
// No decode table for the LCDM yet: every fault is terminal until one
// exists, so an unknown Puloon error latches cash-out off (ADR-005 §7).
return {
value: bills,
error: tagDispenseError(error, {
errorCode: 'PuloonDispenseError',
rawCode: (error as Error & { code?: string }).code,
errorClass: 'terminal',
human: `Puloon dispense error: ${error.message}`,
}),
}
}
return { value: bills }
return { value: bills, error }
}
async close(): Promise<void> {

View file

@ -57,20 +57,5 @@ export type {
DispenserFactory,
} from './types.js'
// Dispense error taxonomy (ADR-005 §7)
export {
tagDispenseError,
isDispenseError,
type DispenseError,
type DispenseErrorClass,
type DispenseErrorInfo,
} from './dispensers/error-codes.js'
export {
decodeF56Error,
listF56ErrorCodes,
normaliseF56Code,
F56_ERROR_CODE,
} from './dispensers/f56/error-codes.js'
// Utilities
export { compute as computeCrc } from './utils/crc.js'

View file

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

View file

@ -48,8 +48,6 @@ import type {
CreateWithdrawResult,
LnbitsWithdrawLink,
UniqueHashesResponse,
DispenseReportBody,
DispenseReportAck,
} from './types.js'
const LNBITS_KIND_RPC = 21000
@ -236,18 +234,6 @@ 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
// ============================================================================

View file

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

View file

@ -310,66 +310,3 @@ export interface MachineConfigResponse {
/** Freshness watermark (unix s) for the consumer's fee-config replay guard. */
created_at: number
}
// ============================================================================
// Dispense outcome report (ADR-005 §2) — machine → spirekeeper `report_dispense`
// ============================================================================
/** Per-denomination outcome. `requested` is what the sale asked for. */
export interface DispenseReportBill {
denomination: number
requested: number
dispensed: number
rejected: number
}
/** Per-bay outcome, verbatim from the machine's cassette_bills row. */
export interface DispenseReportCassette {
position: number
denomination: number
provisioned: number
dispensed: number
rejected: number
}
/**
* One cash-out's dispense outcome, sent on SUCCESS as well as failure — the
* success report is what captures the settlement server-side. Field names
* follow lamassu-server's cash_out_txs / cash_out_actions (dispense_confirmed,
* error, error_code) so the server's model lines up with ten years of prior
* art. Idempotent on `txid`: the machine resends until acked, the server
* upserts.
*/
export interface DispenseReportBody {
txid: string
/** Hash of the invoice the customer paid — the join key to the LNbits payment. */
payment_hash: string | null
tx_type: 'cash_out'
/** Σ(denomination × dispensed) === requested fiat value (computed on value). */
dispense_confirmed: boolean
/** Human message; null on success. */
error: string | null
/** The error's NAME, e.g. 'F56DispenseError'; null on success. */
error_code: string | null
/** Driver-native code, e.g. '78 42'; null when none. */
raw_code: string | null
error_class: 'terminal' | 'recoverable' | 'inventory' | null
fiat_cents: number
currency: string
bills: DispenseReportBill[]
cassettes: DispenseReportCassette[]
/** The machine could not vouch for its bay counts after this dispense. */
counts_uncertain: boolean
/** Set when this report closes an earlier failed txid via manual dispense. */
remediates_txid?: string
/** unix seconds the outcome was recorded on the machine */
at: number
}
/** Server acknowledgement. `settlement_status` is what the server moved the settlement to. */
export interface DispenseReportAck {
txid: string
received: boolean
settlement_status?: string
}

View file

@ -12,7 +12,7 @@ describe('ATM State Machine', () => {
sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
dispenseCash: vi.fn().mockResolvedValue({
bills: [{ denomination: 20, dispensed: 1, rejected: 0 }],
dispenseConfirmed: true,
dispensed: true,
} satisfies DispenseCashResult),
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available
@ -397,224 +397,127 @@ describe('ATM State Machine', () => {
})
})
describe('dispense outcome (ADR-005 §3–§5)', () => {
/** Drive a 1 × $20 cash-out to the dispense and return the actor. */
async function dispenseWith(result: DispenseCashResult, fake = false) {
const services: ATMServices = { ...mockServices, dispenseCash: vi.fn().mockResolvedValue(result) }
const actor = createActor(createATMMachine(services))
describe('dispense error handling', () => {
it('should route to waitingForCashTaken when dispenseCash returns dispensed: true', async () => {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
const tick = async (ms: number) =>
fake ? vi.advanceTimersByTimeAsync(ms) : new Promise((r) => setTimeout(r, ms))
actor.send({ type: 'SELECT_CASH_OUT' })
await tick(100)
await new Promise((resolve) => setTimeout(resolve, 100))
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'CONFIRM_AMOUNT' })
await tick(100)
await new Promise((resolve) => setTimeout(resolve, 100))
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
await tick(100)
return actor
}
await new Promise((resolve) => setTimeout(resolve, 100))
it('completes only on dispenseConfirmed (value equality), never on a driver boolean', async () => {
const actor = await dispenseWith({
bills: [{ denomination: 20, dispensed: 1, rejected: 0 }],
dispenseConfirmed: true,
})
const state = actor.getSnapshot()
// dispenseCash mock returns { dispensed: true }, so should go to waitingForCashTaken
expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' })
expect(state.context.cashDispensed).toBe(true)
expect(state.context.dispenseResult?.dispenseConfirmed).toBe(true)
expect(state.context.cashOutHeld).toBeNull()
expect(state.context.dispenseResult?.dispensed).toBe(true)
})
it('routes a hardware error to dispenseFault — the customer has paid and is owed', async () => {
const actor = await dispenseWith({
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseConfirmed: false,
error: 'Note stopped at the cassette exit',
errorCode: 'F56DispenseError',
rawCode: '78 42',
errorClass: 'terminal',
})
it('should route to dispenseError when dispenseCash returns dispensed: false', async () => {
const failDispenseServices: ATMServices = {
...mockServices,
dispenseCash: vi.fn().mockResolvedValue({
bills: [{ denomination: 20, dispensed: 0, rejected: 1 }],
dispensed: false,
error: 'Cassette jam',
} satisfies DispenseCashResult),
}
const machine = createATMMachine(failDispenseServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
await new Promise((resolve) => setTimeout(resolve, 100))
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'CONFIRM_AMOUNT' })
await new Promise((resolve) => setTimeout(resolve, 100))
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
await new Promise((resolve) => setTimeout(resolve, 100))
const state = actor.getSnapshot()
expect(state.value).toMatchObject({ cashOut: 'dispenseFault' })
expect(state.value).toMatchObject({ cashOut: 'dispenseError' })
expect(state.context.cashDispensed).toBe(false)
expect(state.context.error).toBe('Note stopped at the cassette exit')
expect(state.context.dispenseResult?.rawCode).toBe('78 42')
expect(state.context.dispenseResult?.dispensed).toBe(false)
expect(state.context.dispenseResult?.error).toBe('Cassette jam')
expect(state.context.error).toBe('Cassette jam')
})
it('a terminal fault latches cash-out off; idle refuses SELECT_CASH_OUT until released', async () => {
const actor = await dispenseWith({
it('should auto-idle after 30s in dispenseError state', async () => {
vi.useFakeTimers()
const failDispenseServices: ATMServices = {
...mockServices,
dispenseCash: vi.fn().mockResolvedValue({
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseConfirmed: false,
error: 'jam',
errorCode: 'F56DispenseError',
rawCode: '78 42',
errorClass: 'terminal',
dispensed: false,
error: 'Out of cash',
} satisfies DispenseCashResult),
}
const machine = createATMMachine(failDispenseServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
await vi.advanceTimersByTimeAsync(100)
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'CONFIRM_AMOUNT' })
await vi.advanceTimersByTimeAsync(100)
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
await vi.advanceTimersByTimeAsync(100)
// Should be in dispenseError
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseError' })
// Advance 30s
await vi.advanceTimersByTimeAsync(30000)
// Should have auto-idled
expect(actor.getSnapshot().value).toBe('idle')
vi.useRealTimers()
})
const hold = actor.getSnapshot().context.cashOutHeld
expect(hold).not.toBeNull()
expect(hold?.errorCode).toBe('F56DispenseError')
expect(hold?.rawCode).toBe('78 42')
expect(typeof hold?.since).toBe('number')
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' })
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,7 +10,6 @@ import {
type ATMContext,
type ATMEvent,
type DispenseCashResult,
type CashOutHold,
initialContext,
type ATMServices,
type OfferRequestEvent,
@ -149,8 +148,8 @@ export function createATMMachine(
}
// Extract payment hash from invoice (simplified - real impl would decode BOLT11)
// The service handles the actual extraction
const cleanup = services.watchInvoice(input.invoice, (preimage, paymentHash) => {
sendBack({ type: 'PAYMENT_RECEIVED', preimage, paymentHash })
const cleanup = services.watchInvoice(input.invoice, (preimage: string) => {
sendBack({ type: 'PAYMENT_RECEIVED', preimage })
})
return cleanup
}),
@ -185,9 +184,6 @@ export function createATMMachine(
// without this the just-granted session would be wiped. The session is
// cleared instead on re-lock (locked's entry), i.e. when access ends.
accessSession: context.accessSession,
// The cash-out latch is machine health, not transaction state — it
// survives every reset until an operator op releases it.
cashOutHeld: context.cashOutHeld,
cashInSessionId: null,
dispenseResult: null,
})),
@ -319,10 +315,6 @@ export function createATMMachine(
if (event.type !== 'PAYMENT_RECEIVED') return null
return event.preimage
},
paymentHash: ({ event }) => {
if (event.type !== 'PAYMENT_RECEIVED') return null
return event.paymentHash ?? null
},
}),
setPaymentFailed: assign({
paymentStatus: () => 'failed' as const,
@ -340,37 +332,6 @@ export function createATMMachine(
return output?.error ?? null
},
}),
// ADR-005 §5: a terminal fault latches cash-out off. Idempotent — an
// existing hold is kept (its `since` is the first fault, which is what
// the operator wants to know).
latchCashOutIfTerminal: assign({
cashOutHeld: ({ context }) => {
if (context.cashOutHeld) return context.cashOutHeld
const dr = context.dispenseResult
if (dr?.errorClass !== 'terminal') return null
return {
reason: dr.error ?? 'terminal dispenser fault',
errorCode: dr.errorCode ?? null,
rawCode: dr.rawCode ?? null,
since: Math.floor(Date.now() / 1000),
} satisfies CashOutHold
},
}),
setCashOutHeld: assign({
cashOutHeld: ({ event }) => (event.type === 'CASH_OUT_HELD' ? event.hold : null),
}),
clearCashOutHeld: assign({ cashOutHeld: null }),
setDispenseTimeoutError: assign({
error: () => 'Dispense timed out — hardware may be jammed',
dispenseResult: ({ context }) =>
context.dispenseResult ?? {
bills: [],
dispenseConfirmed: false,
error: 'Dispense timed out — hardware may be jammed',
errorCode: 'DispenseTimeout',
errorClass: 'terminal' as const,
},
}),
setAmount: assign({
fiatCents: ({ event }) => {
if (event.type !== 'SELECT_AMOUNT') return 0
@ -497,18 +458,6 @@ export function createATMMachine(
return principalSats - fee <= context.availableBalance
},
// Cash-out guards
// ADR-005 §5: no cash-out while latched. The idle screen shows why.
cashOutAvailable: ({ context }) => context.cashOutHeld === null,
// ADR-005 §3/§4: only value equality completes a dispense.
dispenseConfirmed: ({ event }) =>
(event as unknown as { output?: DispenseCashResult }).output?.dispenseConfirmed === true,
// A shortfall WITH a hardware error is a fault (customer paid, is owed);
// a shortfall with none, or an inventory refusal, is out-of-cash.
dispenseHadFault: ({ event }) => {
const out = (event as unknown as { output?: DispenseCashResult }).output
if (!out?.error) return false
return out.errorClass !== 'inventory'
},
hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0,
canAddDenomination: ({ context, event }) => {
if (event.type !== 'ADD_DENOMINATION') return false
@ -528,10 +477,7 @@ export function createATMMachine(
INVOICE_TIMEOUT: 300000, // 5 minutes — waiting for payment
COMPLETE_DELAY: 60000,
DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond
DISPENSE_ERROR_TIMEOUT: 30000, // 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,
DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState
// NOTE: idle inactivity re-lock + hard session cap are enforced at the
// DOM layer (useSessionSecurity), not as XState `after` delays — see the
// idle state comment. No IDLE_LOCK_TIMEOUT delay here by design.
@ -567,11 +513,6 @@ export function createATMMachine(
cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction,
}),
},
// ADR-005 §5 — the store restores a persisted hold on boot and
// releases it when an operator op lands. Root-level so it applies in
// any state; it only gates entry to cashOut, never an in-flight sale.
CASH_OUT_HELD: { actions: 'setCashOutHeld' },
CASH_OUT_RELEASED: { actions: 'clearCashOutHeld' },
},
states: {
// === ACCESS GATE (ADR-003) ===
@ -616,7 +557,6 @@ export function createATMMachine(
actions: ['setStartTime', 'setCashInFee'],
},
SELECT_CASH_OUT: {
guard: 'cashOutAvailable',
target: 'cashOut',
actions: ['setStartTime', 'setCashOutFee'],
},
@ -921,12 +861,13 @@ export function createATMMachine(
},
dispensingCash: {
// Safety timeout: if dispenseCash promise hangs (hardware jam,
// serial port freeze), don't stay here forever. A hang is a
// terminal fault — the transport state is unknown.
// serial port freeze), don't stay here forever.
after: {
DISPENSE_TIMEOUT: {
target: 'dispenseFault',
actions: ['setDispenseTimeoutError', 'latchCashOutIfTerminal'],
target: 'dispenseError',
actions: assign({
error: () => 'Dispense timed out — hardware may be jammed',
}),
},
},
invoke: {
@ -934,31 +875,21 @@ export function createATMMachine(
input: ({ context }) => context.dispenseAmounts,
onDone: [
{
// ADR-005 §3: value equality, nothing else, completes.
guard: 'dispenseConfirmed',
guard: ({ event }) =>
(event.output as unknown as DispenseCashResult | undefined)?.dispensed === true,
target: 'waitingForCashTaken',
actions: ['setCashDispensed', 'setDispenseResult'],
},
{
// ADR-005 §4: a hardware error means the customer has paid
// and is owed — the fault screen, with evidence. A terminal
// class also latches cash-out off (§5).
guard: 'dispenseHadFault',
target: 'dispenseFault',
actions: ['setDispenseResult', 'latchCashOutIfTerminal'],
},
{
// Shortfall with no hardware error, or an inventory refusal
// before anything was asked of the dispenser.
target: 'outOfCash',
// Partial or failed dispense
target: 'dispenseError',
actions: 'setDispenseResult',
},
],
onError: {
// The service threw (not a reported dispense failure). No
// per-bay report exists — the store flags counts unverified.
target: 'dispenseFault',
actions: ['setError', 'latchCashOutIfTerminal'],
// Unexpected crash (not a dispense failure)
target: 'dispenseError',
actions: 'setError',
},
},
},
@ -999,26 +930,9 @@ export function createATMMachine(
CANCEL: '#atm.locked',
},
},
// ADR-005 §4 — two terminal states replace the old dispenseError.
//
// dispenseFault: the dispenser reported an error. The customer HAS
// PAID and is owed the shortfall. The screen carries the txid, the
// payment hash, the amounts and a statement that the operator has
// been notified — this is lamassu's fiatTransactionError ("the
// right prompt when they have paid and are owed money"), not the
// out-of-cash screen a jam used to show.
dispenseFault: {
after: {
DISPENSE_FAULT_TIMEOUT: '#atm.locked',
},
on: {
ACKNOWLEDGE_FAULT: '#atm.locked',
CANCEL: '#atm.locked',
},
},
// outOfCash: the request could not be met and the dispenser
// reported NO error — nothing was charged beyond what was dispensed.
outOfCash: {
dispenseError: {
// Payment received but cash not (fully) dispensed.
// Show error + txid for 30s, then auto-idle (matches brain.js _timedState).
after: {
DISPENSE_ERROR_TIMEOUT: '#atm.locked',
},

View file

@ -21,48 +21,18 @@ export interface CassetteBillResult {
rejected: number
}
/** 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 */
/** Result of a dispense operation (always resolves, never throws) */
export interface DispenseCashResult {
/** Per-denomination results (what was actually dispensed) */
bills: { denomination: number; dispensed: number; rejected: number }[]
/**
* Σ(denomination × dispensed) equals the requested fiat value. Computed by
* the HAL on VALUE, never taken from a driver boolean. This is lamassu's
* `dispenseConfirmed` and it is the only thing that routes to `complete`.
*/
dispenseConfirmed: boolean
/** Human-readable message if dispense failed or was partial */
/** Whether the full requested amount was dispensed */
dispensed: boolean
/** Error message if dispense failed or was partial */
error?: string
/** The error's NAME, machine-readable — e.g. 'F56DispenseError' */
errorCode?: string
/** Driver-native code for the decode table — e.g. '78 42' */
rawCode?: string
/**
* terminal → dispenseFault + latch cash-out; recoverable → dispenseFault;
* inventory → outOfCash (nothing was asked of the hardware).
*/
errorClass?: DispenseErrorClass
/** Per-cassette detail (position-aware, produced by HAL) */
cassettes?: CassetteBillResult[]
}
/**
* Cash-out is latched off after a terminal dispenser fault (ADR-005 §5).
* Set by the machine when a fault lands; persisted and restored by the
* store; cleared only by an operator `recount` or `resume_cash_out` op —
* never by re-initialising the dispenser, which does not move a stuck note.
*/
export interface CashOutHold {
reason: string
errorCode: string | null
rawCode: string | null
/** unix seconds */
since: number
}
/** Payment methods supported */
export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu'
@ -152,12 +122,6 @@ export interface ATMContext {
paymentStatus: PaymentStatus
/** Payment preimage (proof of payment) */
preimage: string | null
/**
* Payment hash of the settled invoice — the join key to the LNbits payment
* the operator sees. Shown on the dispense-fault screen (ADR-005 §4) so a
* customer who is owed money leaves with the reference the server indexes.
*/
paymentHash: string | null
/** Payment method used */
paymentMethod: PaymentMethod | null
/** Pending offer request (Kind 21001 from user's wallet) */
@ -193,8 +157,6 @@ export interface ATMContext {
retryCount: number
/** Result from the last dispense operation */
dispenseResult: DispenseCashResult | null
/** Cash-out latched off after a terminal fault; null = available (ADR-005 §5) */
cashOutHeld: CashOutHold | null
// Transaction metadata
/** Unique transaction ID */
@ -237,14 +199,8 @@ export type ATMEvent =
| { type: 'BILL_REJECTED'; reason: string }
| { type: 'CASH_DISPENSED' }
| { type: 'DISPENSE_ERROR'; error: string }
// Customer acknowledges the fault screen ("I've saved this reference")
| { type: 'ACKNOWLEDGE_FAULT' }
// Cash-out latch (ADR-005 §5): the store restores a persisted hold on boot
// and releases it when an operator recount / resume_cash_out op lands.
| { type: 'CASH_OUT_HELD'; hold: CashOutHold }
| { type: 'CASH_OUT_RELEASED' }
// Payment events
| { type: 'PAYMENT_RECEIVED'; preimage: string; paymentHash?: string }
| { type: 'PAYMENT_RECEIVED'; preimage: string }
| { type: 'PAYMENT_FAILED'; error: string }
| { type: 'INVOICE_GENERATED'; invoice: string }
| { type: 'OFFER_GENERATED'; offer: string }
@ -289,7 +245,6 @@ export const initialContext: ATMContext = {
lnurlWithdraw: null,
paymentStatus: null,
preimage: null,
paymentHash: null,
paymentMethod: null,
pendingOfferRequest: null,
billsInserted: [],
@ -303,7 +258,6 @@ export const initialContext: ATMContext = {
error: null,
retryCount: 0,
dispenseResult: null,
cashOutHeld: null,
txid: null,
startedAt: null,
cashInSessionId: null,
@ -352,10 +306,7 @@ export interface ATMServices {
* Watch an invoice for payment (polling-based)
* Calls the callback when paid, returns cleanup function
*/
watchInvoice: (
invoice: string,
callback: (preimage: string, paymentHash?: string) => void
) => () => void
watchInvoice: (paymentHash: string, callback: (preimage: string) => void) => () => void
/**
* Get available inventory: denomination -> count
*/

155
pnpm-lock.yaml generated
View file

@ -96,9 +96,6 @@ importers:
'@types/node':
specifier: ^22.0.0
version: 22.19.7
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@vitejs/plugin-vue':
specifier: ^5.2.0
version: 5.2.4(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vue@3.5.27(typescript@5.9.3))
@ -114,9 +111,6 @@ importers:
esbuild:
specifier: ^0.27.4
version: 0.27.4
qrcode:
specifier: ^1.5.4
version: 1.5.4
tailwindcss:
specifier: ^4.0.0
version: 4.1.18
@ -1257,9 +1251,6 @@ packages:
'@types/plist@3.0.5':
resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/responselike@1.0.3':
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
@ -1563,10 +1554,6 @@ packages:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@ -1612,9 +1599,6 @@ packages:
resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
engines: {node: '>=8'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@ -1717,10 +1701,6 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
@ -1765,9 +1745,6 @@ packages:
detect-node@2.1.0:
resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dir-compare@4.2.0:
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
@ -1938,10 +1915,6 @@ packages:
filelist@1.0.4:
resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
@ -2286,10 +2259,6 @@ packages:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lodash-es@4.17.23:
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
@ -2546,36 +2515,20 @@ packages:
resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==}
engines: {node: '>=8'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-map@4.0.0:
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
engines: {node: '>=10'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
@ -2622,10 +2575,6 @@ packages:
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
engines: {node: '>=10.4.0'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@ -2676,11 +2625,6 @@ packages:
peerDependencies:
vue: ^3.0.0
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
quick-lru@5.1.1:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
@ -2712,9 +2656,6 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resedit@1.7.2:
resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==}
engines: {node: '>=12', npm: '>=6'}
@ -2926,7 +2867,7 @@ packages:
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
temp-file@3.4.0:
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
@ -3193,9 +3134,6 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@ -3209,10 +3147,6 @@ packages:
wide-align@1.1.5:
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@ -3231,9 +3165,6 @@ packages:
xstate@5.25.1:
resolution: {integrity: sha512-oyvsNH5pF2qkHmiHEMdWqc3OjDtoZOH2MTAI35r01f/ZQWOD+VLOiYqo65UgQET0XMA5s9eRm8fnsIo+82biEw==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@ -3241,18 +3172,10 @@ packages:
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@ -4006,10 +3929,6 @@ snapshots:
xmlbuilder: 15.1.1
optional: true
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.19.7
'@types/responselike@1.0.3':
dependencies:
'@types/node': 22.19.7
@ -4452,8 +4371,6 @@ snapshots:
es-errors: 1.3.0
function-bind: 1.1.2
camelcase@5.3.1: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@ -4495,12 +4412,6 @@ snapshots:
string-width: 4.2.3
optional: true
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@ -4591,8 +4502,6 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
@ -4632,8 +4541,6 @@ snapshots:
detect-node@2.1.0:
optional: true
dijkstrajs@1.0.3: {}
dir-compare@4.2.0:
dependencies:
minimatch: 3.1.2
@ -4908,11 +4815,6 @@ snapshots:
dependencies:
minimatch: 5.1.6
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
@ -5275,10 +5177,6 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
lodash-es@4.17.23: {}
lodash.defaults@4.2.0: {}
@ -5528,30 +5426,18 @@ snapshots:
p-cancelable@2.1.1: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-map@4.0.0:
dependencies:
aggregate-error: 3.1.0
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
path-browserify@1.0.1: {}
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
path-key@3.1.1: {}
@ -5589,8 +5475,6 @@ snapshots:
base64-js: 1.5.1
xmlbuilder: 15.1.1
pngjs@5.0.0: {}
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@ -5638,12 +5522,6 @@ snapshots:
dependencies:
vue: 3.5.27(typescript@5.9.3)
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
quick-lru@5.1.1: {}
rc@1.2.8:
@ -5698,8 +5576,6 @@ snapshots:
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
resedit@1.7.2:
dependencies:
pe-library: 0.4.1
@ -6174,8 +6050,6 @@ snapshots:
dependencies:
defaults: 1.0.4
which-module@2.0.1: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
@ -6189,12 +6063,6 @@ snapshots:
dependencies:
string-width: 4.2.3
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@ -6213,33 +6081,12 @@ snapshots:
xstate@5.25.1: {}
y18n@4.0.3: {}
y18n@5.0.8: {}
yallist@4.0.0: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@21.1.1: {}
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@17.7.2:
dependencies:
cliui: 8.0.1