ADR-005 rollout step 1: value-confirmed dispense, fault screens, cash-out latch, dispense-report outbox #123
29 changed files with 1579 additions and 208 deletions
|
|
@ -6,7 +6,8 @@
|
||||||
* so it's available at runtime (unlike src/ which is only for Vite).
|
* so it's available at runtime (unlike src/ which is only for Vite).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BillValidator, BillDispenser } from '@bitSpire/hal'
|
import type { BillValidator, BillDispenser, DispenseErrorClass } from '@bitSpire/hal'
|
||||||
|
import { isDispenseError } from '@bitSpire/hal'
|
||||||
|
|
||||||
export interface CassetteConfig {
|
export interface CassetteConfig {
|
||||||
/**
|
/**
|
||||||
|
|
@ -48,8 +49,20 @@ export interface ValidatorCallbacks {
|
||||||
|
|
||||||
export interface DispenseResult {
|
export interface DispenseResult {
|
||||||
bills: { denomination: number; dispensed: number; rejected: number }[]
|
bills: { denomination: number; dispensed: number; rejected: number }[]
|
||||||
dispensed: boolean
|
/**
|
||||||
|
* Σ(denomination × dispensed) === Σ(denomination × requested). Computed
|
||||||
|
* here on VALUE (ADR-005 §3) — never a driver boolean. Only this routes
|
||||||
|
* the state machine to `complete`.
|
||||||
|
*/
|
||||||
|
dispenseConfirmed: boolean
|
||||||
|
/** Human message when not confirmed */
|
||||||
error?: string
|
error?: string
|
||||||
|
/** The error's NAME — 'F56DispenseError', 'InsufficientInventory', … */
|
||||||
|
errorCode?: string
|
||||||
|
/** Driver-native code, e.g. '78 42' */
|
||||||
|
rawCode?: string
|
||||||
|
/** terminal | recoverable | inventory — see @bitSpire/hal error-codes */
|
||||||
|
errorClass?: DispenseErrorClass
|
||||||
cassettes?: {
|
cassettes?: {
|
||||||
name: string
|
name: string
|
||||||
position: number
|
position: number
|
||||||
|
|
@ -277,6 +290,8 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
notes[i] = (notes[i] ?? 0) + take
|
notes[i] = (notes[i] ?? 0) + take
|
||||||
remaining -= take
|
remaining -= take
|
||||||
}
|
}
|
||||||
|
// Nothing has been asked of the hardware in either refusal below:
|
||||||
|
// errorClass 'inventory' routes to outOfCash, not the fault screen.
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
return {
|
return {
|
||||||
bills: amounts.map((a) => ({
|
bills: amounts.map((a) => ({
|
||||||
|
|
@ -284,8 +299,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
dispensed: 0,
|
dispensed: 0,
|
||||||
rejected: 0,
|
rejected: 0,
|
||||||
})),
|
})),
|
||||||
dispensed: false,
|
dispenseConfirmed: false,
|
||||||
error: `No cassette loaded with denomination: ${denomination}`,
|
error: `No cassette loaded with denomination: ${denomination}`,
|
||||||
|
errorCode: 'NoCassetteForDenomination',
|
||||||
|
errorClass: 'inventory',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
|
|
@ -295,8 +312,10 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
dispensed: 0,
|
dispensed: 0,
|
||||||
rejected: 0,
|
rejected: 0,
|
||||||
})),
|
})),
|
||||||
dispensed: false,
|
dispenseConfirmed: false,
|
||||||
error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`,
|
error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`,
|
||||||
|
errorCode: 'InsufficientInventory',
|
||||||
|
errorClass: 'inventory',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -344,11 +363,44 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
}
|
}
|
||||||
const bills = Array.from(billsByDenom.values())
|
const bills = Array.from(billsByDenom.values())
|
||||||
|
|
||||||
const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
|
// ADR-005 §3: confirmation is VALUE equality — what left the bays is
|
||||||
|
// worth exactly what was asked — not a count, and not the driver's
|
||||||
|
// opinion. lamassu computed the same thing (`tx.fiat.eq(Σ denomination
|
||||||
|
// × dispensed)`); our previous count-based check was only equivalent
|
||||||
|
// while every bay dispensed its own denomination.
|
||||||
|
const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0)
|
||||||
|
const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0)
|
||||||
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
||||||
|
const dispenseConfirmed = requestedValue === dispensedValue
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message }
|
const e = result.error
|
||||||
|
const info = isDispenseError(e)
|
||||||
|
? { errorCode: e.errorCode, rawCode: e.rawCode, errorClass: e.errorClass, human: e.human }
|
||||||
|
: {
|
||||||
|
// Unreachable by type (drivers always tag), kept as a defensive
|
||||||
|
// fallback for a driver that slips an untagged Error through.
|
||||||
|
errorCode: (e as Error).name || 'DispenseError',
|
||||||
|
rawCode: undefined,
|
||||||
|
errorClass: 'terminal' as const,
|
||||||
|
human: (e as Error).message,
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
`[HAL] Dispense error ${info.errorCode}${info.rawCode ? ` ${info.rawCode}` : ''} (${info.errorClass}): ${info.human} — requested ${requestedValue}, dispensed ${dispensedValue}`
|
||||||
|
)
|
||||||
|
// A dispensed value of zero WITH an error is not evidence that nothing
|
||||||
|
// left the bay — a note stopped in the transport completes neither
|
||||||
|
// counter (sintra, 2026-10-09). The store reads this combination and
|
||||||
|
// flags counts unverified; we just report faithfully here.
|
||||||
|
return {
|
||||||
|
bills,
|
||||||
|
cassettes: cassetteResults,
|
||||||
|
dispenseConfirmed: false,
|
||||||
|
error: info.human,
|
||||||
|
errorCode: info.errorCode,
|
||||||
|
rawCode: info.rawCode,
|
||||||
|
errorClass: info.errorClass,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for customer to take bills
|
// Wait for customer to take bills
|
||||||
|
|
@ -357,7 +409,20 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
|
||||||
console.log('[HAL] Bills removed by customer')
|
console.log('[HAL] Bills removed by customer')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
|
if (!dispenseConfirmed) {
|
||||||
|
// Short with no hardware error — the dispenser simply gave less.
|
||||||
|
console.warn(`[HAL] Dispense short with no error: requested ${requestedValue}, dispensed ${dispensedValue}`)
|
||||||
|
return {
|
||||||
|
bills,
|
||||||
|
cassettes: cassetteResults,
|
||||||
|
dispenseConfirmed: false,
|
||||||
|
error: `Dispensed ${dispensedValue} of ${requestedValue} with no dispenser error`,
|
||||||
|
errorCode: 'DispenseShort',
|
||||||
|
errorClass: 'inventory',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bills, cassettes: cassetteResults, dispenseConfirmed: true }
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,13 @@ import {
|
||||||
getCountsUncertainSince,
|
getCountsUncertainSince,
|
||||||
getLastStatePublishedAt,
|
getLastStatePublishedAt,
|
||||||
markCountsUncertain,
|
markCountsUncertain,
|
||||||
|
getCashOutHold,
|
||||||
|
setCashOutHold,
|
||||||
|
clearCashOutHold,
|
||||||
|
type CashOutHold,
|
||||||
|
pendingDispenseReports,
|
||||||
|
markDispenseReportAcked,
|
||||||
|
noteDispenseReportAttempt,
|
||||||
markStatePublished,
|
markStatePublished,
|
||||||
resetStatePublishWatermark,
|
resetStatePublishWatermark,
|
||||||
resetForRepair,
|
resetForRepair,
|
||||||
|
|
@ -565,6 +572,31 @@ ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCount
|
||||||
ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => {
|
ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => {
|
||||||
markCountsUncertain(unixTimestamp)
|
markCountsUncertain(unixTimestamp)
|
||||||
})
|
})
|
||||||
|
// Cash-out hold (ADR-005 §5)
|
||||||
|
ipcMain.handle('state:get-cash-out-hold', (): CashOutHold | null => getCashOutHold())
|
||||||
|
ipcMain.handle('state:set-cash-out-hold', (_event, hold: CashOutHold): CashOutHold => {
|
||||||
|
if (!hold || typeof hold.reason !== 'string' || typeof hold.since !== 'number') {
|
||||||
|
throw new Error('Invalid cash-out hold')
|
||||||
|
}
|
||||||
|
return setCashOutHold(hold)
|
||||||
|
})
|
||||||
|
ipcMain.handle('state:clear-cash-out-hold', (): boolean => clearCashOutHold())
|
||||||
|
|
||||||
|
// Dispense-report outbox (ADR-005 §2) — at-least-once to spirekeeper
|
||||||
|
ipcMain.handle('state:pending-dispense-reports', (_event, limit?: number) =>
|
||||||
|
pendingDispenseReports(typeof limit === 'number' ? limit : 20)
|
||||||
|
)
|
||||||
|
ipcMain.handle('state:ack-dispense-report', (_event, txid: string): boolean => {
|
||||||
|
if (typeof txid !== 'string' || !txid) throw new Error('Invalid txid')
|
||||||
|
return markDispenseReportAcked(txid)
|
||||||
|
})
|
||||||
|
ipcMain.handle(
|
||||||
|
'state:note-dispense-report-attempt',
|
||||||
|
(_event, txid: string, error: string | null): void => {
|
||||||
|
if (typeof txid !== 'string' || !txid) throw new Error('Invalid txid')
|
||||||
|
noteDispenseReportAttempt(txid, typeof error === 'string' ? error.slice(0, 512) : null)
|
||||||
|
}
|
||||||
|
)
|
||||||
ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
|
ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => {
|
||||||
markStatePublished(unixTimestamp)
|
markStatePublished(unixTimestamp)
|
||||||
})
|
})
|
||||||
|
|
@ -841,7 +873,7 @@ function startCommandPoller(): void {
|
||||||
recordTransaction({
|
recordTransaction({
|
||||||
txid,
|
txid,
|
||||||
type: 'manual_dispense',
|
type: 'manual_dispense',
|
||||||
status: result.dispensed ? 'complete' : 'dispense_error',
|
status: result.dispenseConfirmed ? 'complete' : 'dispense_error',
|
||||||
fiatCents: totalFiatCents,
|
fiatCents: totalFiatCents,
|
||||||
sats: 0,
|
sats: 0,
|
||||||
feeSats: 0,
|
feeSats: 0,
|
||||||
|
|
@ -860,7 +892,7 @@ function startCommandPoller(): void {
|
||||||
|
|
||||||
// Only remediate the original tx if ALL requested bills were dispensed
|
// Only remediate the original tx if ALL requested bills were dispensed
|
||||||
let refRemediated = false
|
let refRemediated = false
|
||||||
if (parsed.ref_txid && result.dispensed) {
|
if (parsed.ref_txid && result.dispenseConfirmed) {
|
||||||
refRemediated = remediateTransaction(parsed.ref_txid, txid)
|
refRemediated = remediateTransaction(parsed.ref_txid, txid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -868,7 +900,13 @@ function startCommandPoller(): void {
|
||||||
cmd.id,
|
cmd.id,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
txid,
|
txid,
|
||||||
dispensed: result.dispensed,
|
// Wire key kept as `dispensed` — spirekeeper's command poller
|
||||||
|
// reads it. Value is the ADR-005 value-equality confirmation.
|
||||||
|
dispensed: result.dispenseConfirmed,
|
||||||
|
dispense_confirmed: result.dispenseConfirmed,
|
||||||
|
error_code: result.errorCode,
|
||||||
|
raw_code: result.rawCode,
|
||||||
|
error_class: result.errorClass,
|
||||||
ref_remediated: refRemediated,
|
ref_remediated: refRemediated,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,24 @@
|
||||||
|
|
||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
|
||||||
|
/** Mirrors state-store.CashOutHold (ADR-005 §5) — preload can't import main-process modules. */
|
||||||
|
interface CashOutHold {
|
||||||
|
reason: string
|
||||||
|
errorCode: string | null
|
||||||
|
rawCode: string | null
|
||||||
|
since: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors state-store.PendingDispenseReport (ADR-005 §2). */
|
||||||
|
interface PendingDispenseReport {
|
||||||
|
txid: string
|
||||||
|
payload: unknown
|
||||||
|
createdAt: number
|
||||||
|
attempts: number
|
||||||
|
lastAttemptAt: number | null
|
||||||
|
lastError: string | null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime configuration interface (public info only)
|
* Runtime configuration interface (public info only)
|
||||||
* These values are read from environment variables at runtime (not build time)
|
* These values are read from environment variables at runtime (not build time)
|
||||||
|
|
@ -114,6 +132,18 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
ipcRenderer.invoke('state:get-counts-uncertain-since'),
|
ipcRenderer.invoke('state:get-counts-uncertain-since'),
|
||||||
markCountsUncertain: (unixTimestamp: number): Promise<void> =>
|
markCountsUncertain: (unixTimestamp: number): Promise<void> =>
|
||||||
ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp),
|
ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp),
|
||||||
|
// Cash-out hold (ADR-005 §5)
|
||||||
|
getCashOutHold: (): Promise<CashOutHold | null> => ipcRenderer.invoke('state:get-cash-out-hold'),
|
||||||
|
setCashOutHold: (hold: CashOutHold): Promise<CashOutHold> =>
|
||||||
|
ipcRenderer.invoke('state:set-cash-out-hold', hold),
|
||||||
|
clearCashOutHold: (): Promise<boolean> => ipcRenderer.invoke('state:clear-cash-out-hold'),
|
||||||
|
// Dispense-report outbox (ADR-005 §2)
|
||||||
|
pendingDispenseReports: (limit?: number): Promise<PendingDispenseReport[]> =>
|
||||||
|
ipcRenderer.invoke('state:pending-dispense-reports', limit),
|
||||||
|
ackDispenseReport: (txid: string): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke('state:ack-dispense-report', txid),
|
||||||
|
noteDispenseReportAttempt: (txid: string, error: string | null): Promise<void> =>
|
||||||
|
ipcRenderer.invoke('state:note-dispense-report-attempt', txid, error),
|
||||||
markStatePublished: (unixTimestamp: number): Promise<void> =>
|
markStatePublished: (unixTimestamp: number): Promise<void> =>
|
||||||
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
|
ipcRenderer.invoke('state:mark-state-published', unixTimestamp),
|
||||||
|
|
||||||
|
|
@ -307,6 +337,12 @@ declare global {
|
||||||
getLastStatePublishedAt: () => Promise<number | null>
|
getLastStatePublishedAt: () => Promise<number | null>
|
||||||
getCountsUncertainSince: () => Promise<number | null>
|
getCountsUncertainSince: () => Promise<number | null>
|
||||||
markCountsUncertain: (unixTimestamp: number) => Promise<void>
|
markCountsUncertain: (unixTimestamp: number) => Promise<void>
|
||||||
|
getCashOutHold: () => Promise<CashOutHold | null>
|
||||||
|
setCashOutHold: (hold: CashOutHold) => Promise<CashOutHold>
|
||||||
|
clearCashOutHold: () => Promise<boolean>
|
||||||
|
pendingDispenseReports: (limit?: number) => Promise<PendingDispenseReport[]>
|
||||||
|
ackDispenseReport: (txid: string) => Promise<boolean>
|
||||||
|
noteDispenseReportAttempt: (txid: string, error: string | null) => Promise<void>
|
||||||
markStatePublished: (unixTimestamp: number) => Promise<void>
|
markStatePublished: (unixTimestamp: number) => Promise<void>
|
||||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||||
clearBunkerBinding: () => Promise<void>
|
clearBunkerBinding: () => Promise<void>
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,13 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
|
import type { DispenseReportBody } from '@bitSpire/lnbits'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
|
|
||||||
let db: Database.Database | null = null
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
const SCHEMA_VERSION = '13'
|
const SCHEMA_VERSION = '14'
|
||||||
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
const prodDir = '/var/lib/bitspire'
|
const prodDir = '/var/lib/bitspire'
|
||||||
|
|
@ -136,6 +137,16 @@ export function initDatabase(dbPath?: string): void {
|
||||||
relays TEXT,
|
relays TEXT,
|
||||||
lnbits_server_pubkey TEXT
|
lnbits_server_pubkey TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dispense_reports (
|
||||||
|
txid TEXT PRIMARY KEY REFERENCES transactions(txid),
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_attempt_at INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
acked_at INTEGER
|
||||||
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Seed meta + cashbox if first run, or run migrations
|
// Seed meta + cashbox if first run, or run migrations
|
||||||
|
|
@ -418,6 +429,32 @@ export function initDatabase(dbPath?: string): void {
|
||||||
existing.value = '13'
|
existing.value = '13'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existing && existing.value === '13') {
|
||||||
|
// Migration v13 → v14: the dispense-report outbox (ADR-005 §2).
|
||||||
|
//
|
||||||
|
// Every cash-out's outcome — success or failure — is reported to
|
||||||
|
// spirekeeper over a kind-21000 RPC, and that report is what lets the
|
||||||
|
// server capture (distribute) the settlement or surface a customer who
|
||||||
|
// is owed cash. A relay gives the publisher no delivery guarantee, so the
|
||||||
|
// report is written here, in the SAME transaction as the transactions
|
||||||
|
// row, and resent until the server acknowledges it. Idempotent on txid
|
||||||
|
// server-side; `attempts` / `last_error` drive the resend backoff.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS dispense_reports (
|
||||||
|
txid TEXT PRIMARY KEY REFERENCES transactions(txid),
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_attempt_at INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
acked_at INTEGER
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('14', 'schema_version')
|
||||||
|
console.log('[StateStore] Migrated schema v13 → v14 (added dispense_reports outbox)')
|
||||||
|
existing.value = '14'
|
||||||
|
}
|
||||||
|
|
||||||
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
||||||
// Seed the operator-config meta rows if they're missing (idempotent).
|
// Seed the operator-config meta rows if they're missing (idempotent).
|
||||||
const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)')
|
const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)')
|
||||||
|
|
@ -517,6 +554,133 @@ export function clearCountsUncertain(): void {
|
||||||
).run('countsUncertainSince', '')
|
).run('countsUncertainSince', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cash-out hold (ADR-005 §5)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// A terminal dispenser fault latches cash-out off. The latch is machine
|
||||||
|
// health, so it lives in `meta` (one JSON value) and survives restarts; the
|
||||||
|
// renderer restores it into the state machine on boot and the operator
|
||||||
|
// releases it with a `recount` or `resume_cash_out` op. Re-initialising the
|
||||||
|
// dispenser never clears it — re-init does not move a stuck note.
|
||||||
|
|
||||||
|
export interface CashOutHold {
|
||||||
|
reason: string
|
||||||
|
errorCode: string | null
|
||||||
|
rawCode: string | null
|
||||||
|
/** unix seconds of the FIRST fault — kept across repeat faults */
|
||||||
|
since: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCashOutHold(): CashOutHold | null {
|
||||||
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cashOutHeld') as
|
||||||
|
| { value: string }
|
||||||
|
| undefined
|
||||||
|
if (!row || row.value === '') return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.value) as Partial<CashOutHold>
|
||||||
|
if (typeof parsed.since !== 'number' || typeof parsed.reason !== 'string') return null
|
||||||
|
return {
|
||||||
|
reason: parsed.reason,
|
||||||
|
errorCode: typeof parsed.errorCode === 'string' ? parsed.errorCode : null,
|
||||||
|
rawCode: typeof parsed.rawCode === 'string' ? parsed.rawCode : null,
|
||||||
|
since: parsed.since,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latch cash-out off. Idempotent: an existing hold (and its `since`) is kept. */
|
||||||
|
export function setCashOutHold(hold: CashOutHold): CashOutHold {
|
||||||
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
const existing = getCashOutHold()
|
||||||
|
if (existing) return existing
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
|
).run('cashOutHeld', JSON.stringify(hold))
|
||||||
|
console.warn(
|
||||||
|
`[StateStore] Cash-out HELD: ${hold.errorCode ?? 'fault'}${hold.rawCode ? ` ${hold.rawCode}` : ''} — ${hold.reason}`
|
||||||
|
)
|
||||||
|
return hold
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release the latch — an operator has cleared the machine. */
|
||||||
|
export function clearCashOutHold(): boolean {
|
||||||
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
const had = getCashOutHold() !== null
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
|
).run('cashOutHeld', '')
|
||||||
|
if (had) console.log('[StateStore] Cash-out hold released')
|
||||||
|
return had
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dispense-report outbox (ADR-005 §2)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PendingDispenseReport {
|
||||||
|
txid: string
|
||||||
|
payload: DispenseReportBody
|
||||||
|
createdAt: number
|
||||||
|
attempts: number
|
||||||
|
lastAttemptAt: number | null
|
||||||
|
lastError: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unacknowledged reports, oldest first. The renderer applies the backoff. */
|
||||||
|
export function pendingDispenseReports(limit = 20): PendingDispenseReport[] {
|
||||||
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT txid, payload, created_at, attempts, last_attempt_at, last_error FROM dispense_reports WHERE acked_at IS NULL ORDER BY created_at ASC LIMIT ?'
|
||||||
|
)
|
||||||
|
.all(limit) as Array<{
|
||||||
|
txid: string
|
||||||
|
payload: string
|
||||||
|
created_at: number
|
||||||
|
attempts: number
|
||||||
|
last_attempt_at: number | null
|
||||||
|
last_error: string | null
|
||||||
|
}>
|
||||||
|
const out: PendingDispenseReport[] = []
|
||||||
|
for (const r of rows) {
|
||||||
|
try {
|
||||||
|
out.push({
|
||||||
|
txid: r.txid,
|
||||||
|
payload: JSON.parse(r.payload) as DispenseReportBody,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
attempts: r.attempts,
|
||||||
|
lastAttemptAt: r.last_attempt_at,
|
||||||
|
lastError: r.last_error,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
console.error('[StateStore] dispense_reports row has unparseable payload:', r.txid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server acknowledged this report. Returns whether a row changed. */
|
||||||
|
export function markDispenseReportAcked(txid: string): boolean {
|
||||||
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
const res = db
|
||||||
|
.prepare('UPDATE dispense_reports SET acked_at = ? WHERE txid = ? AND acked_at IS NULL')
|
||||||
|
.run(Date.now(), txid)
|
||||||
|
if (res.changes > 0) console.log('[StateStore] Dispense report acked:', txid)
|
||||||
|
return res.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A send was attempted and did not get an OK. Drives the resend backoff. */
|
||||||
|
export function noteDispenseReportAttempt(txid: string, error: string | null): void {
|
||||||
|
if (!db) throw new Error('Database not initialized')
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE dispense_reports SET attempts = attempts + 1, last_attempt_at = ?, last_error = ? WHERE txid = ?'
|
||||||
|
).run(Date.now(), error, txid)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A counter bumped on every local change to a bay count, from any cause.
|
* A counter bumped on every local change to a bay count, from any cause.
|
||||||
*
|
*
|
||||||
|
|
@ -851,7 +1015,12 @@ export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult {
|
||||||
// A recount is an operator opening the bay and counting it, which is
|
// A recount is an operator opening the bay and counting it, which is
|
||||||
// exactly what resolves an unverified count. Nothing else does: a refill
|
// exactly what resolves an unverified count. Nothing else does: a refill
|
||||||
// adds to a number still known to be wrong.
|
// adds to a number still known to be wrong.
|
||||||
if (sawRecount) upsertMeta.run('countsUncertainSince', '')
|
if (sawRecount) {
|
||||||
|
upsertMeta.run('countsUncertainSince', '')
|
||||||
|
// ADR-005 §5: a recount is an operator at the open machine — the one
|
||||||
|
// gesture that also releases a cash-out hold.
|
||||||
|
upsertMeta.run('cashOutHeld', '')
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|
@ -1173,6 +1342,11 @@ interface TransactionInput {
|
||||||
rejected: number
|
rejected: number
|
||||||
}[]
|
}[]
|
||||||
error?: string | null
|
error?: string | null
|
||||||
|
/**
|
||||||
|
* ADR-005 §2: dispense outcome to queue for spirekeeper. Inserted in the
|
||||||
|
* same transaction as the row so a crash between them cannot lose it.
|
||||||
|
*/
|
||||||
|
report?: DispenseReportBody
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1194,6 +1368,12 @@ export function recordTransaction(tx: TransactionInput): void {
|
||||||
const insertBill = db.prepare(
|
const insertBill = db.prepare(
|
||||||
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
|
'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)'
|
||||||
)
|
)
|
||||||
|
// Outbox row (ADR-005 §2). REPLACE: a re-record of the same txid (should not
|
||||||
|
// happen, but a crash-replay could) refreshes the payload and resets the
|
||||||
|
// delivery state rather than failing the whole transaction.
|
||||||
|
const insertReport = db.prepare(
|
||||||
|
'INSERT OR REPLACE INTO dispense_reports (txid, payload, created_at, attempts, last_attempt_at, last_error, acked_at) VALUES (?, ?, ?, 0, NULL, NULL, NULL)'
|
||||||
|
)
|
||||||
const insertCassetteBill = db.prepare(
|
const insertCassetteBill = db.prepare(
|
||||||
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||||
)
|
)
|
||||||
|
|
@ -1226,6 +1406,10 @@ export function recordTransaction(tx: TransactionInput): void {
|
||||||
insertBill.run(t.txid, bill.denomination, bill.count)
|
insertBill.run(t.txid, bill.denomination, bill.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (t.report) {
|
||||||
|
insertReport.run(t.txid, JSON.stringify(t.report), Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
// Insert per-cassette detail when available
|
// Insert per-cassette detail when available
|
||||||
if (t.cassettes) {
|
if (t.cassettes) {
|
||||||
for (const c of t.cassettes) {
|
for (const c of t.cassettes) {
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,14 @@ interface UseAvailabilityBroadcastOptions {
|
||||||
signer: Signer
|
signer: Signer
|
||||||
/** Reactive inventory: denomination -> count */
|
/** Reactive inventory: denomination -> count */
|
||||||
inventory: Ref<Record<number, number>>
|
inventory: Ref<Record<number, number>>
|
||||||
/** Reactive Lightning.Pub balance in sats (null = unknown) */
|
/** Reactive wallet balance in sats (null = unknown) */
|
||||||
balanceSats: Ref<number | null>
|
balanceSats: Ref<number | null>
|
||||||
|
/**
|
||||||
|
* ADR-005 §5: cash-out is latched off after a terminal dispenser fault.
|
||||||
|
* A machine with full bays and a jammed transport must not advertise
|
||||||
|
* cash-out — that is exactly what sintra did for an hour on 2026-10-09.
|
||||||
|
*/
|
||||||
|
cashOutHeld?: Ref<boolean>
|
||||||
/** Fiat currency code */
|
/** Fiat currency code */
|
||||||
fiatCode: string
|
fiatCode: string
|
||||||
/** Machine model */
|
/** Machine model */
|
||||||
|
|
@ -38,7 +44,7 @@ interface UseAvailabilityBroadcastOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) {
|
export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) {
|
||||||
const { nostrClient, signer, inventory, balanceSats, fiatCode, model } = options
|
const { nostrClient, signer, inventory, balanceSats, cashOutHeld, fiatCode, model } = options
|
||||||
|
|
||||||
let lastSnapshot: AvailabilitySnapshot | null = null
|
let lastSnapshot: AvailabilitySnapshot | null = null
|
||||||
|
|
||||||
|
|
@ -53,7 +59,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
|
||||||
function computeSnapshot(): AvailabilitySnapshot {
|
function computeSnapshot(): AvailabilitySnapshot {
|
||||||
const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0)
|
const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0)
|
||||||
return {
|
return {
|
||||||
cashOut: totalBills > 0,
|
cashOut: totalBills > 0 && !(cashOutHeld?.value ?? false),
|
||||||
cashIn: (balanceSats.value ?? 0) > 0,
|
cashIn: (balanceSats.value ?? 0) > 0,
|
||||||
cashLevel: computeCashLevel(),
|
cashLevel: computeCashLevel(),
|
||||||
}
|
}
|
||||||
|
|
@ -101,7 +107,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption
|
||||||
|
|
||||||
// Watch reactive sources
|
// Watch reactive sources
|
||||||
watch(
|
watch(
|
||||||
[inventory, balanceSats],
|
cashOutHeld ? [inventory, balanceSats, cashOutHeld] : [inventory, balanceSats],
|
||||||
() => {
|
() => {
|
||||||
debouncedPublish()
|
debouncedPublish()
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -147,8 +147,10 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
||||||
dispensed: 0,
|
dispensed: 0,
|
||||||
rejected: 0,
|
rejected: 0,
|
||||||
})),
|
})),
|
||||||
dispensed: false,
|
dispenseConfirmed: false,
|
||||||
error: `No cassette loaded with denomination: ${denomination}`,
|
error: `No cassette loaded with denomination: ${denomination}`,
|
||||||
|
errorCode: 'NoCassetteForDenomination',
|
||||||
|
errorClass: 'inventory',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notes[idx] = count
|
notes[idx] = count
|
||||||
|
|
@ -182,11 +184,23 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
||||||
rejected: c.rejected,
|
rejected: c.rejected,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const totalRequested = amounts.reduce((s, a) => s + a.count, 0)
|
// ADR-005 §3: confirmation on VALUE. Same contract as electron/hal-service.ts.
|
||||||
|
const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0)
|
||||||
|
const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0)
|
||||||
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0)
|
||||||
|
const dispenseConfirmed = requestedValue === dispensedValue
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message }
|
const e = result.error
|
||||||
|
return {
|
||||||
|
bills,
|
||||||
|
cassettes: cassetteResults,
|
||||||
|
dispenseConfirmed: false,
|
||||||
|
error: e.human ?? e.message,
|
||||||
|
errorCode: e.errorCode ?? e.name,
|
||||||
|
rawCode: e.rawCode,
|
||||||
|
errorClass: e.errorClass ?? 'terminal',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for customer to take bills (only if bills were dispensed)
|
// Wait for customer to take bills (only if bills were dispensed)
|
||||||
|
|
@ -195,7 +209,18 @@ export async function initializeHalServices(config: HalConfig): Promise<HalServi
|
||||||
console.log('[HAL] Bills removed by customer')
|
console.log('[HAL] Bills removed by customer')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed }
|
if (!dispenseConfirmed) {
|
||||||
|
return {
|
||||||
|
bills,
|
||||||
|
cassettes: cassetteResults,
|
||||||
|
dispenseConfirmed: false,
|
||||||
|
error: `Dispensed ${dispensedValue} of ${requestedValue} with no dispenser error`,
|
||||||
|
errorCode: 'DispenseShort',
|
||||||
|
errorClass: 'inventory',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bills, cassettes: cassetteResults, dispenseConfirmed: true }
|
||||||
},
|
},
|
||||||
|
|
||||||
getInventory: async () => {
|
getInventory: async () => {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
|
|
||||||
import { NostrClient, type Signer } from '@bitSpire/nostr-client'
|
import { NostrClient, type Signer } from '@bitSpire/nostr-client'
|
||||||
import { resolveSigner } from './signer-resolver.js'
|
import { resolveSigner } from './signer-resolver.js'
|
||||||
import { LnbitsClient } from '@bitSpire/lnbits'
|
import { LnbitsClient, type DispenseReportBody, type DispenseReportAck } from '@bitSpire/lnbits'
|
||||||
import { CLINKClient } from '@bitSpire/clink'
|
import { CLINKClient } from '@bitSpire/clink'
|
||||||
import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink'
|
import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink'
|
||||||
import type { ATMServices, ATMContext } from '@bitSpire/state-machine'
|
import type { ATMServices, ATMContext } from '@bitSpire/state-machine'
|
||||||
|
|
@ -223,6 +223,8 @@ export interface LightningBackend {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LightningServices {
|
interface LightningServices {
|
||||||
|
/** ADR-005 §2: send one cash-out's dispense outcome to spirekeeper (outbox-driven). */
|
||||||
|
reportDispense: (body: DispenseReportBody) => Promise<DispenseReportAck>
|
||||||
nostrClient: NostrClient
|
nostrClient: NostrClient
|
||||||
lightningPub: LightningBackend
|
lightningPub: LightningBackend
|
||||||
clink: CLINKClient
|
clink: CLINKClient
|
||||||
|
|
@ -686,6 +688,11 @@ export async function initializeLightningServices(options?: {
|
||||||
signer,
|
signer,
|
||||||
operatorPubkeys: CONFIG.operatorPubkeys,
|
operatorPubkeys: CONFIG.operatorPubkeys,
|
||||||
atmServices,
|
atmServices,
|
||||||
|
/**
|
||||||
|
* ADR-005 §2: send one cash-out's dispense outcome to spirekeeper. The
|
||||||
|
* store keeps these in a durable outbox and calls this until it resolves.
|
||||||
|
*/
|
||||||
|
reportDispense: (body: DispenseReportBody) => lnbits.reportDispense(body),
|
||||||
onOfferRequest: (callback: OfferRequestCallback) => {
|
onOfferRequest: (callback: OfferRequestCallback) => {
|
||||||
offerRequestCallback = callback
|
offerRequestCallback = callback
|
||||||
},
|
},
|
||||||
|
|
@ -742,7 +749,7 @@ export function createATMServices(
|
||||||
subId: string | null
|
subId: string | null
|
||||||
/** Preimage seen before a consumer attached; replayed on attach. */
|
/** Preimage seen before a consumer attached; replayed on attach. */
|
||||||
settled: string | null
|
settled: string | null
|
||||||
consumer: ((preimage: string) => void) | null
|
consumer: ((preimage: string, paymentHash: string) => void) | null
|
||||||
poll: ReturnType<typeof setInterval> | null
|
poll: ReturnType<typeof setInterval> | null
|
||||||
released: boolean
|
released: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -765,7 +772,7 @@ export function createATMServices(
|
||||||
watch.settled = preimage
|
watch.settled = preimage
|
||||||
stopInvoiceWatchPoll(watch)
|
stopInvoiceWatchPoll(watch)
|
||||||
console.log(`[ATM Service] Invoice paid (${via})!`)
|
console.log(`[ATM Service] Invoice paid (${via})!`)
|
||||||
watch.consumer?.(preimage)
|
watch.consumer?.(preimage, watch.paymentHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startInvoiceWatchPoll(watch: InvoiceWatch): void {
|
function startInvoiceWatchPoll(watch: InvoiceWatch): void {
|
||||||
|
|
@ -1106,7 +1113,7 @@ export function createATMServices(
|
||||||
dispensed: a.count,
|
dispensed: a.count,
|
||||||
rejected: 0,
|
rejected: 0,
|
||||||
})),
|
})),
|
||||||
dispensed: true,
|
dispenseConfirmed: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1206,7 +1213,10 @@ export function createATMServices(
|
||||||
* Watch a BOLT11 invoice for payment via LNbits subscribe_payments
|
* Watch a BOLT11 invoice for payment via LNbits subscribe_payments
|
||||||
* push, filtered by payment_hash. Returns a cleanup function.
|
* push, filtered by payment_hash. Returns a cleanup function.
|
||||||
*/
|
*/
|
||||||
watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => {
|
watchInvoice: (
|
||||||
|
invoice: string,
|
||||||
|
callback: (preimage: string, paymentHash?: string) => void
|
||||||
|
): (() => void) => {
|
||||||
if (!invoice.toLowerCase().startsWith('ln')) {
|
if (!invoice.toLowerCase().startsWith('ln')) {
|
||||||
console.error('[ATM Service] Invalid invoice format - expected BOLT11')
|
console.error('[ATM Service] Invalid invoice format - expected BOLT11')
|
||||||
return () => {}
|
return () => {}
|
||||||
|
|
@ -1221,7 +1231,7 @@ export function createATMServices(
|
||||||
// on a push that has already come and gone.
|
// on a push that has already come and gone.
|
||||||
if (armed.settled) {
|
if (armed.settled) {
|
||||||
const preimage = armed.settled
|
const preimage = armed.settled
|
||||||
queueMicrotask(() => callback(preimage))
|
queueMicrotask(() => callback(preimage, armed.paymentHash))
|
||||||
}
|
}
|
||||||
return () => releaseInvoiceWatch(invoice)
|
return () => releaseInvoiceWatch(invoice)
|
||||||
}
|
}
|
||||||
|
|
@ -1244,7 +1254,7 @@ export function createATMServices(
|
||||||
const late = invoiceWatches.get(invoice)
|
const late = invoiceWatches.get(invoice)
|
||||||
if (!late || cancelled) return
|
if (!late || cancelled) return
|
||||||
late.consumer = callback
|
late.consumer = callback
|
||||||
if (late.settled) callback(late.settled)
|
if (late.settled) callback(late.settled, late.paymentHash)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[ATM Service] LNbits watchInvoice failed:', e)
|
console.error('[ATM Service] LNbits watchInvoice failed:', e)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ const KIND_NIP78 = 30078
|
||||||
/** The wire schema this machine speaks. Operations, not counts (ADR-004). */
|
/** The wire schema this machine speaks. Operations, not counts (ADR-004). */
|
||||||
const CASSETTE_SCHEMA_VERSION = 2
|
const CASSETTE_SCHEMA_VERSION = 2
|
||||||
|
|
||||||
/** One operator-authored operation, as it arrives on the wire. */
|
/** One operator-authored cassette operation, as it arrives on the wire. */
|
||||||
type CassetteOp = {
|
type CassetteOp = {
|
||||||
id: string
|
id: string
|
||||||
at: number
|
at: number
|
||||||
|
|
@ -55,6 +55,18 @@ type CassetteOp = {
|
||||||
denomination?: number
|
denomination?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR-005 §5: the operator releases a cash-out hold without touching a bay
|
||||||
|
* count. Rides the same operator event as the cassette ops (same id/at shape)
|
||||||
|
* but is NOT a cassette op: it never reaches `applyOperatorCassetteOps`, which
|
||||||
|
* would reject the type. Honoured only when stamped AFTER the hold began, so
|
||||||
|
* a re-delivered resume from before a fresh fault cannot clear that fault.
|
||||||
|
* A `recount` releases the hold too — it is the same "operator at the open
|
||||||
|
* machine" gesture and already clears counts-uncertain.
|
||||||
|
*/
|
||||||
|
type ResumeCashOutOp = { id: string; at: number; type: 'resume_cash_out' }
|
||||||
|
type OperatorOp = CassetteOp | ResumeCashOutOp
|
||||||
|
|
||||||
/** Accept operator events stamped up to this many seconds in the future. */
|
/** Accept operator events stamped up to this many seconds in the future. */
|
||||||
const MAX_FUTURE_SKEW_S = 60
|
const MAX_FUTURE_SKEW_S = 60
|
||||||
|
|
||||||
|
|
@ -85,6 +97,12 @@ export interface OperatorConfigServiceConfig {
|
||||||
operatorPubkeys: string[]
|
operatorPubkeys: string[]
|
||||||
/** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
|
/** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */
|
||||||
machineId?: string
|
machineId?: string
|
||||||
|
/**
|
||||||
|
* ADR-005 §5: called when an operator op (recount, resume_cash_out) has
|
||||||
|
* released a persisted cash-out hold, so the store can lift the state
|
||||||
|
* machine's latch. The store wires this to `CASH_OUT_RELEASED`.
|
||||||
|
*/
|
||||||
|
onCashOutHoldReleased?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OperatorConfigService {
|
export interface OperatorConfigService {
|
||||||
|
|
@ -222,7 +240,28 @@ async function handleOperatorConfigEvent(
|
||||||
console.error('[OperatorConfig] Payload missing `ops` array — dropped')
|
console.error('[OperatorConfig] Payload missing `ops` array — dropped')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const ops = parsed.ops as CassetteOp[]
|
const allOps = parsed.ops as OperatorOp[]
|
||||||
|
|
||||||
|
// 4b. ADR-005 §5 — split out resume_cash_out before the cassette apply.
|
||||||
|
// Release only if a resume is stamped after the hold began; an idempotent
|
||||||
|
// re-delivery of an older resume must not clear a newer fault.
|
||||||
|
const holdBefore = await api.getCashOutHold()
|
||||||
|
const resumeOps = allOps.filter(
|
||||||
|
(o): o is ResumeCashOutOp => !!o && o.type === 'resume_cash_out'
|
||||||
|
)
|
||||||
|
const ops = allOps.filter((o): o is CassetteOp => !!o && o.type !== 'resume_cash_out')
|
||||||
|
if (holdBefore && resumeOps.some((o) => typeof o.at === 'number' && o.at > holdBefore.since)) {
|
||||||
|
await api.clearCashOutHold()
|
||||||
|
console.log(
|
||||||
|
`[OperatorConfig] Cash-out hold released by operator resume op ` +
|
||||||
|
`(held since ${holdBefore.since}, ${resumeOps.length} resume op(s))`
|
||||||
|
)
|
||||||
|
} else if (resumeOps.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[OperatorConfig] ${resumeOps.length} resume_cash_out op(s) ignored — ` +
|
||||||
|
(holdBefore ? 'all stamped before the current hold began' : 'no hold in place')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Apply the ones we have not seen, in one transaction with the sequence
|
// 5. Apply the ones we have not seen, in one transaction with the sequence
|
||||||
// bump. No `created_at` watermark: each op carries an operator-minted id
|
// bump. No `created_at` watermark: each op carries an operator-minted id
|
||||||
|
|
@ -230,10 +269,19 @@ async function handleOperatorConfigEvent(
|
||||||
// no-op on its own merits. The watermark would be strictly weaker and
|
// no-op on its own merits. The watermark would be strictly weaker and
|
||||||
// actively harmful — an event arriving out of order can still carry an
|
// actively harmful — an event arriving out of order can still carry an
|
||||||
// operation this machine has never seen.
|
// operation this machine has never seen.
|
||||||
const result = await api.applyOperatorCassetteOps(ops)
|
const result = ops.length
|
||||||
|
? await api.applyOperatorCassetteOps(ops)
|
||||||
|
: { applied: [] as string[], rejected: [] as { id: string; reason: string }[] }
|
||||||
for (const bad of result.rejected) {
|
for (const bad of result.rejected) {
|
||||||
console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`)
|
console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A recount (applied in the store, which also clears the hold) or the resume
|
||||||
|
// above may have released the latch: tell the store so the state machine
|
||||||
|
// lifts its guard. The republishes below carry the cleared state up.
|
||||||
|
if (holdBefore && (await api.getCashOutHold()) === null) {
|
||||||
|
cfg.onCashOutHoldReleased?.()
|
||||||
|
}
|
||||||
if (result.applied.length === 0) {
|
if (result.applied.length === 0) {
|
||||||
console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`)
|
console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`)
|
||||||
// Still republish: the operator learns from our applied_ops echo that
|
// Still republish: the operator learns from our applied_ops echo that
|
||||||
|
|
@ -318,6 +366,15 @@ async function publishCassettesState(
|
||||||
applied_ops: appliedOps,
|
applied_ops: appliedOps,
|
||||||
}
|
}
|
||||||
if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince
|
if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince
|
||||||
|
// ADR-005 §5 — additive, same contract as counts_uncertain_since: an old
|
||||||
|
// consumer ignores it. When present, this machine is refusing cash-out
|
||||||
|
// until an operator recount or resume_cash_out op.
|
||||||
|
const hold = await api.getCashOutHold()
|
||||||
|
if (hold) {
|
||||||
|
payload.cash_out_held_since = hold.since
|
||||||
|
payload.cash_out_held_reason = hold.reason
|
||||||
|
payload.cash_out_held_code = hold.rawCode ?? hold.errorCode ?? null
|
||||||
|
}
|
||||||
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload))
|
const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload))
|
||||||
|
|
||||||
// Force the stamp strictly above our last one. Addressable events are ordered
|
// Force the stamp strictly above our last one. Addressable events are ordered
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
import type { DispenseReportBody } from '@bitSpire/lnbits'
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
createATMMachine,
|
createATMMachine,
|
||||||
|
|
@ -9,6 +10,7 @@ import {
|
||||||
type SnapshotFrom,
|
type SnapshotFrom,
|
||||||
type ATMMachine,
|
type ATMMachine,
|
||||||
type AccessRole,
|
type AccessRole,
|
||||||
|
type DispenseCashResult,
|
||||||
} from '@bitSpire/state-machine'
|
} from '@bitSpire/state-machine'
|
||||||
import type { AccessControlConfig, CardSession } from '@/types/electron'
|
import type { AccessControlConfig, CardSession } from '@/types/electron'
|
||||||
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning'
|
||||||
|
|
@ -126,7 +128,7 @@ async function handleManagementCommand(
|
||||||
await persistTransaction({
|
await persistTransaction({
|
||||||
txid,
|
txid,
|
||||||
type: 'manual_dispense',
|
type: 'manual_dispense',
|
||||||
status: result.dispensed ? 'complete' : 'dispense_error',
|
status: result.dispenseConfirmed ? 'complete' : 'dispense_error',
|
||||||
fiatCents: totalFiatCents,
|
fiatCents: totalFiatCents,
|
||||||
sats: 0,
|
sats: 0,
|
||||||
feeSats: 0,
|
feeSats: 0,
|
||||||
|
|
@ -141,7 +143,7 @@ async function handleManagementCommand(
|
||||||
|
|
||||||
// Only remediate the original tx if ALL requested bills were dispensed
|
// Only remediate the original tx if ALL requested bills were dispensed
|
||||||
let refRemediated = false
|
let refRemediated = false
|
||||||
if (request.ref_txid && result.dispensed && isElectron && window.electronAPI) {
|
if (request.ref_txid && result.dispenseConfirmed && isElectron && window.electronAPI) {
|
||||||
refRemediated = await window.electronAPI.remediateTransaction(request.ref_txid, txid)
|
refRemediated = await window.electronAPI.remediateTransaction(request.ref_txid, txid)
|
||||||
if (refRemediated) {
|
if (refRemediated) {
|
||||||
console.log('[ATM] Remediated failed tx:', request.ref_txid)
|
console.log('[ATM] Remediated failed tx:', request.ref_txid)
|
||||||
|
|
@ -192,6 +194,62 @@ async function loadInventoryFromDb(): Promise<Record<number, number> | null> {
|
||||||
/**
|
/**
|
||||||
* Persist a completed transaction to SQLite via IPC.
|
* Persist a completed transaction to SQLite via IPC.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* ADR-005 §2: the dispense outcome spirekeeper captures on. Built from the
|
||||||
|
* machine context at the moment the terminal state is entered; written in the
|
||||||
|
* same SQLite transaction as the transactions row (see TransactionRecord.report).
|
||||||
|
* `requested` per denomination comes from the sale, `dispensed`/`rejected` from
|
||||||
|
* the hardware report; `cassettes` is the per-bay record verbatim.
|
||||||
|
*/
|
||||||
|
function buildDispenseReport(
|
||||||
|
ctx: ATMContext,
|
||||||
|
dr: DispenseCashResult | null,
|
||||||
|
countsUncertain: boolean
|
||||||
|
): DispenseReportBody {
|
||||||
|
const requestedByDenom = new Map<number, number>()
|
||||||
|
for (const a of ctx.dispenseAmounts) {
|
||||||
|
requestedByDenom.set(a.denomination, (requestedByDenom.get(a.denomination) ?? 0) + a.count)
|
||||||
|
}
|
||||||
|
const seen = new Set<number>()
|
||||||
|
const bills: DispenseReportBody['bills'] = []
|
||||||
|
for (const b of dr?.bills ?? []) {
|
||||||
|
seen.add(b.denomination)
|
||||||
|
bills.push({
|
||||||
|
denomination: b.denomination,
|
||||||
|
requested: requestedByDenom.get(b.denomination) ?? 0,
|
||||||
|
dispensed: b.dispensed,
|
||||||
|
rejected: b.rejected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// A denomination that was asked for but never appears in the report (the
|
||||||
|
// dispenser threw before reporting) still needs a row: requested, zero out.
|
||||||
|
for (const [denomination, requested] of requestedByDenom) {
|
||||||
|
if (!seen.has(denomination)) bills.push({ denomination, requested, dispensed: 0, rejected: 0 })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
txid: ctx.txid ?? '',
|
||||||
|
payment_hash: ctx.paymentHash,
|
||||||
|
tx_type: 'cash_out',
|
||||||
|
dispense_confirmed: dr?.dispenseConfirmed === true,
|
||||||
|
error: dr?.error ?? ctx.error ?? null,
|
||||||
|
error_code: dr?.errorCode ?? (ctx.error && !dr ? 'DispenseThrew' : null),
|
||||||
|
raw_code: dr?.rawCode ?? null,
|
||||||
|
error_class: dr?.errorClass ?? (ctx.error && !dr ? 'terminal' : null),
|
||||||
|
fiat_cents: ctx.fiatCents,
|
||||||
|
currency: ctx.currency,
|
||||||
|
bills,
|
||||||
|
cassettes: (dr?.cassettes ?? []).map((c) => ({
|
||||||
|
position: c.position,
|
||||||
|
denomination: c.denomination,
|
||||||
|
provisioned: c.provisioned,
|
||||||
|
dispensed: c.dispensed,
|
||||||
|
rejected: c.rejected,
|
||||||
|
})),
|
||||||
|
counts_uncertain: countsUncertain,
|
||||||
|
at: Math.floor(Date.now() / 1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function persistTransaction(tx: TransactionRecord): Promise<void> {
|
async function persistTransaction(tx: TransactionRecord): Promise<void> {
|
||||||
if (isElectron && window.electronAPI) {
|
if (isElectron && window.electronAPI) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -254,7 +312,7 @@ const mockServices: ATMServices = {
|
||||||
dispensed: a.count,
|
dispensed: a.count,
|
||||||
rejected: 0,
|
rejected: 0,
|
||||||
})),
|
})),
|
||||||
dispensed: true,
|
dispenseConfirmed: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -401,6 +459,12 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
)
|
)
|
||||||
let stopBalanceWatch: (() => void) | null = null
|
let stopBalanceWatch: (() => void) | null = null
|
||||||
let pricePollingInterval: ReturnType<typeof setInterval> | null = null
|
let pricePollingInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
// ADR-005 §2 — dispense-report outbox. The function pointer is set at every
|
||||||
|
// lightning-init site; the flusher drains state.db's dispense_reports table
|
||||||
|
// to spirekeeper with backoff until each row is acked.
|
||||||
|
let reportDispenseFn: ((body: DispenseReportBody) => Promise<unknown>) | null = null
|
||||||
|
let dispenseReportFlushInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
let dispenseReportFlushing = false
|
||||||
|
|
||||||
// Store reference to ATM services for direct calls
|
// Store reference to ATM services for direct calls
|
||||||
let atmServicesRef: ATMServices | null = null
|
let atmServicesRef: ATMServices | null = null
|
||||||
|
|
@ -618,13 +682,31 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
send({ type: 'CASH_DISPENSED' })
|
send({ type: 'CASH_DISPENSED' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record failed cash-out dispenses (sats debited but cash not dispensed)
|
// ADR-005 §5: persist the cash-out hold the moment the machine sets it,
|
||||||
if (currentNested === 'dispenseError' && prevNestedState !== 'dispenseError') {
|
// and republish the cassette state so the operator sees it. The hold is
|
||||||
|
// machine health, not transaction state — it must survive a restart.
|
||||||
|
const heldNow = newSnapshot.context.cashOutHeld
|
||||||
|
const heldBefore = prevSnapshot?.context.cashOutHeld ?? null
|
||||||
|
if (heldNow && !heldBefore && isElectron && window.electronAPI) {
|
||||||
|
void window.electronAPI
|
||||||
|
.setCashOutHold(heldNow)
|
||||||
|
.then(() => operatorConfigSvc?.publishCassettesState())
|
||||||
|
.catch((e) => console.error('[ATM] Could not persist cash-out hold:', e))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record a cash-out that did not confirm (ADR-005 §3/§4). Both terminal
|
||||||
|
// states mean the customer has PAID and received less than they paid
|
||||||
|
// for — dispenseFault because the dispenser reported an error, outOfCash
|
||||||
|
// because it reported none (an inventory refusal, or simply short).
|
||||||
|
// Either way the row is dispense_error / partial and the server learns
|
||||||
|
// of it; the difference is only the customer screen and the latch.
|
||||||
|
const isDispenseTerminal = currentNested === 'dispenseFault' || currentNested === 'outOfCash'
|
||||||
|
const wasDispenseTerminal = prevNestedState === 'dispenseFault' || prevNestedState === 'outOfCash'
|
||||||
|
if (isDispenseTerminal && !wasDispenseTerminal) {
|
||||||
const ctx = newSnapshot.context
|
const ctx = newSnapshot.context
|
||||||
if (ctx.txid) {
|
if (ctx.txid) {
|
||||||
const dr = ctx.dispenseResult
|
const dr = ctx.dispenseResult
|
||||||
|
|
||||||
// Determine status from dispense result (if available)
|
|
||||||
let status: 'dispense_error' | 'partial' = 'dispense_error'
|
let status: 'dispense_error' | 'partial' = 'dispense_error'
|
||||||
let bills: { denomination: number; count: number }[] = []
|
let bills: { denomination: number; count: number }[] = []
|
||||||
|
|
||||||
|
|
@ -634,6 +716,23 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
bills = dr.bills
|
bills = dr.bills
|
||||||
.filter((b) => b.dispensed > 0)
|
.filter((b) => b.dispensed > 0)
|
||||||
.map((b) => ({ denomination: b.denomination, count: b.dispensed }))
|
.map((b) => ({ denomination: b.denomination, count: b.dispensed }))
|
||||||
|
|
||||||
|
// ADR-005 §3 — the deviation from both bitSpire-before and lamassu:
|
||||||
|
// a report of ZERO dispensed that arrives WITH a hardware error is
|
||||||
|
// not evidence that nothing left the bay. A note that stops in the
|
||||||
|
// transport path completes neither the dispensed nor the rejected
|
||||||
|
// counter (sintra, 2026-10-09: bay read 66, held 65, one in the
|
||||||
|
// transport). Flag the counts unverified so the next recount is
|
||||||
|
// what resolves them, instead of trusting a zero.
|
||||||
|
if (totalDispensed === 0 && dr.error && dr.errorClass !== 'inventory') {
|
||||||
|
console.error(
|
||||||
|
`[ATM] Dispense reported 0 notes WITH an error (${dr.errorCode ?? 'unknown'}` +
|
||||||
|
`${dr.rawCode ? ` ${dr.rawCode}` : ''}) — bay counts are unverified (txid=${ctx.txid})`
|
||||||
|
)
|
||||||
|
void window.electronAPI
|
||||||
|
?.markCountsUncertain(Math.floor(Date.now() / 1000))
|
||||||
|
.catch((e) => console.warn('[ATM] Could not flag counts unverified:', e))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// The dispenser threw, or the dispense timed out, so there is no
|
// The dispenser threw, or the dispense timed out, so there is no
|
||||||
// per-bay report. Bills may well have reached the customer, and
|
// per-bay report. Bills may well have reached the customer, and
|
||||||
|
|
@ -649,6 +748,11 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
.catch((e) => console.warn('[ATM] Could not flag counts unverified:', e))
|
.catch((e) => console.warn('[ATM] Could not flag counts unverified:', e))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const countsUncertain =
|
||||||
|
!dr ||
|
||||||
|
(dr.bills.reduce((sum, b) => sum + b.dispensed, 0) === 0 &&
|
||||||
|
!!dr.error &&
|
||||||
|
dr.errorClass !== 'inventory')
|
||||||
persistTransaction({
|
persistTransaction({
|
||||||
txid: ctx.txid,
|
txid: ctx.txid,
|
||||||
type: 'cash_out',
|
type: 'cash_out',
|
||||||
|
|
@ -662,10 +766,14 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
bills,
|
bills,
|
||||||
cassettes: dr?.cassettes,
|
cassettes: dr?.cassettes,
|
||||||
error: dr?.error ?? ctx.error,
|
error: dr?.error ?? ctx.error,
|
||||||
|
// ADR-005 §2 — queued in the same SQLite transaction as the row.
|
||||||
|
report: buildDispenseReport(ctx, dr, countsUncertain),
|
||||||
})
|
})
|
||||||
.then(() => reloadPersistedInventory())
|
.then(() => reloadPersistedInventory())
|
||||||
// Republish cassette state — a partial dispense changed counts.
|
// Republish cassette state — a partial dispense changed counts, and
|
||||||
|
// the payload now carries the hold / unverified flags.
|
||||||
.then(() => operatorConfigSvc?.publishCassettesState())
|
.then(() => operatorConfigSvc?.publishCassettesState())
|
||||||
|
.then(() => flushDispenseReports())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -695,11 +803,15 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
bills,
|
bills,
|
||||||
cassettes: dr?.cassettes,
|
cassettes: dr?.cassettes,
|
||||||
error: dr?.error,
|
error: dr?.error,
|
||||||
|
// ADR-005 §2 — the SUCCESS report is what lets spirekeeper capture
|
||||||
|
// (distribute) the settlement. Cash-out only; cash-in has no dispense.
|
||||||
|
...(isCashInTx ? {} : { report: buildDispenseReport(ctx, dr, false) }),
|
||||||
})
|
})
|
||||||
.then(() => reloadPersistedInventory())
|
.then(() => reloadPersistedInventory())
|
||||||
// Republish cassette state after a cash-out dispense (counts
|
// Republish cassette state after a cash-out dispense (counts
|
||||||
// decremented); harmless no-op echo for a cash-in complete.
|
// decremented); harmless no-op echo for a cash-in complete.
|
||||||
.then(() => (isCashInTx ? undefined : operatorConfigSvc?.publishCassettesState()))
|
.then(() => (isCashInTx ? undefined : operatorConfigSvc?.publishCassettesState()))
|
||||||
|
.then(() => (isCashInTx ? undefined : flushDispenseReports()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -742,6 +854,23 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
setupNfcListener()
|
setupNfcListener()
|
||||||
setupCassettesChangedListener()
|
setupCassettesChangedListener()
|
||||||
console.log('[ATM] State machine initialized')
|
console.log('[ATM] State machine initialized')
|
||||||
|
|
||||||
|
// ADR-005 §5: a cash-out hold persisted by a previous run gates cash-out
|
||||||
|
// before any dispense — a restart must not quietly put a jammed machine
|
||||||
|
// back in service. Released only by an operator recount / resume op.
|
||||||
|
if (isElectron && window.electronAPI) {
|
||||||
|
void window.electronAPI
|
||||||
|
.getCashOutHold()
|
||||||
|
.then((hold) => {
|
||||||
|
if (!hold) return
|
||||||
|
console.warn(
|
||||||
|
`[ATM] Cash-out HELD since ${new Date(hold.since * 1000).toISOString()} ` +
|
||||||
|
`(${hold.errorCode ?? 'fault'}${hold.rawCode ? ` ${hold.rawCode}` : ''}): ${hold.reason}`
|
||||||
|
)
|
||||||
|
send({ type: 'CASH_OUT_HELD', hold })
|
||||||
|
})
|
||||||
|
.catch((e) => console.warn('[ATM] Could not read cash-out hold:', e))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bolt Card cash-out (NFC tap-to-pay) ───────────────────────────────────
|
// ── Bolt Card cash-out (NFC tap-to-pay) ───────────────────────────────────
|
||||||
|
|
@ -1031,6 +1160,8 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const services = await initializeLightningServices({ strict: !allowMockFallback.value })
|
const services = await initializeLightningServices({ strict: !allowMockFallback.value })
|
||||||
|
reportDispenseFn = services.reportDispense
|
||||||
|
startDispenseReportFlusher()
|
||||||
useLiveServices.value = true
|
useLiveServices.value = true
|
||||||
connectionStatus.value = 'connected'
|
connectionStatus.value = 'connected'
|
||||||
console.log('[ATM] Connected to Lightning.Pub!')
|
console.log('[ATM] Connected to Lightning.Pub!')
|
||||||
|
|
@ -1043,6 +1174,8 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
services.nostrClient.on('connect', () => {
|
services.nostrClient.on('connect', () => {
|
||||||
connectionStatus.value = 'connected'
|
connectionStatus.value = 'connected'
|
||||||
console.log('[ATM] Relay reconnected')
|
console.log('[ATM] Relay reconnected')
|
||||||
|
// A report queued during the outage goes now, not at the next tick.
|
||||||
|
void flushDispenseReports()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Store references to clients for direct operations
|
// Store references to clients for direct operations
|
||||||
|
|
@ -1135,6 +1268,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
nostrClient: services.nostrClient,
|
nostrClient: services.nostrClient,
|
||||||
signer: services.signer,
|
signer: services.signer,
|
||||||
operatorPubkeys: services.operatorPubkeys,
|
operatorPubkeys: services.operatorPubkeys,
|
||||||
|
onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Start operator-fees consumer (aiolabs/lamassu-next#57) — subscribes
|
// Start operator-fees consumer (aiolabs/lamassu-next#57) — subscribes
|
||||||
|
|
@ -1329,6 +1463,8 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
|
|
||||||
// Initialize Lightning services
|
// Initialize Lightning services
|
||||||
const lightning = await initializeLightningServices({ strict: !allowMockFallback.value })
|
const lightning = await initializeLightningServices({ strict: !allowMockFallback.value })
|
||||||
|
reportDispenseFn = lightning.reportDispense
|
||||||
|
startDispenseReportFlusher()
|
||||||
useLiveServices.value = true
|
useLiveServices.value = true
|
||||||
connectionStatus.value = 'connected'
|
connectionStatus.value = 'connected'
|
||||||
lightningPub.value = lightning.lightningPub
|
lightningPub.value = lightning.lightningPub
|
||||||
|
|
@ -1461,6 +1597,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
nostrClient: lightning.nostrClient,
|
nostrClient: lightning.nostrClient,
|
||||||
signer: lightning.signer,
|
signer: lightning.signer,
|
||||||
operatorPubkeys: lightning.operatorPubkeys,
|
operatorPubkeys: lightning.operatorPubkeys,
|
||||||
|
onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Operator-fees consumer (aiolabs/lamassu-next#57)
|
// Operator-fees consumer (aiolabs/lamassu-next#57)
|
||||||
|
|
@ -1621,6 +1758,8 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
|
|
||||||
// Initialize Lightning services
|
// Initialize Lightning services
|
||||||
const lightning = await initializeLightningServices({ strict: !allowMockFallback.value })
|
const lightning = await initializeLightningServices({ strict: !allowMockFallback.value })
|
||||||
|
reportDispenseFn = lightning.reportDispense
|
||||||
|
startDispenseReportFlusher()
|
||||||
useLiveServices.value = true
|
useLiveServices.value = true
|
||||||
connectionStatus.value = 'connected'
|
connectionStatus.value = 'connected'
|
||||||
lightningPub.value = lightning.lightningPub
|
lightningPub.value = lightning.lightningPub
|
||||||
|
|
@ -1797,6 +1936,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
nostrClient: lightning.nostrClient,
|
nostrClient: lightning.nostrClient,
|
||||||
signer: lightning.signer,
|
signer: lightning.signer,
|
||||||
operatorPubkeys: lightning.operatorPubkeys,
|
operatorPubkeys: lightning.operatorPubkeys,
|
||||||
|
onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Operator-fees consumer (aiolabs/lamassu-next#57)
|
// Operator-fees consumer (aiolabs/lamassu-next#57)
|
||||||
|
|
@ -1899,6 +2039,11 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
send({ type: 'SELECT_CASH_IN' })
|
send({ type: 'SELECT_CASH_IN' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Customer dismisses the dispense-fault screen ("I've saved this reference"). */
|
||||||
|
function acknowledgeFault() {
|
||||||
|
send({ type: 'ACKNOWLEDGE_FAULT' })
|
||||||
|
}
|
||||||
|
|
||||||
function selectCashOut() {
|
function selectCashOut() {
|
||||||
settlementError.value = null
|
settlementError.value = null
|
||||||
send({ type: 'SELECT_CASH_OUT' })
|
send({ type: 'SELECT_CASH_OUT' })
|
||||||
|
|
@ -1983,7 +2128,59 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
pricePollingInterval = setInterval(poll, 30_000)
|
pricePollingInterval = setInterval(poll, 30_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain the dispense-report outbox (ADR-005 §2). At-least-once: a row is
|
||||||
|
* acked only on an OK reply; anything else bumps `attempts` and the row is
|
||||||
|
* retried after an exponential backoff (30 s · 2^attempts, capped at 1 h).
|
||||||
|
* While spirekeeper has not registered `report_dispense` every send fails
|
||||||
|
* the same way — the backoff keeps that from being noisy, and the rows wait.
|
||||||
|
* Triggers: right after each persist, on relay (re)connect, every 60 s.
|
||||||
|
*/
|
||||||
|
async function flushDispenseReports(): Promise<void> {
|
||||||
|
if (!isElectron || !window.electronAPI || !reportDispenseFn) return
|
||||||
|
if (dispenseReportFlushing) return
|
||||||
|
dispenseReportFlushing = true
|
||||||
|
try {
|
||||||
|
const pending = await window.electronAPI.pendingDispenseReports(20)
|
||||||
|
const now = Date.now()
|
||||||
|
for (const row of pending) {
|
||||||
|
const backoffMs = Math.min(30_000 * 2 ** row.attempts, 3_600_000)
|
||||||
|
if (row.lastAttemptAt && row.lastAttemptAt + backoffMs > now) continue
|
||||||
|
try {
|
||||||
|
await reportDispenseFn(row.payload as DispenseReportBody)
|
||||||
|
await window.electronAPI.ackDispenseReport(row.txid)
|
||||||
|
console.log(`[ATM] Dispense report delivered: ${row.txid}`)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
await window.electronAPI.noteDispenseReportAttempt(row.txid, msg)
|
||||||
|
console.warn(
|
||||||
|
`[ATM] Dispense report ${row.txid} not delivered (attempt ${row.attempts + 1}): ${msg}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ATM] Dispense-report flush failed:', e)
|
||||||
|
} finally {
|
||||||
|
dispenseReportFlushing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDispenseReportFlusher() {
|
||||||
|
if (dispenseReportFlushInterval) return
|
||||||
|
dispenseReportFlushInterval = setInterval(() => void flushDispenseReports(), 60_000)
|
||||||
|
void flushDispenseReports()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDispenseReportFlusher() {
|
||||||
|
if (dispenseReportFlushInterval) {
|
||||||
|
clearInterval(dispenseReportFlushInterval)
|
||||||
|
dispenseReportFlushInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function stopPricePolling() {
|
function stopPricePolling() {
|
||||||
|
// Both are store-lifetime intervals; whoever stops one stops the other.
|
||||||
|
stopDispenseReportFlusher()
|
||||||
if (pricePollingInterval) {
|
if (pricePollingInterval) {
|
||||||
clearInterval(pricePollingInterval)
|
clearInterval(pricePollingInterval)
|
||||||
pricePollingInterval = null
|
pricePollingInterval = null
|
||||||
|
|
@ -2004,6 +2201,8 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
signer,
|
signer,
|
||||||
inventory: persistedInventory,
|
inventory: persistedInventory,
|
||||||
balanceSats,
|
balanceSats,
|
||||||
|
// ADR-005 §5: a latched machine must not advertise cash-out.
|
||||||
|
cashOutHeld: computed(() => snapshot.value?.context.cashOutHeld != null),
|
||||||
fiatCode: fiatCode.value,
|
fiatCode: fiatCode.value,
|
||||||
model,
|
model,
|
||||||
})
|
})
|
||||||
|
|
@ -2069,6 +2268,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
send,
|
send,
|
||||||
selectCashIn,
|
selectCashIn,
|
||||||
selectCashOut,
|
selectCashOut,
|
||||||
|
acknowledgeFault,
|
||||||
cancel,
|
cancel,
|
||||||
insertBill,
|
insertBill,
|
||||||
finishInserting,
|
finishInserting,
|
||||||
|
|
|
||||||
27
apps/machine/src/types/electron.d.ts
vendored
27
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -150,6 +150,33 @@ declare global {
|
||||||
/** When the bay counts became unverified (a dispense that reported nothing), or null. */
|
/** When the bay counts became unverified (a dispense that reported nothing), or null. */
|
||||||
getCountsUncertainSince: () => Promise<number | null>
|
getCountsUncertainSince: () => Promise<number | null>
|
||||||
markCountsUncertain: (unixTimestamp: number) => Promise<void>
|
markCountsUncertain: (unixTimestamp: number) => Promise<void>
|
||||||
|
// Cash-out hold (ADR-005 §5)
|
||||||
|
getCashOutHold: () => Promise<{
|
||||||
|
reason: string
|
||||||
|
errorCode: string | null
|
||||||
|
rawCode: string | null
|
||||||
|
since: number
|
||||||
|
} | null>
|
||||||
|
setCashOutHold: (hold: {
|
||||||
|
reason: string
|
||||||
|
errorCode: string | null
|
||||||
|
rawCode: string | null
|
||||||
|
since: number
|
||||||
|
}) => Promise<{ reason: string; errorCode: string | null; rawCode: string | null; since: number }>
|
||||||
|
clearCashOutHold: () => Promise<boolean>
|
||||||
|
// Dispense-report outbox (ADR-005 §2)
|
||||||
|
pendingDispenseReports: (limit?: number) => Promise<
|
||||||
|
Array<{
|
||||||
|
txid: string
|
||||||
|
payload: unknown
|
||||||
|
createdAt: number
|
||||||
|
attempts: number
|
||||||
|
lastAttemptAt: number | null
|
||||||
|
lastError: string | null
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
ackDispenseReport: (txid: string) => Promise<boolean>
|
||||||
|
noteDispenseReportAttempt: (txid: string, error: string | null) => Promise<void>
|
||||||
markStatePublished: (unixTimestamp: number) => Promise<void>
|
markStatePublished: (unixTimestamp: number) => Promise<void>
|
||||||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||||
clearBunkerBinding: () => Promise<void>
|
clearBunkerBinding: () => Promise<void>
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@ export interface TransactionRecord {
|
||||||
}[]
|
}[]
|
||||||
error?: string | null
|
error?: string | null
|
||||||
remediatedBy?: string | null
|
remediatedBy?: string | null
|
||||||
|
/**
|
||||||
|
* ADR-005 §2: the dispense outcome to queue for spirekeeper, written in
|
||||||
|
* the same SQLite transaction as the row so a crash between the two cannot
|
||||||
|
* lose it. Cash-out only. Shape is @bitSpire/lnbits DispenseReportBody.
|
||||||
|
*/
|
||||||
|
report?: import('@bitSpire/lnbits').DispenseReportBody
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ATMAvailability {
|
export interface ATMAvailability {
|
||||||
|
|
|
||||||
|
|
@ -63,13 +63,19 @@ watch(
|
||||||
const nestedState = computed(() => atmStore.nestedState)
|
const nestedState = computed(() => atmStore.nestedState)
|
||||||
const context = computed(() => atmStore.context)
|
const context = computed(() => atmStore.context)
|
||||||
|
|
||||||
// Dispense error 30s countdown
|
// Terminal-screen countdowns (ADR-005 §4). The machine owns the real timers
|
||||||
|
// (DISPENSE_FAULT_TIMEOUT 120 s, DISPENSE_ERROR_TIMEOUT 30 s); this mirrors
|
||||||
|
// them for display only.
|
||||||
|
const TERMINAL_SECONDS: Record<string, number> = { dispenseFault: 120, outOfCash: 30 }
|
||||||
const dispenseErrorCountdown = ref(30)
|
const dispenseErrorCountdown = ref(30)
|
||||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
watch(nestedState, (newState, oldState) => {
|
watch(nestedState, (newState, oldState) => {
|
||||||
if (newState === 'dispenseError' && oldState !== 'dispenseError') {
|
const entering = typeof newState === 'string' && newState in TERMINAL_SECONDS
|
||||||
dispenseErrorCountdown.value = 30
|
const leaving = typeof oldState === 'string' && oldState in TERMINAL_SECONDS
|
||||||
|
if (entering && newState !== oldState) {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
dispenseErrorCountdown.value = TERMINAL_SECONDS[newState as string] ?? 30
|
||||||
countdownTimer = setInterval(() => {
|
countdownTimer = setInterval(() => {
|
||||||
dispenseErrorCountdown.value--
|
dispenseErrorCountdown.value--
|
||||||
if (dispenseErrorCountdown.value <= 0 && countdownTimer) {
|
if (dispenseErrorCountdown.value <= 0 && countdownTimer) {
|
||||||
|
|
@ -77,12 +83,21 @@ watch(nestedState, (newState, oldState) => {
|
||||||
countdownTimer = null
|
countdownTimer = null
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
} else if (oldState === 'dispenseError' && countdownTimer) {
|
} else if (leaving && !entering && countdownTimer) {
|
||||||
clearInterval(countdownTimer)
|
clearInterval(countdownTimer)
|
||||||
countdownTimer = null
|
countdownTimer = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function acknowledgeFault() {
|
||||||
|
atmStore.acknowledgeFault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const faultTime = computed(() => {
|
||||||
|
const t = context.value?.startedAt
|
||||||
|
return t ? new Date(t).toLocaleString() : ''
|
||||||
|
})
|
||||||
|
|
||||||
// Available denominations from inventory
|
// Available denominations from inventory
|
||||||
const availableDenominations = computed(() => {
|
const availableDenominations = computed(() => {
|
||||||
if (!context.value?.inventory) return []
|
if (!context.value?.inventory) return []
|
||||||
|
|
@ -535,63 +550,92 @@ function formatFiat(cents: number): string {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dispense Error (timed, 30s → idle) -->
|
<!-- Dispense fault / could-not-dispense (ADR-005 §4).
|
||||||
|
Both reach here AFTER payment: the customer has paid and received
|
||||||
|
less than they paid for. dispenseFault = the dispenser reported an
|
||||||
|
error (and may have latched cash-out off); outOfCash = it reported
|
||||||
|
none. Either way: evidence on screen, operator notified. The raw
|
||||||
|
dispenser code is deliberately NOT shown — it travels in the report. -->
|
||||||
<div
|
<div
|
||||||
v-else-if="nestedState === 'dispenseError'"
|
v-else-if="nestedState === 'dispenseFault' || nestedState === 'outOfCash'"
|
||||||
key="dispenseError"
|
key="dispenseTerminal"
|
||||||
class="flex flex-1 flex-col lg:flex-row items-center justify-center gap-6 lg:gap-10 p-4 lg:p-8"
|
class="flex flex-1 flex-col lg:flex-row items-center justify-center gap-6 lg:gap-10 p-4 lg:p-8"
|
||||||
style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
|
style="background: color-mix(in srgb, var(--destructive) 8%, var(--background))"
|
||||||
>
|
>
|
||||||
<!-- Left side — error details -->
|
<div class="flex flex-col items-center gap-3 lg:gap-5 max-w-xl">
|
||||||
<div class="flex flex-col items-center gap-3 lg:gap-5">
|
|
||||||
<div class="text-5xl lg:text-[8vh]">⚠️</div>
|
<div class="text-5xl lg:text-[8vh]">⚠️</div>
|
||||||
<h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">Dispense Error</h3>
|
<h3 class="text-2xl lg:text-[3rem] font-bold text-destructive">
|
||||||
<p class="text-base lg:text-2xl text-muted-foreground">
|
{{ nestedState === 'dispenseFault' ? 'Dispenser fault' : 'Could not dispense' }}
|
||||||
{{ context?.error || 'Cash could not be dispensed' }}
|
</h3>
|
||||||
|
<p class="text-base lg:text-2xl text-foreground text-center font-semibold">
|
||||||
|
Your payment went through. The cash below could not be dispensed.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Partial dispense info -->
|
<div class="w-full rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2">
|
||||||
<div
|
<div class="flex justify-between text-base lg:text-2xl">
|
||||||
v-if="context?.dispenseResult?.bills?.length"
|
<span class="text-muted-foreground">You paid</span>
|
||||||
class="w-full max-w-md rounded-xl bg-background/60 px-4 py-4 lg:px-8 lg:py-6 space-y-2"
|
<span class="font-semibold"
|
||||||
>
|
>{{ atmStore.fiatSymbol }}{{ ((context?.fiatCents ?? 0) / 100).toFixed(2) }}
|
||||||
|
<span class="text-muted-foreground text-sm lg:text-lg"
|
||||||
|
>({{ (context?.satsAmount ?? 0).toLocaleString() }} sats)</span
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="bill in context.dispenseResult.bills"
|
v-for="bill in context?.dispenseResult?.bills ?? []"
|
||||||
:key="bill.denomination"
|
:key="bill.denomination"
|
||||||
class="flex justify-between text-base lg:text-2xl"
|
class="flex justify-between text-base lg:text-2xl"
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground"
|
<span class="text-muted-foreground"
|
||||||
>{{ atmStore.fiatSymbol }}{{ bill.denomination }}</span
|
>{{ atmStore.fiatSymbol }}{{ bill.denomination }} notes</span
|
||||||
>
|
>
|
||||||
<span :class="bill.dispensed > 0 ? 'text-success' : 'text-destructive'">
|
<span :class="bill.dispensed > 0 ? 'text-success' : 'text-destructive'">
|
||||||
{{ bill.dispensed }} dispensed
|
{{ bill.dispensed }} dispensed
|
||||||
<span v-if="bill.rejected > 0" class="text-destructive">
|
|
||||||
({{ bill.rejected }} rejected)
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm lg:text-lg text-muted-foreground">
|
<p class="text-base lg:text-xl text-foreground text-center">
|
||||||
Please contact support with the transaction ID below.
|
The operator has been notified and holds a record of this transaction.
|
||||||
|
<strong>Keep this reference</strong> — photograph it or write it down.
|
||||||
|
</p>
|
||||||
|
<p v-if="context?.error" class="text-xs lg:text-sm text-muted-foreground text-center">
|
||||||
|
Technical detail: {{ context.error }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Countdown -->
|
<div class="flex flex-wrap items-center justify-center gap-3">
|
||||||
|
<Button
|
||||||
|
v-if="nestedState === 'dispenseFault'"
|
||||||
|
class="bg-gradient-to-r from-orange-500 to-yellow-400 text-black"
|
||||||
|
size="kiosk"
|
||||||
|
@click="acknowledgeFault"
|
||||||
|
>
|
||||||
|
I've saved this
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="kiosk" @click="cancel"> Return to Start </Button>
|
||||||
|
</div>
|
||||||
<p class="text-sm lg:text-base text-muted-foreground">
|
<p class="text-sm lg:text-base text-muted-foreground">
|
||||||
Returning to start in {{ dispenseErrorCountdown }}s
|
Returning to start in {{ dispenseErrorCountdown }}s
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button variant="outline" size="kiosk" @click="cancel"> Return to Start </Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right side — txid QR -->
|
<div v-if="context?.txid" class="flex flex-col items-center gap-3">
|
||||||
<div v-if="context?.txid" class="flex flex-col items-center gap-4">
|
<QRCode :value="context.txid" :size="260" />
|
||||||
<QRCode :value="context.txid" :size="280" />
|
<p class="text-xs lg:text-sm text-muted-foreground">Transaction</p>
|
||||||
<p
|
<p
|
||||||
class="font-mono-code text-sm text-muted-foreground max-w-[300px] text-center break-all"
|
class="font-mono-code text-sm lg:text-base text-foreground max-w-[320px] text-center break-all"
|
||||||
>
|
>
|
||||||
{{ context.txid }}
|
{{ context.txid }}
|
||||||
</p>
|
</p>
|
||||||
|
<template v-if="context?.paymentHash">
|
||||||
|
<p class="text-xs lg:text-sm text-muted-foreground">Payment hash</p>
|
||||||
|
<p
|
||||||
|
class="font-mono-code text-xs lg:text-sm text-foreground max-w-[320px] text-center break-all"
|
||||||
|
>
|
||||||
|
{{ context.paymentHash }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p v-if="faultTime" class="text-xs lg:text-sm text-muted-foreground">{{ faultTime }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,16 +121,32 @@ function handleCashOut() {
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Sell Bitcoin -->
|
<!-- Sell Bitcoin — disabled while cash-out is held after a terminal
|
||||||
|
dispenser fault (ADR-005 §5). The state machine refuses
|
||||||
|
SELECT_CASH_OUT regardless; this just tells the customer why. -->
|
||||||
<button
|
<button
|
||||||
class="flex aspect-square w-40 lg:w-[36vh] flex-col items-center justify-center gap-1.5 lg:gap-4 rounded-full border-2 border-success/50 bg-success/10 transition-all active:scale-[0.97]"
|
class="flex aspect-square w-40 lg:w-[36vh] flex-col items-center justify-center gap-1.5 lg:gap-4 rounded-full border-2 transition-all"
|
||||||
|
:class="
|
||||||
|
atmStore.context?.cashOutHeld
|
||||||
|
? 'border-muted-foreground/30 bg-muted/20 opacity-60 cursor-not-allowed'
|
||||||
|
: 'border-success/50 bg-success/10 active:scale-[0.97]'
|
||||||
|
"
|
||||||
|
:disabled="!!atmStore.context?.cashOutHeld"
|
||||||
@click="handleCashOut"
|
@click="handleCashOut"
|
||||||
>
|
>
|
||||||
<span class="text-4xl lg:text-[8vh] leading-none">💵</span>
|
<span class="text-4xl lg:text-[8vh] leading-none">{{
|
||||||
<span class="text-base lg:text-[3.5vh] font-bold text-success">Sell Bitcoin</span>
|
atmStore.context?.cashOutHeld ? '🔧' : '💵'
|
||||||
<span class="text-[10px] lg:text-[1.8vh] text-foreground/70"
|
}}</span>
|
||||||
>Pay invoice, receive cash</span
|
<span
|
||||||
|
class="text-base lg:text-[3.5vh] font-bold"
|
||||||
|
:class="atmStore.context?.cashOutHeld ? 'text-muted-foreground' : 'text-success'"
|
||||||
|
>Sell Bitcoin</span
|
||||||
>
|
>
|
||||||
|
<span class="text-[10px] lg:text-[1.8vh] text-foreground/70 text-center px-3">{{
|
||||||
|
atmStore.context?.cashOutHeld
|
||||||
|
? 'Temporarily unavailable — operator notified'
|
||||||
|
: 'Pay invoice, receive cash'
|
||||||
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -195,10 +195,16 @@ instead of a clean ledger.
|
||||||
|
|
||||||
Two distinct terminal states replace the single `dispenseError`:
|
Two distinct terminal states replace the single `dispenseError`:
|
||||||
|
|
||||||
- **`outOfCash`** — the request could not be met from inventory and the dispenser reported
|
- **`outOfCash`** — the request could not be met and the dispenser reported **no error**
|
||||||
**no error**. Nothing was charged beyond what was dispensed.
|
(an inventory refusal, or simply short). In the cash-out flow this state is reached *after*
|
||||||
|
payment, so the customer **has paid** and is owed the shortfall exactly as below; the
|
||||||
|
difference is the cause — no hardware fault, so the machine stays in service and nothing
|
||||||
|
latches. (An earlier draft said "nothing was charged beyond what was dispensed"; that is
|
||||||
|
only true of the inventory check *before* payment, which already prevents the sale.)
|
||||||
- **`dispenseFault`** — the dispenser reported an error. The customer **has paid** and is
|
- **`dispenseFault`** — the dispenser reported an error. The customer **has paid** and is
|
||||||
owed the shortfall.
|
owed the shortfall, and a `terminal` class also latches cash-out off (Decision 5).
|
||||||
|
|
||||||
|
Both screens therefore show the same evidence; the heading and the latch differ.
|
||||||
|
|
||||||
`dispenseFault` shows: the amount paid, the amount dispensed (per denomination, as now), the
|
`dispenseFault` shows: the amount paid, the amount dispensed (per denomination, as now), the
|
||||||
txid as QR (as now) **and as text**, the first 12 characters of the payment hash, the time,
|
txid as QR (as now) **and as text**, the first 12 characters of the payment hash, the time,
|
||||||
|
|
|
||||||
60
packages/hal/src/dispensers/error-codes.ts
Normal file
60
packages/hal/src/dispensers/error-codes.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/**
|
||||||
|
* Dispense error taxonomy shared by every dispenser driver (ADR-005 §7).
|
||||||
|
*
|
||||||
|
* Three fields travel with a failed dispense, mirroring the shape
|
||||||
|
* lamassu-server kept on cash_out_txs — `error` (human), `error_code`
|
||||||
|
* (the error's NAME), plus the boolean the caller computes on value:
|
||||||
|
*
|
||||||
|
* errorCode the family, e.g. 'F56DispenseError' — stable, machine-readable
|
||||||
|
* rawCode the driver-native code, e.g. '78 42' — for the decode table
|
||||||
|
* errorClass how the caller should route it (below)
|
||||||
|
* human what an operator will find when they open the machine
|
||||||
|
*
|
||||||
|
* errorClass decides what the state machine does next:
|
||||||
|
*
|
||||||
|
* terminal the transport path is compromised (jam, motor, diverter,
|
||||||
|
* sensor, comm timeout). Retrying from another bay would jam
|
||||||
|
* too, and re-initialising does not move a stuck note. The
|
||||||
|
* machine latches cash-out off until an operator clears it.
|
||||||
|
* recoverable this bay or this note (pick failure, length/thickness
|
||||||
|
* reject, bill-end). The customer-facing fault screen still
|
||||||
|
* shows — they have paid — but the machine stays in service.
|
||||||
|
* inventory nothing was asked of the hardware: the request could not be
|
||||||
|
* met from the bays. Not a fault; routes to the out-of-cash
|
||||||
|
* screen, and nothing was charged beyond what was dispensed.
|
||||||
|
*
|
||||||
|
* No code here is borrowed from another layer's vocabulary. lamassu tagged
|
||||||
|
* every F56 fault with statusCode 570, which its server read as "insufficient
|
||||||
|
* funds" — a jam told the operator to refill a cassette that was not empty.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DispenseErrorClass = 'terminal' | 'recoverable' | 'inventory'
|
||||||
|
|
||||||
|
export interface DispenseErrorInfo {
|
||||||
|
errorCode: string
|
||||||
|
rawCode?: string
|
||||||
|
errorClass: DispenseErrorClass
|
||||||
|
human: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An Error carrying the taxonomy. Drivers return these from `dispense()`. */
|
||||||
|
export interface DispenseError extends Error, DispenseErrorInfo {}
|
||||||
|
|
||||||
|
/** Stamp the taxonomy onto an existing Error without losing its stack. */
|
||||||
|
export function tagDispenseError(error: Error, info: DispenseErrorInfo): DispenseError {
|
||||||
|
const tagged = error as DispenseError
|
||||||
|
tagged.name = info.errorCode
|
||||||
|
tagged.errorCode = info.errorCode
|
||||||
|
tagged.rawCode = info.rawCode
|
||||||
|
tagged.errorClass = info.errorClass
|
||||||
|
tagged.human = info.human
|
||||||
|
return tagged
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDispenseError(err: unknown): err is DispenseError {
|
||||||
|
return (
|
||||||
|
err instanceof Error &&
|
||||||
|
typeof (err as Partial<DispenseError>).errorCode === 'string' &&
|
||||||
|
typeof (err as Partial<DispenseError>).errorClass === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
64
packages/hal/src/dispensers/f56/__tests__/bills.test.ts
Normal file
64
packages/hal/src/dispensers/f56/__tests__/bills.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* The F56 bill table is a list of [hi, lo] accept windows in millimetres per
|
||||||
|
* denomination, sent to the BDU at initialise. It was carried byte for byte
|
||||||
|
* from lamassu with nothing over it, and a wrong GTQ window produced a
|
||||||
|
* production fault (5/5 notes rejected, error 82 00) for weeks. These tests
|
||||||
|
* are the "no value table without a test" rule from ADR-005 finding 10.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { bills } from '../bills.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every quetzal, dollar and lempira note is 156 × 67 mm. HNL is in lamassu's
|
||||||
|
* 37-currency table but not (yet) in ours — include it only if present so the
|
||||||
|
* invariant holds the day it is added.
|
||||||
|
*/
|
||||||
|
const SAME_PHYSICAL_NOTE = (['USD', 'GTQ', 'HNL'] as const).filter((c) => c in bills)
|
||||||
|
|
||||||
|
describe('F56 bill table', () => {
|
||||||
|
const currencies = Object.keys(bills)
|
||||||
|
|
||||||
|
it('has entries', () => {
|
||||||
|
expect(currencies.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(currencies)('%s: every window is [hi, lo] with hi > lo and a plausible note length', (cur) => {
|
||||||
|
const data = bills[cur]!
|
||||||
|
expect(typeof data.thickness).toBe('number')
|
||||||
|
expect(typeof data.polymer).toBe('boolean')
|
||||||
|
for (const [denom, window] of Object.entries(data.lengths)) {
|
||||||
|
const [hi, lo] = window
|
||||||
|
expect(hi, `${cur} ${denom} hi`).toBeGreaterThan(lo)
|
||||||
|
// Real banknotes are roughly 110–180 mm long; a window outside that
|
||||||
|
// means a typo, not a note.
|
||||||
|
expect(lo, `${cur} ${denom} lo`).toBeGreaterThanOrEqual(100)
|
||||||
|
expect(hi, `${cur} ${denom} hi`).toBeLessThanOrEqual(190)
|
||||||
|
// A window narrower than ±5 rejects real notes on sensor noise; wider
|
||||||
|
// than ±15 stops catching offset double-picks.
|
||||||
|
expect(hi - lo, `${cur} ${denom} width`).toBeGreaterThanOrEqual(10)
|
||||||
|
expect(hi - lo, `${cur} ${denom} width`).toBeLessThanOrEqual(30)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GTQ, USD and HNL — physically the same 156 mm note — share one window', () => {
|
||||||
|
const windows = SAME_PHYSICAL_NOTE.map((cur) => {
|
||||||
|
const lengths = bills[cur]!.lengths
|
||||||
|
const all = Object.values(lengths).map(([hi, lo]) => `${hi}-${lo}`)
|
||||||
|
return { cur, distinct: [...new Set(all)] }
|
||||||
|
})
|
||||||
|
for (const w of windows) {
|
||||||
|
expect(w.distinct, `${w.cur} has one window for all denominations`).toHaveLength(1)
|
||||||
|
}
|
||||||
|
const usd = windows.find((w) => w.cur === 'USD')!.distinct[0]
|
||||||
|
for (const w of windows) {
|
||||||
|
expect(w.distinct[0], `${w.cur} matches USD (lamassu b1cc3622)`).toBe(usd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the 156 mm window is 146–166 (±10)', () => {
|
||||||
|
const [hi, lo] = bills.USD!.lengths[20]!
|
||||||
|
expect(hi).toBe(166)
|
||||||
|
expect(lo).toBe(146)
|
||||||
|
expect((hi + lo) / 2).toBe(156)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { decodeF56Error, listF56ErrorCodes, normaliseF56Code, F56_ERROR_CODE } from '../error-codes.js'
|
||||||
|
|
||||||
|
describe('F56 error-code decode (ADR-005 §7)', () => {
|
||||||
|
it("decodes sintra's exit jam as terminal", () => {
|
||||||
|
const info = decodeF56Error('78 42')
|
||||||
|
expect(info.errorCode).toBe(F56_ERROR_CODE)
|
||||||
|
expect(info.rawCode).toBe('78 42')
|
||||||
|
expect(info.errorClass).toBe('terminal')
|
||||||
|
expect(info.human).toMatch(/cassette exit/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("decodes the Tejo's long-bill reject as recoverable", () => {
|
||||||
|
const info = decodeF56Error('82 00')
|
||||||
|
expect(info.errorClass).toBe('recoverable')
|
||||||
|
expect(info.human).toMatch(/length/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('decodes parameterised families by first byte', () => {
|
||||||
|
expect(decodeF56Error('85 03').errorClass).toBe('recoverable')
|
||||||
|
expect(decodeF56Error('85 03').human).toMatch(/another safe/i)
|
||||||
|
expect(decodeF56Error('B5 01').errorClass).toBe('terminal')
|
||||||
|
expect(decodeF56Error('b5 7f').errorClass).toBe('terminal')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails SAFE on an unknown code: terminal, named, code preserved', () => {
|
||||||
|
const info = decodeF56Error('99 99')
|
||||||
|
expect(info.errorCode).toBe(F56_ERROR_CODE)
|
||||||
|
expect(info.errorClass).toBe('terminal')
|
||||||
|
expect(info.rawCode).toBe('99 99')
|
||||||
|
expect(info.human).toContain('99 99')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a missing frame (serial timeout) as terminal', () => {
|
||||||
|
const info = decodeF56Error(undefined)
|
||||||
|
expect(info.errorClass).toBe('terminal')
|
||||||
|
expect(info.rawCode).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalises spelling variants to "XX YY"', () => {
|
||||||
|
expect(normaliseF56Code('7842')).toBe('78 42')
|
||||||
|
expect(normaliseF56Code('78-42')).toBe('78 42')
|
||||||
|
expect(normaliseF56Code('78 42')).toBe('78 42')
|
||||||
|
expect(normaliseF56Code('b5 01')).toBe('B5 01')
|
||||||
|
expect(decodeF56Error('7842')).toEqual(decodeF56Error('78 42'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never borrows another layer\'s code (the lamassu 570 lesson)', () => {
|
||||||
|
for (const row of listF56ErrorCodes()) {
|
||||||
|
expect(row).not.toHaveProperty('statusCode')
|
||||||
|
}
|
||||||
|
expect(decodeF56Error('78 42')).not.toHaveProperty('statusCode')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists every known code for the operator glossary', () => {
|
||||||
|
const codes = listF56ErrorCodes().map((r) => r.code)
|
||||||
|
expect(codes).toContain('78 42')
|
||||||
|
expect(codes).toContain('82 00')
|
||||||
|
expect(codes).toContain('85 ..')
|
||||||
|
expect(codes).toContain('B5 ..')
|
||||||
|
for (const row of listF56ErrorCodes()) {
|
||||||
|
expect(['terminal', 'recoverable', 'inventory']).toContain(row.errorClass)
|
||||||
|
expect(row.human.length).toBeGreaterThan(10)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
109
packages/hal/src/dispensers/f56/error-codes.ts
Normal file
109
packages/hal/src/dispensers/f56/error-codes.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
/**
|
||||||
|
* Fujitsu F53/F56 BDU error-code decode table (ADR-005 §7).
|
||||||
|
*
|
||||||
|
* The BDU answers a failed bill-count with an 0xF0 frame whose bytes 3–4 are
|
||||||
|
* the error code; `f56-rs232.billCount` surfaces them as `rawCode` in the
|
||||||
|
* form prettyHex produces, e.g. '78 42'. Neither lamassu codebase ever
|
||||||
|
* decoded these — both collapsed every fault into one opaque string.
|
||||||
|
*
|
||||||
|
* Built empirically and from the Fujitsu Frontech F56-BDU Error Code List
|
||||||
|
* (K3KD03234–K3KD03236-0001, ed. E02). Entries are keyed by the full two-byte
|
||||||
|
* code, or by the first byte for families where the second byte is a
|
||||||
|
* parameter (`85 0n` = pick from another safe n; `B5 ..` = reject-box
|
||||||
|
* overflow). An unknown code fails SAFE: terminal, so an unfamiliar fault
|
||||||
|
* latches cash-out off rather than letting the next customer pay into it.
|
||||||
|
*
|
||||||
|
* Add a row when a code occurs. Each row's `observed` is the first machine
|
||||||
|
* and date we saw it, so the table doubles as the incident log.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DispenseErrorClass, DispenseErrorInfo } from '../error-codes.js'
|
||||||
|
|
||||||
|
export const F56_ERROR_CODE = 'F56DispenseError'
|
||||||
|
|
||||||
|
interface F56ErrorEntry {
|
||||||
|
errorClass: DispenseErrorClass
|
||||||
|
human: string
|
||||||
|
/** First observed — machine, date. Empty for spec-only entries. */
|
||||||
|
observed?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact two-byte codes. */
|
||||||
|
const EXACT: Record<string, F56ErrorEntry> = {
|
||||||
|
'78 42': {
|
||||||
|
errorClass: 'terminal',
|
||||||
|
human: 'Note stopped at the cassette exit — open the unit and clear the transport path',
|
||||||
|
observed: 'sintra, 2026-10-09',
|
||||||
|
},
|
||||||
|
'82 00': {
|
||||||
|
errorClass: 'recoverable',
|
||||||
|
human: 'Bill length check failed (long) — note read longer than the configured window',
|
||||||
|
observed: 'tejo (GTQ), 2026-09-26',
|
||||||
|
},
|
||||||
|
'83 00': {
|
||||||
|
errorClass: 'recoverable',
|
||||||
|
human: 'Bill length check failed (short)',
|
||||||
|
},
|
||||||
|
'84 00': {
|
||||||
|
errorClass: 'recoverable',
|
||||||
|
human: 'Bill thickness check failed — possible double pick or damaged note',
|
||||||
|
},
|
||||||
|
'86 00': {
|
||||||
|
errorClass: 'recoverable',
|
||||||
|
human: 'Bill spacing error — notes too close together on the transport',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Families keyed by the first byte; the second byte is a parameter. */
|
||||||
|
const FAMILY: Record<string, F56ErrorEntry> = {
|
||||||
|
'85': {
|
||||||
|
errorClass: 'recoverable',
|
||||||
|
human: 'Pick from another safe — the note came from a different cassette than commanded',
|
||||||
|
},
|
||||||
|
B5: {
|
||||||
|
errorClass: 'terminal',
|
||||||
|
human: 'Reject box overflow — empty the reject tray',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalise '78 42', '7842', '78-42', lowercase, etc. to 'XX YY'. */
|
||||||
|
export function normaliseF56Code(raw: string): string {
|
||||||
|
const hex = raw.replace(/[^0-9a-fA-F]/g, '').toUpperCase()
|
||||||
|
if (hex.length !== 4) return raw.trim().toUpperCase()
|
||||||
|
return `${hex.slice(0, 2)} ${hex.slice(2, 4)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeF56Error(rawCode: string | undefined): DispenseErrorInfo {
|
||||||
|
if (!rawCode) {
|
||||||
|
// No frame came back at all — serial timeout, port closed, framing error.
|
||||||
|
// The transport is in an unknown state; treat as terminal.
|
||||||
|
return {
|
||||||
|
errorCode: F56_ERROR_CODE,
|
||||||
|
errorClass: 'terminal',
|
||||||
|
human: 'Dispenser did not answer — serial timeout or framing error',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const code = normaliseF56Code(rawCode)
|
||||||
|
const exact = EXACT[code]
|
||||||
|
if (exact) {
|
||||||
|
return { errorCode: F56_ERROR_CODE, rawCode: code, errorClass: exact.errorClass, human: exact.human }
|
||||||
|
}
|
||||||
|
const family = FAMILY[code.slice(0, 2)]
|
||||||
|
if (family) {
|
||||||
|
return { errorCode: F56_ERROR_CODE, rawCode: code, errorClass: family.errorClass, human: family.human }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
errorCode: F56_ERROR_CODE,
|
||||||
|
rawCode: code,
|
||||||
|
errorClass: 'terminal',
|
||||||
|
human: `Unrecognised dispenser error ${code} — treat as a jam until decoded`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For the operator glossary: every known code with its class and meaning. */
|
||||||
|
export function listF56ErrorCodes(): Array<{ code: string; errorClass: DispenseErrorClass; human: string; observed?: string }> {
|
||||||
|
return [
|
||||||
|
...Object.entries(EXACT).map(([code, e]) => ({ code, ...e })),
|
||||||
|
...Object.entries(FAMILY).map(([code, e]) => ({ code: `${code} ..`, ...e })),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -134,6 +134,8 @@ export async function initialize(currency: string, denominations: number[]): Pro
|
||||||
export interface BillCountResult {
|
export interface BillCountResult {
|
||||||
bills: Array<{ dispensed: number; rejected: number }>
|
bills: Array<{ dispensed: number; rejected: number }>
|
||||||
error?: Error
|
error?: Error
|
||||||
|
/** BDU error code from bytes 3–4 of an 0xF0 frame, e.g. '78 42'. */
|
||||||
|
rawCode?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function billCount(counts: number[]): Promise<BillCountResult> {
|
export async function billCount(counts: number[]): Promise<BillCountResult> {
|
||||||
|
|
@ -172,9 +174,10 @@ export async function billCount(counts: number[]): Promise<BillCountResult> {
|
||||||
|
|
||||||
if (res[0] === 0xf0) {
|
if (res[0] === 0xf0) {
|
||||||
console.log('response', res)
|
console.log('response', res)
|
||||||
const errorCode = res.subarray(3, 5)
|
const rawCode = prettyHex(res.subarray(3, 5))
|
||||||
response.error = new Error(`Dispensing, code: ${prettyHex(errorCode)}`)
|
response.rawCode = rawCode
|
||||||
console.error(`found error code: ${prettyHex(errorCode)}`)
|
response.error = new Error(`Dispensing, code: ${rawCode}`)
|
||||||
|
console.error(`found error code: ${rawCode}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as f56 from './f56-rs232.js'
|
import * as f56 from './f56-rs232.js'
|
||||||
|
import { decodeF56Error } from './error-codes.js'
|
||||||
|
import { tagDispenseError, type DispenseError } from '../error-codes.js'
|
||||||
import type {
|
import type {
|
||||||
BillDispenser,
|
BillDispenser,
|
||||||
DispenserConfig,
|
DispenserConfig,
|
||||||
|
|
@ -51,23 +53,28 @@ export class F56Dispenser implements BillDispenser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: Error }> {
|
/**
|
||||||
|
* On any failure the port is closed so the next attempt re-initialises;
|
||||||
|
* the error is tagged with the ADR-005 taxonomy (class + decoded meaning)
|
||||||
|
* so the caller can route it. No statusCode: lamassu's 570 meant
|
||||||
|
* "insufficient funds" to its server and sent operators to refill full
|
||||||
|
* cassettes after jams.
|
||||||
|
*/
|
||||||
|
async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: DispenseError }> {
|
||||||
try {
|
try {
|
||||||
const { bills, error } = await f56.billCount(notes)
|
const { bills, error, rawCode } = await f56.billCount(notes)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
await this.close()
|
await this.close()
|
||||||
;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError'
|
return { value: bills, error: tagDispenseError(error, decodeF56Error(rawCode)) }
|
||||||
;(error as Error & { statusCode: number }).statusCode = 570
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { value: bills, error }
|
return { value: bills }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await this.close()
|
await this.close()
|
||||||
const error = err as Error
|
const error = err instanceof Error ? err : new Error(String(err))
|
||||||
;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError'
|
// No frame: serial timeout / framing. decodeF56Error(undefined) → terminal.
|
||||||
;(error as Error & { statusCode: number }).statusCode = 570
|
return { value: [], error: tagDispenseError(error, decodeF56Error(undefined)) }
|
||||||
return { value: [], error }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
DispenserInitData,
|
DispenserInitData,
|
||||||
DispenseResult,
|
DispenseResult,
|
||||||
} from '../../types.js'
|
} from '../../types.js'
|
||||||
|
import { tagDispenseError, type DispenseError } from '../error-codes.js'
|
||||||
|
|
||||||
export class PuloonDispenser implements BillDispenser {
|
export class PuloonDispenser implements BillDispenser {
|
||||||
public type: string = 'Puloon'
|
public type: string = 'Puloon'
|
||||||
|
|
@ -46,16 +47,26 @@ export class PuloonDispenser implements BillDispenser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: Error }> {
|
async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: DispenseError }> {
|
||||||
const { bills, error } = await this.device.dispense(notes)
|
const { bills, error } = await this.device.dispense(notes)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
await this.close()
|
await this.close()
|
||||||
error.name = 'PuloonDispenseError'
|
|
||||||
console.log('PULOON | dispense error', error)
|
console.log('PULOON | dispense error', error)
|
||||||
|
// No decode table for the LCDM yet: every fault is terminal until one
|
||||||
|
// exists, so an unknown Puloon error latches cash-out off (ADR-005 §7).
|
||||||
|
return {
|
||||||
|
value: bills,
|
||||||
|
error: tagDispenseError(error, {
|
||||||
|
errorCode: 'PuloonDispenseError',
|
||||||
|
rawCode: (error as Error & { code?: string }).code,
|
||||||
|
errorClass: 'terminal',
|
||||||
|
human: `Puloon dispense error: ${error.message}`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { value: bills, error }
|
return { value: bills }
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -57,5 +57,20 @@ export type {
|
||||||
DispenserFactory,
|
DispenserFactory,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
||||||
|
// Dispense error taxonomy (ADR-005 §7)
|
||||||
|
export {
|
||||||
|
tagDispenseError,
|
||||||
|
isDispenseError,
|
||||||
|
type DispenseError,
|
||||||
|
type DispenseErrorClass,
|
||||||
|
type DispenseErrorInfo,
|
||||||
|
} from './dispensers/error-codes.js'
|
||||||
|
export {
|
||||||
|
decodeF56Error,
|
||||||
|
listF56ErrorCodes,
|
||||||
|
normaliseF56Code,
|
||||||
|
F56_ERROR_CODE,
|
||||||
|
} from './dispensers/f56/error-codes.js'
|
||||||
|
|
||||||
// Utilities
|
// Utilities
|
||||||
export { compute as computeCrc } from './utils/crc.js'
|
export { compute as computeCrc } from './utils/crc.js'
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { DispenseError } from './dispensers/error-codes.js'
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events'
|
import { EventEmitter } from 'node:events'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -176,7 +178,8 @@ export interface BillDispenser {
|
||||||
*/
|
*/
|
||||||
dispense(notes: number[]): Promise<{
|
dispense(notes: number[]): Promise<{
|
||||||
value: DispenseResult[]
|
value: DispenseResult[]
|
||||||
error?: Error
|
/** Tagged with the ADR-005 taxonomy — see dispensers/error-codes.ts */
|
||||||
|
error?: DispenseError
|
||||||
}>
|
}>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ import type {
|
||||||
CreateWithdrawResult,
|
CreateWithdrawResult,
|
||||||
LnbitsWithdrawLink,
|
LnbitsWithdrawLink,
|
||||||
UniqueHashesResponse,
|
UniqueHashesResponse,
|
||||||
|
DispenseReportBody,
|
||||||
|
DispenseReportAck,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
||||||
const LNBITS_KIND_RPC = 21000
|
const LNBITS_KIND_RPC = 21000
|
||||||
|
|
@ -234,6 +236,18 @@ export class LnbitsClient {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report a cash-out's dispense outcome (ADR-005 §2). Sent on success and on
|
||||||
|
* failure; the success report is what captures the settlement server-side.
|
||||||
|
* Idempotent on `txid` (the server upserts), so it is safe to retry — and the
|
||||||
|
* caller keeps it in a durable outbox and resends until this resolves.
|
||||||
|
* Rejects with LnbitsRpcError while spirekeeper has not registered the RPC;
|
||||||
|
* the outbox treats that like any other transient failure.
|
||||||
|
*/
|
||||||
|
async reportDispense(body: DispenseReportBody): Promise<DispenseReportAck> {
|
||||||
|
return this.idempotent(() => this.sendRpc<DispenseReportAck>('report_dispense', { body }))
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Invoices
|
// Invoices
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -80,4 +80,8 @@ export type {
|
||||||
UniqueHashEntry,
|
UniqueHashEntry,
|
||||||
UniqueHashesResponse,
|
UniqueHashesResponse,
|
||||||
LnbitsPayLink,
|
LnbitsPayLink,
|
||||||
|
DispenseReportBody,
|
||||||
|
DispenseReportAck,
|
||||||
|
DispenseReportBill,
|
||||||
|
DispenseReportCassette,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
|
||||||
|
|
@ -310,3 +310,66 @@ export interface MachineConfigResponse {
|
||||||
/** Freshness watermark (unix s) for the consumer's fee-config replay guard. */
|
/** Freshness watermark (unix s) for the consumer's fee-config replay guard. */
|
||||||
created_at: number
|
created_at: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Dispense outcome report (ADR-005 §2) — machine → spirekeeper `report_dispense`
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Per-denomination outcome. `requested` is what the sale asked for. */
|
||||||
|
export interface DispenseReportBill {
|
||||||
|
denomination: number
|
||||||
|
requested: number
|
||||||
|
dispensed: number
|
||||||
|
rejected: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-bay outcome, verbatim from the machine's cassette_bills row. */
|
||||||
|
export interface DispenseReportCassette {
|
||||||
|
position: number
|
||||||
|
denomination: number
|
||||||
|
provisioned: number
|
||||||
|
dispensed: number
|
||||||
|
rejected: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One cash-out's dispense outcome, sent on SUCCESS as well as failure — the
|
||||||
|
* success report is what captures the settlement server-side. Field names
|
||||||
|
* follow lamassu-server's cash_out_txs / cash_out_actions (dispense_confirmed,
|
||||||
|
* error, error_code) so the server's model lines up with ten years of prior
|
||||||
|
* art. Idempotent on `txid`: the machine resends until acked, the server
|
||||||
|
* upserts.
|
||||||
|
*/
|
||||||
|
export interface DispenseReportBody {
|
||||||
|
txid: string
|
||||||
|
/** Hash of the invoice the customer paid — the join key to the LNbits payment. */
|
||||||
|
payment_hash: string | null
|
||||||
|
tx_type: 'cash_out'
|
||||||
|
/** Σ(denomination × dispensed) === requested fiat value (computed on value). */
|
||||||
|
dispense_confirmed: boolean
|
||||||
|
/** Human message; null on success. */
|
||||||
|
error: string | null
|
||||||
|
/** The error's NAME, e.g. 'F56DispenseError'; null on success. */
|
||||||
|
error_code: string | null
|
||||||
|
/** Driver-native code, e.g. '78 42'; null when none. */
|
||||||
|
raw_code: string | null
|
||||||
|
error_class: 'terminal' | 'recoverable' | 'inventory' | null
|
||||||
|
fiat_cents: number
|
||||||
|
currency: string
|
||||||
|
bills: DispenseReportBill[]
|
||||||
|
cassettes: DispenseReportCassette[]
|
||||||
|
/** The machine could not vouch for its bay counts after this dispense. */
|
||||||
|
counts_uncertain: boolean
|
||||||
|
/** Set when this report closes an earlier failed txid via manual dispense. */
|
||||||
|
remediates_txid?: string
|
||||||
|
/** unix seconds the outcome was recorded on the machine */
|
||||||
|
at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server acknowledgement. `settlement_status` is what the server moved the settlement to. */
|
||||||
|
export interface DispenseReportAck {
|
||||||
|
txid: string
|
||||||
|
received: boolean
|
||||||
|
settlement_status?: string
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ describe('ATM State Machine', () => {
|
||||||
sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
|
sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
|
||||||
dispenseCash: vi.fn().mockResolvedValue({
|
dispenseCash: vi.fn().mockResolvedValue({
|
||||||
bills: [{ denomination: 20, dispensed: 1, rejected: 0 }],
|
bills: [{ denomination: 20, dispensed: 1, rejected: 0 }],
|
||||||
dispensed: true,
|
dispenseConfirmed: true,
|
||||||
} satisfies DispenseCashResult),
|
} satisfies DispenseCashResult),
|
||||||
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
|
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
|
||||||
getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available
|
getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available
|
||||||
|
|
@ -397,127 +397,224 @@ describe('ATM State Machine', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('dispense error handling', () => {
|
describe('dispense outcome (ADR-005 §3–§5)', () => {
|
||||||
it('should route to waitingForCashTaken when dispenseCash returns dispensed: true', async () => {
|
/** Drive a 1 × $20 cash-out to the dispense and return the actor. */
|
||||||
const machine = createATMMachine(mockServices)
|
async function dispenseWith(result: DispenseCashResult, fake = false) {
|
||||||
const actor = createActor(machine)
|
const services: ATMServices = { ...mockServices, dispenseCash: vi.fn().mockResolvedValue(result) }
|
||||||
|
const actor = createActor(createATMMachine(services))
|
||||||
actor.start()
|
actor.start()
|
||||||
|
const tick = async (ms: number) =>
|
||||||
|
fake ? vi.advanceTimersByTimeAsync(ms) : new Promise((r) => setTimeout(r, ms))
|
||||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await tick(100)
|
||||||
|
|
||||||
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
||||||
actor.send({ type: 'CONFIRM_AMOUNT' })
|
actor.send({ type: 'CONFIRM_AMOUNT' })
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await tick(100)
|
||||||
|
|
||||||
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
|
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
await tick(100)
|
||||||
|
return actor
|
||||||
|
}
|
||||||
|
|
||||||
|
it('completes only on dispenseConfirmed (value equality), never on a driver boolean', async () => {
|
||||||
|
const actor = await dispenseWith({
|
||||||
|
bills: [{ denomination: 20, dispensed: 1, rejected: 0 }],
|
||||||
|
dispenseConfirmed: true,
|
||||||
|
})
|
||||||
const state = actor.getSnapshot()
|
const state = actor.getSnapshot()
|
||||||
// dispenseCash mock returns { dispensed: true }, so should go to waitingForCashTaken
|
|
||||||
expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' })
|
expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' })
|
||||||
expect(state.context.cashDispensed).toBe(true)
|
expect(state.context.cashDispensed).toBe(true)
|
||||||
expect(state.context.dispenseResult?.dispensed).toBe(true)
|
expect(state.context.dispenseResult?.dispenseConfirmed).toBe(true)
|
||||||
|
expect(state.context.cashOutHeld).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should route to dispenseError when dispenseCash returns dispensed: false', async () => {
|
it('routes a hardware error to dispenseFault — the customer has paid and is owed', async () => {
|
||||||
const failDispenseServices: ATMServices = {
|
const actor = await dispenseWith({
|
||||||
...mockServices,
|
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||||
dispenseCash: vi.fn().mockResolvedValue({
|
dispenseConfirmed: false,
|
||||||
bills: [{ denomination: 20, dispensed: 0, rejected: 1 }],
|
error: 'Note stopped at the cassette exit',
|
||||||
dispensed: false,
|
errorCode: 'F56DispenseError',
|
||||||
error: 'Cassette jam',
|
rawCode: '78 42',
|
||||||
} satisfies DispenseCashResult),
|
errorClass: 'terminal',
|
||||||
}
|
})
|
||||||
|
|
||||||
const machine = createATMMachine(failDispenseServices)
|
|
||||||
const actor = createActor(machine)
|
|
||||||
actor.start()
|
|
||||||
|
|
||||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
|
|
||||||
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
|
||||||
actor.send({ type: 'CONFIRM_AMOUNT' })
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
|
|
||||||
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
|
|
||||||
const state = actor.getSnapshot()
|
const state = actor.getSnapshot()
|
||||||
expect(state.value).toMatchObject({ cashOut: 'dispenseError' })
|
expect(state.value).toMatchObject({ cashOut: 'dispenseFault' })
|
||||||
expect(state.context.cashDispensed).toBe(false)
|
expect(state.context.cashDispensed).toBe(false)
|
||||||
expect(state.context.dispenseResult?.dispensed).toBe(false)
|
expect(state.context.error).toBe('Note stopped at the cassette exit')
|
||||||
expect(state.context.dispenseResult?.error).toBe('Cassette jam')
|
expect(state.context.dispenseResult?.rawCode).toBe('78 42')
|
||||||
expect(state.context.error).toBe('Cassette jam')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should auto-idle after 30s in dispenseError state', async () => {
|
it('a terminal fault latches cash-out off; idle refuses SELECT_CASH_OUT until released', async () => {
|
||||||
vi.useFakeTimers()
|
const actor = await dispenseWith({
|
||||||
|
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||||
const failDispenseServices: ATMServices = {
|
dispenseConfirmed: false,
|
||||||
...mockServices,
|
error: 'jam',
|
||||||
dispenseCash: vi.fn().mockResolvedValue({
|
errorCode: 'F56DispenseError',
|
||||||
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
rawCode: '78 42',
|
||||||
dispensed: false,
|
errorClass: 'terminal',
|
||||||
error: 'Out of cash',
|
})
|
||||||
} satisfies DispenseCashResult),
|
const hold = actor.getSnapshot().context.cashOutHeld
|
||||||
}
|
expect(hold).not.toBeNull()
|
||||||
|
expect(hold?.errorCode).toBe('F56DispenseError')
|
||||||
const machine = createATMMachine(failDispenseServices)
|
expect(hold?.rawCode).toBe('78 42')
|
||||||
const actor = createActor(machine)
|
expect(typeof hold?.since).toBe('number')
|
||||||
actor.start()
|
|
||||||
|
|
||||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
|
||||||
await vi.advanceTimersByTimeAsync(100)
|
|
||||||
|
|
||||||
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
|
||||||
actor.send({ type: 'CONFIRM_AMOUNT' })
|
|
||||||
await vi.advanceTimersByTimeAsync(100)
|
|
||||||
|
|
||||||
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
|
|
||||||
await vi.advanceTimersByTimeAsync(100)
|
|
||||||
|
|
||||||
// Should be in dispenseError
|
|
||||||
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseError' })
|
|
||||||
|
|
||||||
// Advance 30s
|
|
||||||
await vi.advanceTimersByTimeAsync(30000)
|
|
||||||
|
|
||||||
// Should have auto-idled
|
|
||||||
expect(actor.getSnapshot().value).toBe('idle')
|
|
||||||
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should allow CANCEL from dispenseError to go to idle immediately', async () => {
|
|
||||||
const failDispenseServices: ATMServices = {
|
|
||||||
...mockServices,
|
|
||||||
dispenseCash: vi.fn().mockResolvedValue({
|
|
||||||
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
|
||||||
dispensed: false,
|
|
||||||
error: 'Jam',
|
|
||||||
} satisfies DispenseCashResult),
|
|
||||||
}
|
|
||||||
|
|
||||||
const machine = createATMMachine(failDispenseServices)
|
|
||||||
const actor = createActor(machine)
|
|
||||||
actor.start()
|
|
||||||
|
|
||||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
|
|
||||||
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
|
||||||
actor.send({ type: 'CONFIRM_AMOUNT' })
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
|
|
||||||
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
|
|
||||||
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseError' })
|
|
||||||
|
|
||||||
actor.send({ type: 'CANCEL' })
|
actor.send({ type: 'CANCEL' })
|
||||||
expect(actor.getSnapshot().value).toBe('idle')
|
expect(actor.getSnapshot().value).toBe('idle')
|
||||||
|
// The hold survives resetContext — it is machine health, not transaction state.
|
||||||
|
expect(actor.getSnapshot().context.cashOutHeld).not.toBeNull()
|
||||||
|
|
||||||
|
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||||
|
expect(actor.getSnapshot().value).toBe('idle')
|
||||||
|
|
||||||
|
// Only an operator op releases it (the store sends this on recount / resume_cash_out).
|
||||||
|
actor.send({ type: 'CASH_OUT_RELEASED' })
|
||||||
|
expect(actor.getSnapshot().context.cashOutHeld).toBeNull()
|
||||||
|
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||||
|
expect(actor.getSnapshot().value).toMatchObject({ cashOut: expect.anything() })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a hold gates cash-out only — cash-in is unaffected by a dispenser fault', () => {
|
||||||
|
const actor = createActor(createATMMachine(mockServices))
|
||||||
|
actor.start()
|
||||||
|
actor.send({
|
||||||
|
type: 'CASH_OUT_HELD',
|
||||||
|
hold: { reason: 'jam', errorCode: 'F56DispenseError', rawCode: '78 42', since: 1 },
|
||||||
|
})
|
||||||
|
actor.send({ type: 'SELECT_CASH_IN' })
|
||||||
|
expect(actor.getSnapshot().value).toMatchObject({ cashIn: expect.anything() })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a recoverable fault shows the fault screen but does NOT latch', async () => {
|
||||||
|
const actor = await dispenseWith({
|
||||||
|
bills: [{ denomination: 20, dispensed: 0, rejected: 1 }],
|
||||||
|
dispenseConfirmed: false,
|
||||||
|
error: 'Bill length check failed (long)',
|
||||||
|
errorCode: 'F56DispenseError',
|
||||||
|
rawCode: '82 00',
|
||||||
|
errorClass: 'recoverable',
|
||||||
|
})
|
||||||
|
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
|
||||||
|
expect(actor.getSnapshot().context.cashOutHeld).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a partial WITH an error is a fault (owed the shortfall), not out-of-cash', async () => {
|
||||||
|
const actor = await dispenseWith({
|
||||||
|
bills: [{ denomination: 20, dispensed: 1, rejected: 1 }],
|
||||||
|
dispenseConfirmed: false,
|
||||||
|
error: 'jam after first note',
|
||||||
|
errorCode: 'F56DispenseError',
|
||||||
|
rawCode: '78 42',
|
||||||
|
errorClass: 'terminal',
|
||||||
|
})
|
||||||
|
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an inventory refusal (nothing asked of the hardware) is outOfCash, and does not latch', async () => {
|
||||||
|
const actor = await dispenseWith({
|
||||||
|
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||||
|
dispenseConfirmed: false,
|
||||||
|
error: 'Insufficient inventory for denomination 20: short 1',
|
||||||
|
errorCode: 'InsufficientInventory',
|
||||||
|
errorClass: 'inventory',
|
||||||
|
})
|
||||||
|
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' })
|
||||||
|
expect(actor.getSnapshot().context.cashOutHeld).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a shortfall with NO error is outOfCash', async () => {
|
||||||
|
const actor = await dispenseWith({
|
||||||
|
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||||
|
dispenseConfirmed: false,
|
||||||
|
})
|
||||||
|
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a persisted hold restored on boot gates cash-out before any dispense', () => {
|
||||||
|
const actor = createActor(createATMMachine(mockServices))
|
||||||
|
actor.start()
|
||||||
|
actor.send({
|
||||||
|
type: 'CASH_OUT_HELD',
|
||||||
|
hold: { reason: 'jam', errorCode: 'F56DispenseError', rawCode: '78 42', since: 1791529353 },
|
||||||
|
})
|
||||||
|
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||||
|
expect(actor.getSnapshot().value).toBe('idle')
|
||||||
|
expect(actor.getSnapshot().context.cashOutHeld?.since).toBe(1791529353)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('outOfCash auto-returns after 30s; dispenseFault gives the customer 120s', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
const ooc = await dispenseWith(
|
||||||
|
{ bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], dispenseConfirmed: false },
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(ooc.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' })
|
||||||
|
await vi.advanceTimersByTimeAsync(30000)
|
||||||
|
expect(ooc.getSnapshot().value).toBe('idle')
|
||||||
|
|
||||||
|
const fault = await dispenseWith(
|
||||||
|
{
|
||||||
|
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||||
|
dispenseConfirmed: false,
|
||||||
|
error: 'jam',
|
||||||
|
errorCode: 'F56DispenseError',
|
||||||
|
errorClass: 'recoverable',
|
||||||
|
},
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(fault.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
|
||||||
|
await vi.advanceTimersByTimeAsync(30000)
|
||||||
|
expect(fault.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
|
||||||
|
await vi.advanceTimersByTimeAsync(90000)
|
||||||
|
expect(fault.getSnapshot().value).toBe('idle')
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the customer can acknowledge the fault screen or cancel; both return to idle', async () => {
|
||||||
|
const a = await dispenseWith({
|
||||||
|
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||||
|
dispenseConfirmed: false,
|
||||||
|
error: 'jam',
|
||||||
|
errorClass: 'recoverable',
|
||||||
|
})
|
||||||
|
a.send({ type: 'ACKNOWLEDGE_FAULT' })
|
||||||
|
expect(a.getSnapshot().value).toBe('idle')
|
||||||
|
|
||||||
|
const b = await dispenseWith({
|
||||||
|
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
|
||||||
|
dispenseConfirmed: false,
|
||||||
|
error: 'jam',
|
||||||
|
errorClass: 'recoverable',
|
||||||
|
})
|
||||||
|
b.send({ type: 'CANCEL' })
|
||||||
|
expect(b.getSnapshot().value).toBe('idle')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a hung dispense (timeout) is a terminal fault and latches', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
const hang: ATMServices = {
|
||||||
|
...mockServices,
|
||||||
|
dispenseCash: vi.fn().mockReturnValue(new Promise(() => {})),
|
||||||
|
}
|
||||||
|
const actor = createActor(createATMMachine(hang))
|
||||||
|
actor.start()
|
||||||
|
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
||||||
|
actor.send({ type: 'CONFIRM_AMOUNT' })
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'p' })
|
||||||
|
await vi.advanceTimersByTimeAsync(120000 + 10)
|
||||||
|
const s = actor.getSnapshot()
|
||||||
|
expect(s.value).toMatchObject({ cashOut: 'dispenseFault' })
|
||||||
|
expect(s.context.dispenseResult?.errorCode).toBe('DispenseTimeout')
|
||||||
|
expect(s.context.cashOutHeld).not.toBeNull()
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
type ATMContext,
|
type ATMContext,
|
||||||
type ATMEvent,
|
type ATMEvent,
|
||||||
type DispenseCashResult,
|
type DispenseCashResult,
|
||||||
|
type CashOutHold,
|
||||||
initialContext,
|
initialContext,
|
||||||
type ATMServices,
|
type ATMServices,
|
||||||
type OfferRequestEvent,
|
type OfferRequestEvent,
|
||||||
|
|
@ -148,8 +149,8 @@ export function createATMMachine(
|
||||||
}
|
}
|
||||||
// Extract payment hash from invoice (simplified - real impl would decode BOLT11)
|
// Extract payment hash from invoice (simplified - real impl would decode BOLT11)
|
||||||
// The service handles the actual extraction
|
// The service handles the actual extraction
|
||||||
const cleanup = services.watchInvoice(input.invoice, (preimage: string) => {
|
const cleanup = services.watchInvoice(input.invoice, (preimage, paymentHash) => {
|
||||||
sendBack({ type: 'PAYMENT_RECEIVED', preimage })
|
sendBack({ type: 'PAYMENT_RECEIVED', preimage, paymentHash })
|
||||||
})
|
})
|
||||||
return cleanup
|
return cleanup
|
||||||
}),
|
}),
|
||||||
|
|
@ -184,6 +185,9 @@ export function createATMMachine(
|
||||||
// without this the just-granted session would be wiped. The session is
|
// without this the just-granted session would be wiped. The session is
|
||||||
// cleared instead on re-lock (locked's entry), i.e. when access ends.
|
// cleared instead on re-lock (locked's entry), i.e. when access ends.
|
||||||
accessSession: context.accessSession,
|
accessSession: context.accessSession,
|
||||||
|
// The cash-out latch is machine health, not transaction state — it
|
||||||
|
// survives every reset until an operator op releases it.
|
||||||
|
cashOutHeld: context.cashOutHeld,
|
||||||
cashInSessionId: null,
|
cashInSessionId: null,
|
||||||
dispenseResult: null,
|
dispenseResult: null,
|
||||||
})),
|
})),
|
||||||
|
|
@ -315,6 +319,10 @@ export function createATMMachine(
|
||||||
if (event.type !== 'PAYMENT_RECEIVED') return null
|
if (event.type !== 'PAYMENT_RECEIVED') return null
|
||||||
return event.preimage
|
return event.preimage
|
||||||
},
|
},
|
||||||
|
paymentHash: ({ event }) => {
|
||||||
|
if (event.type !== 'PAYMENT_RECEIVED') return null
|
||||||
|
return event.paymentHash ?? null
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
setPaymentFailed: assign({
|
setPaymentFailed: assign({
|
||||||
paymentStatus: () => 'failed' as const,
|
paymentStatus: () => 'failed' as const,
|
||||||
|
|
@ -332,6 +340,37 @@ export function createATMMachine(
|
||||||
return output?.error ?? null
|
return output?.error ?? null
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
// ADR-005 §5: a terminal fault latches cash-out off. Idempotent — an
|
||||||
|
// existing hold is kept (its `since` is the first fault, which is what
|
||||||
|
// the operator wants to know).
|
||||||
|
latchCashOutIfTerminal: assign({
|
||||||
|
cashOutHeld: ({ context }) => {
|
||||||
|
if (context.cashOutHeld) return context.cashOutHeld
|
||||||
|
const dr = context.dispenseResult
|
||||||
|
if (dr?.errorClass !== 'terminal') return null
|
||||||
|
return {
|
||||||
|
reason: dr.error ?? 'terminal dispenser fault',
|
||||||
|
errorCode: dr.errorCode ?? null,
|
||||||
|
rawCode: dr.rawCode ?? null,
|
||||||
|
since: Math.floor(Date.now() / 1000),
|
||||||
|
} satisfies CashOutHold
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
setCashOutHeld: assign({
|
||||||
|
cashOutHeld: ({ event }) => (event.type === 'CASH_OUT_HELD' ? event.hold : null),
|
||||||
|
}),
|
||||||
|
clearCashOutHeld: assign({ cashOutHeld: null }),
|
||||||
|
setDispenseTimeoutError: assign({
|
||||||
|
error: () => 'Dispense timed out — hardware may be jammed',
|
||||||
|
dispenseResult: ({ context }) =>
|
||||||
|
context.dispenseResult ?? {
|
||||||
|
bills: [],
|
||||||
|
dispenseConfirmed: false,
|
||||||
|
error: 'Dispense timed out — hardware may be jammed',
|
||||||
|
errorCode: 'DispenseTimeout',
|
||||||
|
errorClass: 'terminal' as const,
|
||||||
|
},
|
||||||
|
}),
|
||||||
setAmount: assign({
|
setAmount: assign({
|
||||||
fiatCents: ({ event }) => {
|
fiatCents: ({ event }) => {
|
||||||
if (event.type !== 'SELECT_AMOUNT') return 0
|
if (event.type !== 'SELECT_AMOUNT') return 0
|
||||||
|
|
@ -458,6 +497,18 @@ export function createATMMachine(
|
||||||
return principalSats - fee <= context.availableBalance
|
return principalSats - fee <= context.availableBalance
|
||||||
},
|
},
|
||||||
// Cash-out guards
|
// Cash-out guards
|
||||||
|
// ADR-005 §5: no cash-out while latched. The idle screen shows why.
|
||||||
|
cashOutAvailable: ({ context }) => context.cashOutHeld === null,
|
||||||
|
// ADR-005 §3/§4: only value equality completes a dispense.
|
||||||
|
dispenseConfirmed: ({ event }) =>
|
||||||
|
(event as unknown as { output?: DispenseCashResult }).output?.dispenseConfirmed === true,
|
||||||
|
// A shortfall WITH a hardware error is a fault (customer paid, is owed);
|
||||||
|
// a shortfall with none, or an inventory refusal, is out-of-cash.
|
||||||
|
dispenseHadFault: ({ event }) => {
|
||||||
|
const out = (event as unknown as { output?: DispenseCashResult }).output
|
||||||
|
if (!out?.error) return false
|
||||||
|
return out.errorClass !== 'inventory'
|
||||||
|
},
|
||||||
hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0,
|
hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0,
|
||||||
canAddDenomination: ({ context, event }) => {
|
canAddDenomination: ({ context, event }) => {
|
||||||
if (event.type !== 'ADD_DENOMINATION') return false
|
if (event.type !== 'ADD_DENOMINATION') return false
|
||||||
|
|
@ -477,7 +528,10 @@ export function createATMMachine(
|
||||||
INVOICE_TIMEOUT: 300000, // 5 minutes — waiting for payment
|
INVOICE_TIMEOUT: 300000, // 5 minutes — waiting for payment
|
||||||
COMPLETE_DELAY: 60000,
|
COMPLETE_DELAY: 60000,
|
||||||
DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond
|
DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond
|
||||||
DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState
|
DISPENSE_ERROR_TIMEOUT: 30000, // out-of-cash: 30s like brain.js _timedState
|
||||||
|
// Fault screen: the customer has paid and is owed money; give them time
|
||||||
|
// to photograph/write down the reference (ADR-005 §4).
|
||||||
|
DISPENSE_FAULT_TIMEOUT: 120000,
|
||||||
// NOTE: idle inactivity re-lock + hard session cap are enforced at the
|
// NOTE: idle inactivity re-lock + hard session cap are enforced at the
|
||||||
// DOM layer (useSessionSecurity), not as XState `after` delays — see the
|
// DOM layer (useSessionSecurity), not as XState `after` delays — see the
|
||||||
// idle state comment. No IDLE_LOCK_TIMEOUT delay here by design.
|
// idle state comment. No IDLE_LOCK_TIMEOUT delay here by design.
|
||||||
|
|
@ -513,6 +567,11 @@ export function createATMMachine(
|
||||||
cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction,
|
cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
// ADR-005 §5 — the store restores a persisted hold on boot and
|
||||||
|
// releases it when an operator op lands. Root-level so it applies in
|
||||||
|
// any state; it only gates entry to cashOut, never an in-flight sale.
|
||||||
|
CASH_OUT_HELD: { actions: 'setCashOutHeld' },
|
||||||
|
CASH_OUT_RELEASED: { actions: 'clearCashOutHeld' },
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
// === ACCESS GATE (ADR-003) ===
|
// === ACCESS GATE (ADR-003) ===
|
||||||
|
|
@ -557,6 +616,7 @@ export function createATMMachine(
|
||||||
actions: ['setStartTime', 'setCashInFee'],
|
actions: ['setStartTime', 'setCashInFee'],
|
||||||
},
|
},
|
||||||
SELECT_CASH_OUT: {
|
SELECT_CASH_OUT: {
|
||||||
|
guard: 'cashOutAvailable',
|
||||||
target: 'cashOut',
|
target: 'cashOut',
|
||||||
actions: ['setStartTime', 'setCashOutFee'],
|
actions: ['setStartTime', 'setCashOutFee'],
|
||||||
},
|
},
|
||||||
|
|
@ -861,13 +921,12 @@ export function createATMMachine(
|
||||||
},
|
},
|
||||||
dispensingCash: {
|
dispensingCash: {
|
||||||
// Safety timeout: if dispenseCash promise hangs (hardware jam,
|
// Safety timeout: if dispenseCash promise hangs (hardware jam,
|
||||||
// serial port freeze), don't stay here forever.
|
// serial port freeze), don't stay here forever. A hang is a
|
||||||
|
// terminal fault — the transport state is unknown.
|
||||||
after: {
|
after: {
|
||||||
DISPENSE_TIMEOUT: {
|
DISPENSE_TIMEOUT: {
|
||||||
target: 'dispenseError',
|
target: 'dispenseFault',
|
||||||
actions: assign({
|
actions: ['setDispenseTimeoutError', 'latchCashOutIfTerminal'],
|
||||||
error: () => 'Dispense timed out — hardware may be jammed',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
invoke: {
|
invoke: {
|
||||||
|
|
@ -875,21 +934,31 @@ export function createATMMachine(
|
||||||
input: ({ context }) => context.dispenseAmounts,
|
input: ({ context }) => context.dispenseAmounts,
|
||||||
onDone: [
|
onDone: [
|
||||||
{
|
{
|
||||||
guard: ({ event }) =>
|
// ADR-005 §3: value equality, nothing else, completes.
|
||||||
(event.output as unknown as DispenseCashResult | undefined)?.dispensed === true,
|
guard: 'dispenseConfirmed',
|
||||||
target: 'waitingForCashTaken',
|
target: 'waitingForCashTaken',
|
||||||
actions: ['setCashDispensed', 'setDispenseResult'],
|
actions: ['setCashDispensed', 'setDispenseResult'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Partial or failed dispense
|
// ADR-005 §4: a hardware error means the customer has paid
|
||||||
target: 'dispenseError',
|
// and is owed — the fault screen, with evidence. A terminal
|
||||||
|
// class also latches cash-out off (§5).
|
||||||
|
guard: 'dispenseHadFault',
|
||||||
|
target: 'dispenseFault',
|
||||||
|
actions: ['setDispenseResult', 'latchCashOutIfTerminal'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Shortfall with no hardware error, or an inventory refusal
|
||||||
|
// before anything was asked of the dispenser.
|
||||||
|
target: 'outOfCash',
|
||||||
actions: 'setDispenseResult',
|
actions: 'setDispenseResult',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onError: {
|
onError: {
|
||||||
// Unexpected crash (not a dispense failure)
|
// The service threw (not a reported dispense failure). No
|
||||||
target: 'dispenseError',
|
// per-bay report exists — the store flags counts unverified.
|
||||||
actions: 'setError',
|
target: 'dispenseFault',
|
||||||
|
actions: ['setError', 'latchCashOutIfTerminal'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -930,9 +999,26 @@ export function createATMMachine(
|
||||||
CANCEL: '#atm.locked',
|
CANCEL: '#atm.locked',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dispenseError: {
|
// ADR-005 §4 — two terminal states replace the old dispenseError.
|
||||||
// Payment received but cash not (fully) dispensed.
|
//
|
||||||
// Show error + txid for 30s, then auto-idle (matches brain.js _timedState).
|
// dispenseFault: the dispenser reported an error. The customer HAS
|
||||||
|
// PAID and is owed the shortfall. The screen carries the txid, the
|
||||||
|
// payment hash, the amounts and a statement that the operator has
|
||||||
|
// been notified — this is lamassu's fiatTransactionError ("the
|
||||||
|
// right prompt when they have paid and are owed money"), not the
|
||||||
|
// out-of-cash screen a jam used to show.
|
||||||
|
dispenseFault: {
|
||||||
|
after: {
|
||||||
|
DISPENSE_FAULT_TIMEOUT: '#atm.locked',
|
||||||
|
},
|
||||||
|
on: {
|
||||||
|
ACKNOWLEDGE_FAULT: '#atm.locked',
|
||||||
|
CANCEL: '#atm.locked',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// outOfCash: the request could not be met and the dispenser
|
||||||
|
// reported NO error — nothing was charged beyond what was dispensed.
|
||||||
|
outOfCash: {
|
||||||
after: {
|
after: {
|
||||||
DISPENSE_ERROR_TIMEOUT: '#atm.locked',
|
DISPENSE_ERROR_TIMEOUT: '#atm.locked',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -21,18 +21,48 @@ export interface CassetteBillResult {
|
||||||
rejected: number
|
rejected: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of a dispense operation (always resolves, never throws) */
|
/** How a dispense error should be routed — see @bitSpire/hal dispensers/error-codes.ts */
|
||||||
|
export type DispenseErrorClass = 'terminal' | 'recoverable' | 'inventory'
|
||||||
|
|
||||||
|
/** Result of a dispense operation (always resolves, never throws) — ADR-005 §3 */
|
||||||
export interface DispenseCashResult {
|
export interface DispenseCashResult {
|
||||||
/** Per-denomination results (what was actually dispensed) */
|
/** Per-denomination results (what was actually dispensed) */
|
||||||
bills: { denomination: number; dispensed: number; rejected: number }[]
|
bills: { denomination: number; dispensed: number; rejected: number }[]
|
||||||
/** Whether the full requested amount was dispensed */
|
/**
|
||||||
dispensed: boolean
|
* Σ(denomination × dispensed) equals the requested fiat value. Computed by
|
||||||
/** Error message if dispense failed or was partial */
|
* the HAL on VALUE, never taken from a driver boolean. This is lamassu's
|
||||||
|
* `dispenseConfirmed` and it is the only thing that routes to `complete`.
|
||||||
|
*/
|
||||||
|
dispenseConfirmed: boolean
|
||||||
|
/** Human-readable message if dispense failed or was partial */
|
||||||
error?: string
|
error?: string
|
||||||
|
/** The error's NAME, machine-readable — e.g. 'F56DispenseError' */
|
||||||
|
errorCode?: string
|
||||||
|
/** Driver-native code for the decode table — e.g. '78 42' */
|
||||||
|
rawCode?: string
|
||||||
|
/**
|
||||||
|
* terminal → dispenseFault + latch cash-out; recoverable → dispenseFault;
|
||||||
|
* inventory → outOfCash (nothing was asked of the hardware).
|
||||||
|
*/
|
||||||
|
errorClass?: DispenseErrorClass
|
||||||
/** Per-cassette detail (position-aware, produced by HAL) */
|
/** Per-cassette detail (position-aware, produced by HAL) */
|
||||||
cassettes?: CassetteBillResult[]
|
cassettes?: CassetteBillResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cash-out is latched off after a terminal dispenser fault (ADR-005 §5).
|
||||||
|
* Set by the machine when a fault lands; persisted and restored by the
|
||||||
|
* store; cleared only by an operator `recount` or `resume_cash_out` op —
|
||||||
|
* never by re-initialising the dispenser, which does not move a stuck note.
|
||||||
|
*/
|
||||||
|
export interface CashOutHold {
|
||||||
|
reason: string
|
||||||
|
errorCode: string | null
|
||||||
|
rawCode: string | null
|
||||||
|
/** unix seconds */
|
||||||
|
since: number
|
||||||
|
}
|
||||||
|
|
||||||
/** Payment methods supported */
|
/** Payment methods supported */
|
||||||
export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu'
|
export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu'
|
||||||
|
|
||||||
|
|
@ -122,6 +152,12 @@ export interface ATMContext {
|
||||||
paymentStatus: PaymentStatus
|
paymentStatus: PaymentStatus
|
||||||
/** Payment preimage (proof of payment) */
|
/** Payment preimage (proof of payment) */
|
||||||
preimage: string | null
|
preimage: string | null
|
||||||
|
/**
|
||||||
|
* Payment hash of the settled invoice — the join key to the LNbits payment
|
||||||
|
* the operator sees. Shown on the dispense-fault screen (ADR-005 §4) so a
|
||||||
|
* customer who is owed money leaves with the reference the server indexes.
|
||||||
|
*/
|
||||||
|
paymentHash: string | null
|
||||||
/** Payment method used */
|
/** Payment method used */
|
||||||
paymentMethod: PaymentMethod | null
|
paymentMethod: PaymentMethod | null
|
||||||
/** Pending offer request (Kind 21001 from user's wallet) */
|
/** Pending offer request (Kind 21001 from user's wallet) */
|
||||||
|
|
@ -157,6 +193,8 @@ export interface ATMContext {
|
||||||
retryCount: number
|
retryCount: number
|
||||||
/** Result from the last dispense operation */
|
/** Result from the last dispense operation */
|
||||||
dispenseResult: DispenseCashResult | null
|
dispenseResult: DispenseCashResult | null
|
||||||
|
/** Cash-out latched off after a terminal fault; null = available (ADR-005 §5) */
|
||||||
|
cashOutHeld: CashOutHold | null
|
||||||
|
|
||||||
// Transaction metadata
|
// Transaction metadata
|
||||||
/** Unique transaction ID */
|
/** Unique transaction ID */
|
||||||
|
|
@ -199,8 +237,14 @@ export type ATMEvent =
|
||||||
| { type: 'BILL_REJECTED'; reason: string }
|
| { type: 'BILL_REJECTED'; reason: string }
|
||||||
| { type: 'CASH_DISPENSED' }
|
| { type: 'CASH_DISPENSED' }
|
||||||
| { type: 'DISPENSE_ERROR'; error: string }
|
| { type: 'DISPENSE_ERROR'; error: string }
|
||||||
|
// Customer acknowledges the fault screen ("I've saved this reference")
|
||||||
|
| { type: 'ACKNOWLEDGE_FAULT' }
|
||||||
|
// Cash-out latch (ADR-005 §5): the store restores a persisted hold on boot
|
||||||
|
// and releases it when an operator recount / resume_cash_out op lands.
|
||||||
|
| { type: 'CASH_OUT_HELD'; hold: CashOutHold }
|
||||||
|
| { type: 'CASH_OUT_RELEASED' }
|
||||||
// Payment events
|
// Payment events
|
||||||
| { type: 'PAYMENT_RECEIVED'; preimage: string }
|
| { type: 'PAYMENT_RECEIVED'; preimage: string; paymentHash?: string }
|
||||||
| { type: 'PAYMENT_FAILED'; error: string }
|
| { type: 'PAYMENT_FAILED'; error: string }
|
||||||
| { type: 'INVOICE_GENERATED'; invoice: string }
|
| { type: 'INVOICE_GENERATED'; invoice: string }
|
||||||
| { type: 'OFFER_GENERATED'; offer: string }
|
| { type: 'OFFER_GENERATED'; offer: string }
|
||||||
|
|
@ -245,6 +289,7 @@ export const initialContext: ATMContext = {
|
||||||
lnurlWithdraw: null,
|
lnurlWithdraw: null,
|
||||||
paymentStatus: null,
|
paymentStatus: null,
|
||||||
preimage: null,
|
preimage: null,
|
||||||
|
paymentHash: null,
|
||||||
paymentMethod: null,
|
paymentMethod: null,
|
||||||
pendingOfferRequest: null,
|
pendingOfferRequest: null,
|
||||||
billsInserted: [],
|
billsInserted: [],
|
||||||
|
|
@ -258,6 +303,7 @@ export const initialContext: ATMContext = {
|
||||||
error: null,
|
error: null,
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
dispenseResult: null,
|
dispenseResult: null,
|
||||||
|
cashOutHeld: null,
|
||||||
txid: null,
|
txid: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
cashInSessionId: null,
|
cashInSessionId: null,
|
||||||
|
|
@ -306,7 +352,10 @@ export interface ATMServices {
|
||||||
* Watch an invoice for payment (polling-based)
|
* Watch an invoice for payment (polling-based)
|
||||||
* Calls the callback when paid, returns cleanup function
|
* Calls the callback when paid, returns cleanup function
|
||||||
*/
|
*/
|
||||||
watchInvoice: (paymentHash: string, callback: (preimage: string) => void) => () => void
|
watchInvoice: (
|
||||||
|
invoice: string,
|
||||||
|
callback: (preimage: string, paymentHash?: string) => void
|
||||||
|
) => () => void
|
||||||
/**
|
/**
|
||||||
* Get available inventory: denomination -> count
|
* Get available inventory: denomination -> count
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue