feat(machine): value-confirmed dispense, cash-out hold, fault screens, counts-uncertain on zero-with-error (ADR-005 §3–§5)
HAL glue (electron/hal-service.ts and the renderer-side services/hal.ts): dispenseConfirmed is Σ(denomination × dispensed) === Σ(denomination × requested), computed on value. The driver's tagged error is carried through as errorCode / rawCode / errorClass / human; pre-dispense inventory refusals are errorClass 'inventory' so they route to outOfCash rather than the fault screen. The manual-dispense command result keeps its wire key `dispensed` (spirekeeper's poller reads it) and gains the new fields alongside. Cash-out hold: state-store persists it in meta as one JSON value beside countsUncertainSince, idempotent on set (the first fault's `since` is kept); IPC get/set/clear through preload. The store persists the hold the moment the machine sets it and restores it into the machine on boot. A recount clears it in the store (same gesture that clears counts- uncertain); operator-config also honours a new resume_cash_out op — not a cassette op, split off before applyOperatorCassetteOps, and honoured only when stamped after the hold began so a re-delivered old resume cannot clear a fresh fault. Either release calls back into the store, which sends CASH_OUT_RELEASED. The cassettes-state document carries cash_out_held_since / _reason / _code (additive, like counts_uncertain_since); the availability beacon reports cash_out false while held; the idle Sell button is disabled with the reason. Store watcher: dispenseFault and outOfCash both record dispense_error / partial (the customer has paid either way). A report of zero dispensed WITH a hardware error now sets countsUncertainSince instead of being trusted as zero — a note stopped in the transport completes neither counter (sintra 2026-10-09: bay read 66, held 65, one in the transport). Fault screen: both terminal states show "your payment went through", amount paid, per-denomination dispensed, the txid as QR and text, the payment hash (threaded from the settlement watch through PAYMENT_RECEIVED) and the time, with "keep this reference" and an acknowledge button. The raw dispenser code is not shown; it travels in the report.
This commit is contained in:
parent
44c65c66c2
commit
0dd48432f6
12 changed files with 469 additions and 72 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,10 @@ import {
|
||||||
getCountsUncertainSince,
|
getCountsUncertainSince,
|
||||||
getLastStatePublishedAt,
|
getLastStatePublishedAt,
|
||||||
markCountsUncertain,
|
markCountsUncertain,
|
||||||
|
getCashOutHold,
|
||||||
|
setCashOutHold,
|
||||||
|
clearCashOutHold,
|
||||||
|
type CashOutHold,
|
||||||
markStatePublished,
|
markStatePublished,
|
||||||
resetStatePublishWatermark,
|
resetStatePublishWatermark,
|
||||||
resetForRepair,
|
resetForRepair,
|
||||||
|
|
@ -565,6 +569,15 @@ ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCount
|
||||||
ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => {
|
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())
|
||||||
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 +854,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 +873,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 +881,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,14 @@
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 +122,11 @@ 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'),
|
||||||
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 +320,9 @@ 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>
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -517,6 +517,69 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 +914,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(
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -742,7 +742,7 @@ export function createATMServices(
|
||||||
subId: string | null
|
subId: string | null
|
||||||
/** Preimage seen before a consumer attached; replayed on attach. */
|
/** Preimage seen before a consumer attached; replayed on attach. */
|
||||||
settled: string | null
|
settled: string | null
|
||||||
consumer: ((preimage: string) => 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 +765,7 @@ export function createATMServices(
|
||||||
watch.settled = preimage
|
watch.settled = preimage
|
||||||
stopInvoiceWatchPoll(watch)
|
stopInvoiceWatchPoll(watch)
|
||||||
console.log(`[ATM Service] Invoice paid (${via})!`)
|
console.log(`[ATM Service] Invoice paid (${via})!`)
|
||||||
watch.consumer?.(preimage)
|
watch.consumer?.(preimage, watch.paymentHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startInvoiceWatchPoll(watch: InvoiceWatch): void {
|
function startInvoiceWatchPoll(watch: InvoiceWatch): void {
|
||||||
|
|
@ -1106,7 +1106,7 @@ export function createATMServices(
|
||||||
dispensed: a.count,
|
dispensed: a.count,
|
||||||
rejected: 0,
|
rejected: 0,
|
||||||
})),
|
})),
|
||||||
dispensed: true,
|
dispenseConfirmed: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1206,7 +1206,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 +1224,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 +1247,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
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,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 +141,7 @@ async function handleManagementCommand(
|
||||||
|
|
||||||
// Only remediate the original tx if ALL requested bills were dispensed
|
// Only remediate the original tx if ALL requested bills were dispensed
|
||||||
let refRemediated = false
|
let refRemediated = false
|
||||||
if (request.ref_txid && result.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)
|
||||||
|
|
@ -254,7 +254,7 @@ const mockServices: ATMServices = {
|
||||||
dispensed: a.count,
|
dispensed: a.count,
|
||||||
rejected: 0,
|
rejected: 0,
|
||||||
})),
|
})),
|
||||||
dispensed: true,
|
dispenseConfirmed: true,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -618,13 +618,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 +652,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
|
||||||
|
|
@ -664,7 +699,8 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
error: dr?.error ?? ctx.error,
|
error: dr?.error ?? ctx.error,
|
||||||
})
|
})
|
||||||
.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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -742,6 +778,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) ───────────────────────────────────
|
||||||
|
|
@ -1135,6 +1188,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
|
||||||
|
|
@ -1461,6 +1515,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)
|
||||||
|
|
@ -1797,6 +1852,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 +1955,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' })
|
||||||
|
|
@ -2004,6 +2065,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 +2132,7 @@ export const useAtmStore = defineStore('atm', () => {
|
||||||
send,
|
send,
|
||||||
selectCashIn,
|
selectCashIn,
|
||||||
selectCashOut,
|
selectCashOut,
|
||||||
|
acknowledgeFault,
|
||||||
cancel,
|
cancel,
|
||||||
insertBill,
|
insertBill,
|
||||||
finishInserting,
|
finishInserting,
|
||||||
|
|
|
||||||
14
apps/machine/src/types/electron.d.ts
vendored
14
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -150,6 +150,20 @@ 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>
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue