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