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). * so it's available at runtime (unlike src/ which is only for Vite).
*/ */
import type { BillValidator, BillDispenser, DispenseErrorClass } from '@bitSpire/hal' import type { BillValidator, BillDispenser } from '@bitSpire/hal'
import { isDispenseError } from '@bitSpire/hal'
export interface CassetteConfig { export interface CassetteConfig {
/** /**
@ -49,20 +48,8 @@ export interface ValidatorCallbacks {
export interface DispenseResult { export interface DispenseResult {
bills: { denomination: number; dispensed: number; rejected: number }[] bills: { denomination: number; dispensed: number; rejected: number }[]
/** dispensed: boolean
* Σ(denomination × dispensed) === Σ(denomination × requested). Computed
* here on VALUE (ADR-005 §3) — never a driver boolean. Only this routes
* the state machine to `complete`.
*/
dispenseConfirmed: boolean
/** Human message when not confirmed */
error?: string error?: string
/** The error's NAME — 'F56DispenseError', 'InsufficientInventory', … */
errorCode?: string
/** Driver-native code, e.g. '78 42' */
rawCode?: string
/** terminal | recoverable | inventory — see @bitSpire/hal error-codes */
errorClass?: DispenseErrorClass
cassettes?: { cassettes?: {
name: string name: string
position: number position: number
@ -290,8 +277,6 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
notes[i] = (notes[i] ?? 0) + take notes[i] = (notes[i] ?? 0) + take
remaining -= take remaining -= take
} }
// Nothing has been asked of the hardware in either refusal below:
// errorClass 'inventory' routes to outOfCash, not the fault screen.
if (!matched) { if (!matched) {
return { return {
bills: amounts.map((a) => ({ bills: amounts.map((a) => ({
@ -299,10 +284,8 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
dispensed: 0, dispensed: 0,
rejected: 0, rejected: 0,
})), })),
dispenseConfirmed: false, dispensed: false,
error: `No cassette loaded with denomination: ${denomination}`, error: `No cassette loaded with denomination: ${denomination}`,
errorCode: 'NoCassetteForDenomination',
errorClass: 'inventory',
} }
} }
if (remaining > 0) { if (remaining > 0) {
@ -312,10 +295,8 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
dispensed: 0, dispensed: 0,
rejected: 0, rejected: 0,
})), })),
dispenseConfirmed: false, dispensed: false,
error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`, 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()) const bills = Array.from(billsByDenom.values())
// ADR-005 §3: confirmation is VALUE equality — what left the bays is const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
// worth exactly what was asked — not a count, and not the driver's
// opinion. lamassu computed the same thing (`tx.fiat.eq(Σ denomination
// × dispensed)`); our previous count-based check was only equivalent
// while every bay dispensed its own denomination.
const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0)
const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0)
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0) const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
const dispenseConfirmed = requestedValue === dispensedValue
if (result.error) { if (result.error) {
const e = result.error return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message }
const info = isDispenseError(e)
? { errorCode: e.errorCode, rawCode: e.rawCode, errorClass: e.errorClass, human: e.human }
: {
// Unreachable by type (drivers always tag), kept as a defensive
// fallback for a driver that slips an untagged Error through.
errorCode: (e as Error).name || 'DispenseError',
rawCode: undefined,
errorClass: 'terminal' as const,
human: (e as Error).message,
}
console.error(
`[HAL] Dispense error ${info.errorCode}${info.rawCode ? ` ${info.rawCode}` : ''} (${info.errorClass}): ${info.human} — requested ${requestedValue}, dispensed ${dispensedValue}`
)
// A dispensed value of zero WITH an error is not evidence that nothing
// left the bay — a note stopped in the transport completes neither
// counter (sintra, 2026-10-09). The store reads this combination and
// flags counts unverified; we just report faithfully here.
return {
bills,
cassettes: cassetteResults,
dispenseConfirmed: false,
error: info.human,
errorCode: info.errorCode,
rawCode: info.rawCode,
errorClass: info.errorClass,
}
} }
// Wait for customer to take bills // Wait for customer to take bills
@ -409,20 +357,7 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
console.log('[HAL] Bills removed by customer') console.log('[HAL] Bills removed by customer')
} }
if (!dispenseConfirmed) { return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
// Short with no hardware error — the dispenser simply gave less.
console.warn(`[HAL] Dispense short with no error: requested ${requestedValue}, dispensed ${dispensedValue}`)
return {
bills,
cassettes: cassetteResults,
dispenseConfirmed: false,
error: `Dispensed ${dispensedValue} of ${requestedValue} with no dispenser error`,
errorCode: 'DispenseShort',
errorClass: 'inventory',
}
}
return { bills, cassettes: cassetteResults, dispenseConfirmed: true }
}, },
/** /**

View file

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

View file

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

View file

@ -10,13 +10,12 @@
*/ */
import Database from 'better-sqlite3' import Database from 'better-sqlite3'
import type { DispenseReportBody } from '@bitSpire/lnbits'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs' import fs from 'node:fs'
let db: Database.Database | null = null let db: Database.Database | null = null
const SCHEMA_VERSION = '14' const SCHEMA_VERSION = '13'
function getDbPath(): string { function getDbPath(): string {
const prodDir = '/var/lib/bitspire' const prodDir = '/var/lib/bitspire'
@ -137,16 +136,6 @@ export function initDatabase(dbPath?: string): void {
relays TEXT, relays TEXT,
lnbits_server_pubkey TEXT lnbits_server_pubkey TEXT
); );
CREATE TABLE IF NOT EXISTS dispense_reports (
txid TEXT PRIMARY KEY REFERENCES transactions(txid),
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at INTEGER,
last_error TEXT,
acked_at INTEGER
);
`) `)
// Seed meta + cashbox if first run, or run migrations // Seed meta + cashbox if first run, or run migrations
@ -429,32 +418,6 @@ export function initDatabase(dbPath?: string): void {
existing.value = '13' existing.value = '13'
} }
if (existing && existing.value === '13') {
// Migration v13 → v14: the dispense-report outbox (ADR-005 §2).
//
// Every cash-out's outcome — success or failure — is reported to
// spirekeeper over a kind-21000 RPC, and that report is what lets the
// server capture (distribute) the settlement or surface a customer who
// is owed cash. A relay gives the publisher no delivery guarantee, so the
// report is written here, in the SAME transaction as the transactions
// row, and resent until the server acknowledges it. Idempotent on txid
// server-side; `attempts` / `last_error` drive the resend backoff.
db.exec(`
CREATE TABLE IF NOT EXISTS dispense_reports (
txid TEXT PRIMARY KEY REFERENCES transactions(txid),
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_attempt_at INTEGER,
last_error TEXT,
acked_at INTEGER
);
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('14', 'schema_version')
console.log('[StateStore] Migrated schema v13 → v14 (added dispense_reports outbox)')
existing.value = '14'
}
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations. // Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
// Seed the operator-config meta rows if they're missing (idempotent). // Seed the operator-config meta rows if they're missing (idempotent).
const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)') const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)')
@ -554,133 +517,6 @@ export function clearCountsUncertain(): void {
).run('countsUncertainSince', '') ).run('countsUncertainSince', '')
} }
// ---------------------------------------------------------------------------
// Cash-out hold (ADR-005 §5)
// ---------------------------------------------------------------------------
//
// A terminal dispenser fault latches cash-out off. The latch is machine
// health, so it lives in `meta` (one JSON value) and survives restarts; the
// renderer restores it into the state machine on boot and the operator
// releases it with a `recount` or `resume_cash_out` op. Re-initialising the
// dispenser never clears it — re-init does not move a stuck note.
export interface CashOutHold {
reason: string
errorCode: string | null
rawCode: string | null
/** unix seconds of the FIRST fault — kept across repeat faults */
since: number
}
export function getCashOutHold(): CashOutHold | null {
if (!db) throw new Error('Database not initialized')
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cashOutHeld') as
| { value: string }
| undefined
if (!row || row.value === '') return null
try {
const parsed = JSON.parse(row.value) as Partial<CashOutHold>
if (typeof parsed.since !== 'number' || typeof parsed.reason !== 'string') return null
return {
reason: parsed.reason,
errorCode: typeof parsed.errorCode === 'string' ? parsed.errorCode : null,
rawCode: typeof parsed.rawCode === 'string' ? parsed.rawCode : null,
since: parsed.since,
}
} catch {
return null
}
}
/** Latch cash-out off. Idempotent: an existing hold (and its `since`) is kept. */
export function setCashOutHold(hold: CashOutHold): CashOutHold {
if (!db) throw new Error('Database not initialized')
const existing = getCashOutHold()
if (existing) return existing
db.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run('cashOutHeld', JSON.stringify(hold))
console.warn(
`[StateStore] Cash-out HELD: ${hold.errorCode ?? 'fault'}${hold.rawCode ? ` ${hold.rawCode}` : ''} — ${hold.reason}`
)
return hold
}
/** Release the latch — an operator has cleared the machine. */
export function clearCashOutHold(): boolean {
if (!db) throw new Error('Database not initialized')
const had = getCashOutHold() !== null
db.prepare(
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run('cashOutHeld', '')
if (had) console.log('[StateStore] Cash-out hold released')
return had
}
// ---------------------------------------------------------------------------
// Dispense-report outbox (ADR-005 §2)
// ---------------------------------------------------------------------------
export interface PendingDispenseReport {
txid: string
payload: DispenseReportBody
createdAt: number
attempts: number
lastAttemptAt: number | null
lastError: string | null
}
/** Unacknowledged reports, oldest first. The renderer applies the backoff. */
export function pendingDispenseReports(limit = 20): PendingDispenseReport[] {
if (!db) throw new Error('Database not initialized')
const rows = db
.prepare(
'SELECT txid, payload, created_at, attempts, last_attempt_at, last_error FROM dispense_reports WHERE acked_at IS NULL ORDER BY created_at ASC LIMIT ?'
)
.all(limit) as Array<{
txid: string
payload: string
created_at: number
attempts: number
last_attempt_at: number | null
last_error: string | null
}>
const out: PendingDispenseReport[] = []
for (const r of rows) {
try {
out.push({
txid: r.txid,
payload: JSON.parse(r.payload) as DispenseReportBody,
createdAt: r.created_at,
attempts: r.attempts,
lastAttemptAt: r.last_attempt_at,
lastError: r.last_error,
})
} catch {
console.error('[StateStore] dispense_reports row has unparseable payload:', r.txid)
}
}
return out
}
/** The server acknowledged this report. Returns whether a row changed. */
export function markDispenseReportAcked(txid: string): boolean {
if (!db) throw new Error('Database not initialized')
const res = db
.prepare('UPDATE dispense_reports SET acked_at = ? WHERE txid = ? AND acked_at IS NULL')
.run(Date.now(), txid)
if (res.changes > 0) console.log('[StateStore] Dispense report acked:', txid)
return res.changes > 0
}
/** A send was attempted and did not get an OK. Drives the resend backoff. */
export function noteDispenseReportAttempt(txid: string, error: string | null): void {
if (!db) throw new Error('Database not initialized')
db.prepare(
'UPDATE dispense_reports SET attempts = attempts + 1, last_attempt_at = ?, last_error = ? WHERE txid = ?'
).run(Date.now(), error, txid)
}
/** /**
* A counter bumped on every local change to a bay count, from any cause. * A counter bumped on every local change to a bay count, from any cause.
* *
@ -1015,12 +851,7 @@ export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult {
// A recount is an operator opening the bay and counting it, which is // A recount is an operator opening the bay and counting it, which is
// exactly what resolves an unverified count. Nothing else does: a refill // exactly what resolves an unverified count. Nothing else does: a refill
// adds to a number still known to be wrong. // adds to a number still known to be wrong.
if (sawRecount) { if (sawRecount) upsertMeta.run('countsUncertainSince', '')
upsertMeta.run('countsUncertainSince', '')
// ADR-005 §5: a recount is an operator at the open machine — the one
// gesture that also releases a cash-out hold.
upsertMeta.run('cashOutHeld', '')
}
})() })()
console.log( console.log(
@ -1342,11 +1173,6 @@ interface TransactionInput {
rejected: number rejected: number
}[] }[]
error?: string | null error?: string | null
/**
* ADR-005 §2: dispense outcome to queue for spirekeeper. Inserted in the
* same transaction as the row so a crash between them cannot lose it.
*/
report?: DispenseReportBody
} }
/** /**
@ -1368,12 +1194,6 @@ export function recordTransaction(tx: TransactionInput): void {
const insertBill = db.prepare( const insertBill = db.prepare(
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)' 'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
) )
// Outbox row (ADR-005 §2). REPLACE: a re-record of the same txid (should not
// happen, but a crash-replay could) refreshes the payload and resets the
// delivery state rather than failing the whole transaction.
const insertReport = db.prepare(
'INSERT OR REPLACE INTO dispense_reports (txid, payload, created_at, attempts, last_attempt_at, last_error, acked_at) VALUES (?, ?, ?, 0, NULL, NULL, NULL)'
)
const insertCassetteBill = db.prepare( const insertCassetteBill = db.prepare(
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
) )
@ -1406,10 +1226,6 @@ export function recordTransaction(tx: TransactionInput): void {
insertBill.run(t.txid, bill.denomination, bill.count) insertBill.run(t.txid, bill.denomination, bill.count)
} }
if (t.report) {
insertReport.run(t.txid, JSON.stringify(t.report), Date.now())
}
// Insert per-cassette detail when available // Insert per-cassette detail when available
if (t.cassettes) { if (t.cassettes) {
for (const c of t.cassettes) { for (const c of t.cassettes) {

View file

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

View file

@ -29,14 +29,8 @@ interface UseAvailabilityBroadcastOptions {
signer: Signer signer: Signer
/** Reactive inventory: denomination -> count */ /** Reactive inventory: denomination -> count */
inventory: Ref<Record<number, number>> inventory: Ref<Record<number, number>>
/** Reactive wallet balance in sats (null = unknown) */ /** Reactive Lightning.Pub balance in sats (null = unknown) */
balanceSats: Ref<number | null> balanceSats: Ref<number | null>
/**
* ADR-005 §5: cash-out is latched off after a terminal dispenser fault.
* A machine with full bays and a jammed transport must not advertise
* cash-out — that is exactly what sintra did for an hour on 2026-10-09.
*/
cashOutHeld?: Ref<boolean>
/** Fiat currency code */ /** Fiat currency code */
fiatCode: string fiatCode: string
/** Machine model */ /** Machine model */
@ -44,7 +38,7 @@ interface UseAvailabilityBroadcastOptions {
} }
export function useAvailabilityBroadcast(options: 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 let lastSnapshot: AvailabilitySnapshot | null = null
@ -59,7 +53,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
function computeSnapshot(): AvailabilitySnapshot { function computeSnapshot(): AvailabilitySnapshot {
const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0) const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0)
return { return {
cashOut: totalBills > 0 && !(cashOutHeld?.value ?? false), cashOut: totalBills > 0,
cashIn: (balanceSats.value ?? 0) > 0, cashIn: (balanceSats.value ?? 0) > 0,
cashLevel: computeCashLevel(), cashLevel: computeCashLevel(),
} }
@ -107,7 +101,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
// Watch reactive sources // Watch reactive sources
watch( watch(
cashOutHeld ? [inventory, balanceSats, cashOutHeld] : [inventory, balanceSats], [inventory, balanceSats],
() => { () => {
debouncedPublish() debouncedPublish()
}, },

View file

@ -147,10 +147,8 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
dispensed: 0, dispensed: 0,
rejected: 0, rejected: 0,
})), })),
dispenseConfirmed: false, dispensed: false,
error: `No cassette loaded with denomination: ${denomination}`, error: `No cassette loaded with denomination: ${denomination}`,
errorCode: 'NoCassetteForDenomination',
errorClass: 'inventory',
} }
} }
notes[idx] = count notes[idx] = count
@ -184,23 +182,11 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
rejected: c.rejected, rejected: c.rejected,
})) }))
// ADR-005 §3: confirmation on VALUE. Same contract as electron/hal-service.ts. const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0)
const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0)
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0) const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
const dispenseConfirmed = requestedValue === dispensedValue
if (result.error) { if (result.error) {
const e = result.error return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message }
return {
bills,
cassettes: cassetteResults,
dispenseConfirmed: false,
error: e.human ?? e.message,
errorCode: e.errorCode ?? e.name,
rawCode: e.rawCode,
errorClass: e.errorClass ?? 'terminal',
}
} }
// Wait for customer to take bills (only if bills were dispensed) // Wait for customer to take bills (only if bills were dispensed)
@ -209,18 +195,7 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
console.log('[HAL] Bills removed by customer') console.log('[HAL] Bills removed by customer')
} }
if (!dispenseConfirmed) { return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
return {
bills,
cassettes: cassetteResults,
dispenseConfirmed: false,
error: `Dispensed ${dispensedValue} of ${requestedValue} with no dispenser error`,
errorCode: 'DispenseShort',
errorClass: 'inventory',
}
}
return { bills, cassettes: cassetteResults, dispenseConfirmed: true }
}, },
getInventory: async () => { getInventory: async () => {

View file

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

View file

@ -44,7 +44,7 @@ const KIND_NIP78 = 30078
/** The wire schema this machine speaks. Operations, not counts (ADR-004). */ /** The wire schema this machine speaks. Operations, not counts (ADR-004). */
const CASSETTE_SCHEMA_VERSION = 2 const CASSETTE_SCHEMA_VERSION = 2
/** One operator-authored cassette operation, as it arrives on the wire. */ /** One operator-authored operation, as it arrives on the wire. */
type CassetteOp = { type CassetteOp = {
id: string id: string
at: number at: number
@ -55,18 +55,6 @@ type CassetteOp = {
denomination?: number denomination?: number
} }
/**
* ADR-005 §5: the operator releases a cash-out hold without touching a bay
* count. Rides the same operator event as the cassette ops (same id/at shape)
* but is NOT a cassette op: it never reaches `applyOperatorCassetteOps`, which
* would reject the type. Honoured only when stamped AFTER the hold began, so
* a re-delivered resume from before a fresh fault cannot clear that fault.
* A `recount` releases the hold too — it is the same "operator at the open
* machine" gesture and already clears counts-uncertain.
*/
type ResumeCashOutOp = { id: string; at: number; type: 'resume_cash_out' }
type OperatorOp = CassetteOp | ResumeCashOutOp
/** Accept operator events stamped up to this many seconds in the future. */ /** Accept operator events stamped up to this many seconds in the future. */
const MAX_FUTURE_SKEW_S = 60 const MAX_FUTURE_SKEW_S = 60
@ -97,12 +85,6 @@ export interface OperatorConfigServiceConfig {
operatorPubkeys: string[] operatorPubkeys: string[]
/** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */ /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
machineId?: string machineId?: string
/**
* ADR-005 §5: called when an operator op (recount, resume_cash_out) has
* released a persisted cash-out hold, so the store can lift the state
* machine's latch. The store wires this to `CASH_OUT_RELEASED`.
*/
onCashOutHoldReleased?: () => void
} }
export interface OperatorConfigService { export interface OperatorConfigService {
@ -240,28 +222,7 @@ async function handleOperatorConfigEvent(
console.error('[OperatorConfig] Payload missing `ops` array — dropped') console.error('[OperatorConfig] Payload missing `ops` array — dropped')
return return
} }
const allOps = parsed.ops as OperatorOp[] const ops = parsed.ops as CassetteOp[]
// 4b. ADR-005 §5 — split out resume_cash_out before the cassette apply.
// Release only if a resume is stamped after the hold began; an idempotent
// re-delivery of an older resume must not clear a newer fault.
const holdBefore = await api.getCashOutHold()
const resumeOps = allOps.filter(
(o): o is ResumeCashOutOp => !!o && o.type === 'resume_cash_out'
)
const ops = allOps.filter((o): o is CassetteOp => !!o && o.type !== 'resume_cash_out')
if (holdBefore && resumeOps.some((o) => typeof o.at === 'number' && o.at > holdBefore.since)) {
await api.clearCashOutHold()
console.log(
`[OperatorConfig] Cash-out hold released by operator resume op ` +
`(held since ${holdBefore.since}, ${resumeOps.length} resume op(s))`
)
} else if (resumeOps.length > 0) {
console.log(
`[OperatorConfig] ${resumeOps.length} resume_cash_out op(s) ignored — ` +
(holdBefore ? 'all stamped before the current hold began' : 'no hold in place')
)
}
// 5. Apply the ones we have not seen, in one transaction with the sequence // 5. Apply the ones we have not seen, in one transaction with the sequence
// bump. No `created_at` watermark: each op carries an operator-minted id // bump. No `created_at` watermark: each op carries an operator-minted id
@ -269,19 +230,10 @@ async function handleOperatorConfigEvent(
// no-op on its own merits. The watermark would be strictly weaker and // no-op on its own merits. The watermark would be strictly weaker and
// actively harmful — an event arriving out of order can still carry an // actively harmful — an event arriving out of order can still carry an
// operation this machine has never seen. // operation this machine has never seen.
const result = ops.length const result = await api.applyOperatorCassetteOps(ops)
? await api.applyOperatorCassetteOps(ops)
: { applied: [] as string[], rejected: [] as { id: string; reason: string }[] }
for (const bad of result.rejected) { for (const bad of result.rejected) {
console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`) console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`)
} }
// A recount (applied in the store, which also clears the hold) or the resume
// above may have released the latch: tell the store so the state machine
// lifts its guard. The republishes below carry the cleared state up.
if (holdBefore && (await api.getCashOutHold()) === null) {
cfg.onCashOutHoldReleased?.()
}
if (result.applied.length === 0) { if (result.applied.length === 0) {
console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`) console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`)
// Still republish: the operator learns from our applied_ops echo that // Still republish: the operator learns from our applied_ops echo that
@ -366,15 +318,6 @@ async function publishCassettesState(
applied_ops: appliedOps, applied_ops: appliedOps,
} }
if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince
// ADR-005 §5 — additive, same contract as counts_uncertain_since: an old
// consumer ignores it. When present, this machine is refusing cash-out
// until an operator recount or resume_cash_out op.
const hold = await api.getCashOutHold()
if (hold) {
payload.cash_out_held_since = hold.since
payload.cash_out_held_reason = hold.reason
payload.cash_out_held_code = hold.rawCode ?? hold.errorCode ?? null
}
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload)) const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload))
// Force the stamp strictly above our last one. Addressable events are ordered // Force the stamp strictly above our last one. Addressable events are ordered

View file

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

View file

@ -150,33 +150,6 @@ declare global {
/** When the bay counts became unverified (a dispense that reported nothing), or null. */ /** When the bay counts became unverified (a dispense that reported nothing), or null. */
getCountsUncertainSince: () => Promise<number | null> getCountsUncertainSince: () => Promise<number | null>
markCountsUncertain: (unixTimestamp: number) => Promise<void> markCountsUncertain: (unixTimestamp: number) => Promise<void>
// Cash-out hold (ADR-005 §5)
getCashOutHold: () => Promise<{
reason: string
errorCode: string | null
rawCode: string | null
since: number
} | null>
setCashOutHold: (hold: {
reason: string
errorCode: string | null
rawCode: string | null
since: number
}) => Promise<{ reason: string; errorCode: string | null; rawCode: string | null; since: number }>
clearCashOutHold: () => Promise<boolean>
// Dispense-report outbox (ADR-005 §2)
pendingDispenseReports: (limit?: number) => Promise<
Array<{
txid: string
payload: unknown
createdAt: number
attempts: number
lastAttemptAt: number | null
lastError: string | null
}>
>
ackDispenseReport: (txid: string) => Promise<boolean>
noteDispenseReportAttempt: (txid: string, error: string | null) => Promise<void>
markStatePublished: (unixTimestamp: number) => Promise<void> markStatePublished: (unixTimestamp: number) => Promise<void>
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void> saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void> clearBunkerBinding: () => Promise<void>

View file

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

View file

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

View file

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

View file

@ -195,16 +195,10 @@ instead of a clean ledger.
Two distinct terminal states replace the single `dispenseError`: Two distinct terminal states replace the single `dispenseError`:
- **`outOfCash`** — the request could not be met and the dispenser reported **no error** - **`outOfCash`** — the request could not be met from inventory and the dispenser reported
(an inventory refusal, or simply short). In the cash-out flow this state is reached *after* **no error**. Nothing was charged beyond what was dispensed.
payment, so the customer **has paid** and is owed the shortfall exactly as below; the
difference is the cause — no hardware fault, so the machine stays in service and nothing
latches. (An earlier draft said "nothing was charged beyond what was dispensed"; that is
only true of the inventory check *before* payment, which already prevents the sale.)
- **`dispenseFault`** — the dispenser reported an error. The customer **has paid** and is - **`dispenseFault`** — the dispenser reported an error. The customer **has paid** and is
owed the shortfall, and a `terminal` class also latches cash-out off (Decision 5). owed the shortfall.
Both screens therefore show the same evidence; the heading and the latch differ.
`dispenseFault` shows: the amount paid, the amount dispensed (per denomination, as now), the `dispenseFault` shows: the amount paid, the amount dispensed (per denomination, as now), the
txid as QR (as now) **and as text**, the first 12 characters of the payment hash, the time, txid as QR (as now) **and as text**, the first 12 characters of the payment hash, the time,

View file

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

View file

@ -8,8 +8,6 @@
*/ */
import * as f56 from './f56-rs232.js' import * as f56 from './f56-rs232.js'
import { decodeF56Error } from './error-codes.js'
import { tagDispenseError, type DispenseError } from '../error-codes.js'
import type { import type {
BillDispenser, BillDispenser,
DispenserConfig, DispenserConfig,
@ -53,28 +51,23 @@ export class F56Dispenser implements BillDispenser {
} }
} }
/** async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: Error }> {
* On any failure the port is closed so the next attempt re-initialises;
* the error is tagged with the ADR-005 taxonomy (class + decoded meaning)
* so the caller can route it. No statusCode: lamassu's 570 meant
* "insufficient funds" to its server and sent operators to refill full
* cassettes after jams.
*/
async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: DispenseError }> {
try { try {
const { bills, error, rawCode } = await f56.billCount(notes) const { bills, error } = await f56.billCount(notes)
if (error) { if (error) {
await this.close() 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) { } catch (err) {
await this.close() await this.close()
const error = err instanceof Error ? err : new Error(String(err)) const error = err as Error
// No frame: serial timeout / framing. decodeF56Error(undefined) → terminal. ;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError'
return { value: [], error: tagDispenseError(error, decodeF56Error(undefined)) } ;(error as Error & { statusCode: number }).statusCode = 570
return { value: [], error }
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,7 +12,7 @@ describe('ATM State Machine', () => {
sendNostrReceipt: vi.fn().mockResolvedValue(undefined), sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
dispenseCash: vi.fn().mockResolvedValue({ dispenseCash: vi.fn().mockResolvedValue({
bills: [{ denomination: 20, dispensed: 1, rejected: 0 }], bills: [{ denomination: 20, dispensed: 1, rejected: 0 }],
dispenseConfirmed: true, dispensed: true,
} satisfies DispenseCashResult), } satisfies DispenseCashResult),
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available
@ -397,224 +397,127 @@ describe('ATM State Machine', () => {
}) })
}) })
describe('dispense outcome (ADR-005 §3–§5)', () => { describe('dispense error handling', () => {
/** Drive a 1 × $20 cash-out to the dispense and return the actor. */ it('should route to waitingForCashTaken when dispenseCash returns dispensed: true', async () => {
async function dispenseWith(result: DispenseCashResult, fake = false) { const machine = createATMMachine(mockServices)
const services: ATMServices = { ...mockServices, dispenseCash: vi.fn().mockResolvedValue(result) } const actor = createActor(machine)
const actor = createActor(createATMMachine(services))
actor.start() actor.start()
const tick = async (ms: number) =>
fake ? vi.advanceTimersByTimeAsync(ms) : new Promise((r) => setTimeout(r, ms))
actor.send({ type: 'SELECT_CASH_OUT' }) actor.send({ type: 'SELECT_CASH_OUT' })
await tick(100) await new Promise((resolve) => setTimeout(resolve, 100))
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'CONFIRM_AMOUNT' }) actor.send({ type: 'CONFIRM_AMOUNT' })
await tick(100) await new Promise((resolve) => setTimeout(resolve, 100))
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
await tick(100) await new Promise((resolve) => setTimeout(resolve, 100))
return actor
}
it('completes only on dispenseConfirmed (value equality), never on a driver boolean', async () => {
const actor = await dispenseWith({
bills: [{ denomination: 20, dispensed: 1, rejected: 0 }],
dispenseConfirmed: true,
})
const state = actor.getSnapshot() const state = actor.getSnapshot()
// dispenseCash mock returns { dispensed: true }, so should go to waitingForCashTaken
expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' }) expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' })
expect(state.context.cashDispensed).toBe(true) expect(state.context.cashDispensed).toBe(true)
expect(state.context.dispenseResult?.dispenseConfirmed).toBe(true) expect(state.context.dispenseResult?.dispensed).toBe(true)
expect(state.context.cashOutHeld).toBeNull()
}) })
it('routes a hardware error to dispenseFault — the customer has paid and is owed', async () => { it('should route to dispenseError when dispenseCash returns dispensed: false', async () => {
const actor = await dispenseWith({ const failDispenseServices: ATMServices = {
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], ...mockServices,
dispenseConfirmed: false, dispenseCash: vi.fn().mockResolvedValue({
error: 'Note stopped at the cassette exit', bills: [{ denomination: 20, dispensed: 0, rejected: 1 }],
errorCode: 'F56DispenseError', dispensed: false,
rawCode: '78 42', error: 'Cassette jam',
errorClass: 'terminal', } 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() 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.cashDispensed).toBe(false)
expect(state.context.error).toBe('Note stopped at the cassette exit') expect(state.context.dispenseResult?.dispensed).toBe(false)
expect(state.context.dispenseResult?.rawCode).toBe('78 42') 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 () => { it('should auto-idle after 30s in dispenseError state', async () => {
const actor = await dispenseWith({ vi.useFakeTimers()
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseConfirmed: false, const failDispenseServices: ATMServices = {
error: 'jam', ...mockServices,
errorCode: 'F56DispenseError', dispenseCash: vi.fn().mockResolvedValue({
rawCode: '78 42', bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
errorClass: 'terminal', dispensed: false,
}) error: 'Out of cash',
const hold = actor.getSnapshot().context.cashOutHeld } satisfies DispenseCashResult),
expect(hold).not.toBeNull() }
expect(hold?.errorCode).toBe('F56DispenseError')
expect(hold?.rawCode).toBe('78 42') const machine = createATMMachine(failDispenseServices)
expect(typeof hold?.since).toBe('number') const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
await vi.advanceTimersByTimeAsync(100)
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'CONFIRM_AMOUNT' })
await vi.advanceTimersByTimeAsync(100)
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
await vi.advanceTimersByTimeAsync(100)
// Should be in dispenseError
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseError' })
// Advance 30s
await vi.advanceTimersByTimeAsync(30000)
// Should have auto-idled
expect(actor.getSnapshot().value).toBe('idle')
vi.useRealTimers()
})
it('should allow CANCEL from dispenseError to go to idle immediately', async () => {
const failDispenseServices: ATMServices = {
...mockServices,
dispenseCash: vi.fn().mockResolvedValue({
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispensed: false,
error: 'Jam',
} satisfies DispenseCashResult),
}
const machine = createATMMachine(failDispenseServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
await new Promise((resolve) => setTimeout(resolve, 100))
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'CONFIRM_AMOUNT' })
await new Promise((resolve) => setTimeout(resolve, 100))
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
await new Promise((resolve) => setTimeout(resolve, 100))
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseError' })
actor.send({ type: 'CANCEL' }) actor.send({ type: 'CANCEL' })
expect(actor.getSnapshot().value).toBe('idle') expect(actor.getSnapshot().value).toBe('idle')
// The hold survives resetContext — it is machine health, not transaction state.
expect(actor.getSnapshot().context.cashOutHeld).not.toBeNull()
actor.send({ type: 'SELECT_CASH_OUT' })
expect(actor.getSnapshot().value).toBe('idle')
// Only an operator op releases it (the store sends this on recount / resume_cash_out).
actor.send({ type: 'CASH_OUT_RELEASED' })
expect(actor.getSnapshot().context.cashOutHeld).toBeNull()
actor.send({ type: 'SELECT_CASH_OUT' })
expect(actor.getSnapshot().value).toMatchObject({ cashOut: expect.anything() })
})
it('a hold gates cash-out only — cash-in is unaffected by a dispenser fault', () => {
const actor = createActor(createATMMachine(mockServices))
actor.start()
actor.send({
type: 'CASH_OUT_HELD',
hold: { reason: 'jam', errorCode: 'F56DispenseError', rawCode: '78 42', since: 1 },
})
actor.send({ type: 'SELECT_CASH_IN' })
expect(actor.getSnapshot().value).toMatchObject({ cashIn: expect.anything() })
})
it('a recoverable fault shows the fault screen but does NOT latch', async () => {
const actor = await dispenseWith({
bills: [{ denomination: 20, dispensed: 0, rejected: 1 }],
dispenseConfirmed: false,
error: 'Bill length check failed (long)',
errorCode: 'F56DispenseError',
rawCode: '82 00',
errorClass: 'recoverable',
})
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
expect(actor.getSnapshot().context.cashOutHeld).toBeNull()
})
it('a partial WITH an error is a fault (owed the shortfall), not out-of-cash', async () => {
const actor = await dispenseWith({
bills: [{ denomination: 20, dispensed: 1, rejected: 1 }],
dispenseConfirmed: false,
error: 'jam after first note',
errorCode: 'F56DispenseError',
rawCode: '78 42',
errorClass: 'terminal',
})
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
})
it('an inventory refusal (nothing asked of the hardware) is outOfCash, and does not latch', async () => {
const actor = await dispenseWith({
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseConfirmed: false,
error: 'Insufficient inventory for denomination 20: short 1',
errorCode: 'InsufficientInventory',
errorClass: 'inventory',
})
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' })
expect(actor.getSnapshot().context.cashOutHeld).toBeNull()
})
it('a shortfall with NO error is outOfCash', async () => {
const actor = await dispenseWith({
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseConfirmed: false,
})
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' })
})
it('a persisted hold restored on boot gates cash-out before any dispense', () => {
const actor = createActor(createATMMachine(mockServices))
actor.start()
actor.send({
type: 'CASH_OUT_HELD',
hold: { reason: 'jam', errorCode: 'F56DispenseError', rawCode: '78 42', since: 1791529353 },
})
actor.send({ type: 'SELECT_CASH_OUT' })
expect(actor.getSnapshot().value).toBe('idle')
expect(actor.getSnapshot().context.cashOutHeld?.since).toBe(1791529353)
})
it('outOfCash auto-returns after 30s; dispenseFault gives the customer 120s', async () => {
vi.useFakeTimers()
try {
const ooc = await dispenseWith(
{ bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], dispenseConfirmed: false },
true
)
expect(ooc.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' })
await vi.advanceTimersByTimeAsync(30000)
expect(ooc.getSnapshot().value).toBe('idle')
const fault = await dispenseWith(
{
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseConfirmed: false,
error: 'jam',
errorCode: 'F56DispenseError',
errorClass: 'recoverable',
},
true
)
expect(fault.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
await vi.advanceTimersByTimeAsync(30000)
expect(fault.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
await vi.advanceTimersByTimeAsync(90000)
expect(fault.getSnapshot().value).toBe('idle')
} finally {
vi.useRealTimers()
}
})
it('the customer can acknowledge the fault screen or cancel; both return to idle', async () => {
const a = await dispenseWith({
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseConfirmed: false,
error: 'jam',
errorClass: 'recoverable',
})
a.send({ type: 'ACKNOWLEDGE_FAULT' })
expect(a.getSnapshot().value).toBe('idle')
const b = await dispenseWith({
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseConfirmed: false,
error: 'jam',
errorClass: 'recoverable',
})
b.send({ type: 'CANCEL' })
expect(b.getSnapshot().value).toBe('idle')
})
it('a hung dispense (timeout) is a terminal fault and latches', async () => {
vi.useFakeTimers()
try {
const hang: ATMServices = {
...mockServices,
dispenseCash: vi.fn().mockReturnValue(new Promise(() => {})),
}
const actor = createActor(createATMMachine(hang))
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
await vi.advanceTimersByTimeAsync(100)
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'CONFIRM_AMOUNT' })
await vi.advanceTimersByTimeAsync(100)
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'p' })
await vi.advanceTimersByTimeAsync(120000 + 10)
const s = actor.getSnapshot()
expect(s.value).toMatchObject({ cashOut: 'dispenseFault' })
expect(s.context.dispenseResult?.errorCode).toBe('DispenseTimeout')
expect(s.context.cashOutHeld).not.toBeNull()
} finally {
vi.useRealTimers()
}
}) })
}) })

View file

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

View file

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

155
pnpm-lock.yaml generated
View file

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