ADR-005 rollout step 1: value-confirmed dispense, fault screens, cash-out latch, dispense-report outbox #123

Merged
padreug merged 6 commits from feat/dispense-outcome into dev 2026-10-10 19:54:56 +00:00
12 changed files with 469 additions and 72 deletions
Showing only changes of commit 888870d01a - Show all commits

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.
Padreug 2026-10-10 21:25:51 +02:00

View file

@ -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 }
}, },
/** /**

View file

@ -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,
}) })

View file

@ -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>

View file

@ -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(

View file

@ -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()
}, },

View file

@ -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 () => {

View file

@ -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)
} }

View file

@ -44,7 +44,7 @@ const KIND_NIP78 = 30078
/** The wire schema this machine speaks. Operations, not counts (ADR-004). */ /** The wire schema this machine speaks. Operations, not counts (ADR-004). */
const CASSETTE_SCHEMA_VERSION = 2 const CASSETTE_SCHEMA_VERSION = 2
/** One operator-authored 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

View file

@ -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,

View file

@ -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>

View file

@ -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>

View file

@ -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>