diff --git a/packages/state-machine/src/__tests__/machine.test.ts b/packages/state-machine/src/__tests__/machine.test.ts index 9b9bf91..e56b2b7 100644 --- a/packages/state-machine/src/__tests__/machine.test.ts +++ b/packages/state-machine/src/__tests__/machine.test.ts @@ -12,7 +12,7 @@ describe('ATM State Machine', () => { sendNostrReceipt: vi.fn().mockResolvedValue(undefined), dispenseCash: vi.fn().mockResolvedValue({ bills: [{ denomination: 20, dispensed: 1, rejected: 0 }], - dispensed: true, + dispenseConfirmed: true, } satisfies DispenseCashResult), getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available @@ -397,127 +397,224 @@ describe('ATM State Machine', () => { }) }) - describe('dispense error handling', () => { - it('should route to waitingForCashTaken when dispenseCash returns dispensed: true', async () => { - const machine = createATMMachine(mockServices) - const actor = createActor(machine) + describe('dispense outcome (ADR-005 §3–§5)', () => { + /** Drive a 1 × $20 cash-out to the dispense and return the actor. */ + async function dispenseWith(result: DispenseCashResult, fake = false) { + const services: ATMServices = { ...mockServices, dispenseCash: vi.fn().mockResolvedValue(result) } + const actor = createActor(createATMMachine(services)) actor.start() - + const tick = async (ms: number) => + fake ? vi.advanceTimersByTimeAsync(ms) : new Promise((r) => setTimeout(r, ms)) actor.send({ type: 'SELECT_CASH_OUT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - + await tick(100) actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) actor.send({ type: 'CONFIRM_AMOUNT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - + await tick(100) actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) - await new Promise((resolve) => setTimeout(resolve, 100)) + await tick(100) + return actor + } + it('completes only on dispenseConfirmed (value equality), never on a driver boolean', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 1, rejected: 0 }], + dispenseConfirmed: true, + }) const state = actor.getSnapshot() - // dispenseCash mock returns { dispensed: true }, so should go to waitingForCashTaken expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' }) expect(state.context.cashDispensed).toBe(true) - expect(state.context.dispenseResult?.dispensed).toBe(true) + expect(state.context.dispenseResult?.dispenseConfirmed).toBe(true) + expect(state.context.cashOutHeld).toBeNull() }) - it('should route to dispenseError when dispenseCash returns dispensed: false', async () => { - const failDispenseServices: ATMServices = { - ...mockServices, - dispenseCash: vi.fn().mockResolvedValue({ - bills: [{ denomination: 20, dispensed: 0, rejected: 1 }], - dispensed: false, - error: 'Cassette jam', - } satisfies DispenseCashResult), - } - - const machine = createATMMachine(failDispenseServices) - const actor = createActor(machine) - actor.start() - - actor.send({ type: 'SELECT_CASH_OUT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) - actor.send({ type: 'CONFIRM_AMOUNT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - + it('routes a hardware error to dispenseFault — the customer has paid and is owed', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'Note stopped at the cassette exit', + errorCode: 'F56DispenseError', + rawCode: '78 42', + errorClass: 'terminal', + }) const state = actor.getSnapshot() - expect(state.value).toMatchObject({ cashOut: 'dispenseError' }) + expect(state.value).toMatchObject({ cashOut: 'dispenseFault' }) expect(state.context.cashDispensed).toBe(false) - expect(state.context.dispenseResult?.dispensed).toBe(false) - expect(state.context.dispenseResult?.error).toBe('Cassette jam') - expect(state.context.error).toBe('Cassette jam') + expect(state.context.error).toBe('Note stopped at the cassette exit') + expect(state.context.dispenseResult?.rawCode).toBe('78 42') }) - it('should auto-idle after 30s in dispenseError state', async () => { - vi.useFakeTimers() - - const failDispenseServices: ATMServices = { - ...mockServices, - dispenseCash: vi.fn().mockResolvedValue({ - bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], - dispensed: false, - error: 'Out of cash', - } satisfies DispenseCashResult), - } - - const machine = createATMMachine(failDispenseServices) - const actor = createActor(machine) - actor.start() - - actor.send({ type: 'SELECT_CASH_OUT' }) - await vi.advanceTimersByTimeAsync(100) - - actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) - actor.send({ type: 'CONFIRM_AMOUNT' }) - await vi.advanceTimersByTimeAsync(100) - - actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) - await vi.advanceTimersByTimeAsync(100) - - // Should be in dispenseError - expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseError' }) - - // Advance 30s - await vi.advanceTimersByTimeAsync(30000) - - // Should have auto-idled - expect(actor.getSnapshot().value).toBe('idle') - - vi.useRealTimers() - }) - - it('should allow CANCEL from dispenseError to go to idle immediately', async () => { - const failDispenseServices: ATMServices = { - ...mockServices, - dispenseCash: vi.fn().mockResolvedValue({ - bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], - dispensed: false, - error: 'Jam', - } satisfies DispenseCashResult), - } - - const machine = createATMMachine(failDispenseServices) - const actor = createActor(machine) - actor.start() - - actor.send({ type: 'SELECT_CASH_OUT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) - actor.send({ type: 'CONFIRM_AMOUNT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseError' }) + it('a terminal fault latches cash-out off; idle refuses SELECT_CASH_OUT until released', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'jam', + errorCode: 'F56DispenseError', + rawCode: '78 42', + errorClass: 'terminal', + }) + const hold = actor.getSnapshot().context.cashOutHeld + expect(hold).not.toBeNull() + expect(hold?.errorCode).toBe('F56DispenseError') + expect(hold?.rawCode).toBe('78 42') + expect(typeof hold?.since).toBe('number') actor.send({ type: 'CANCEL' }) expect(actor.getSnapshot().value).toBe('idle') + // The hold survives resetContext — it is machine health, not transaction state. + expect(actor.getSnapshot().context.cashOutHeld).not.toBeNull() + + actor.send({ type: 'SELECT_CASH_OUT' }) + expect(actor.getSnapshot().value).toBe('idle') + + // Only an operator op releases it (the store sends this on recount / resume_cash_out). + actor.send({ type: 'CASH_OUT_RELEASED' }) + expect(actor.getSnapshot().context.cashOutHeld).toBeNull() + actor.send({ type: 'SELECT_CASH_OUT' }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: expect.anything() }) + }) + + it('a hold gates cash-out only — cash-in is unaffected by a dispenser fault', () => { + const actor = createActor(createATMMachine(mockServices)) + actor.start() + actor.send({ + type: 'CASH_OUT_HELD', + hold: { reason: 'jam', errorCode: 'F56DispenseError', rawCode: '78 42', since: 1 }, + }) + actor.send({ type: 'SELECT_CASH_IN' }) + expect(actor.getSnapshot().value).toMatchObject({ cashIn: expect.anything() }) + }) + + it('a recoverable fault shows the fault screen but does NOT latch', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 1 }], + dispenseConfirmed: false, + error: 'Bill length check failed (long)', + errorCode: 'F56DispenseError', + rawCode: '82 00', + errorClass: 'recoverable', + }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' }) + expect(actor.getSnapshot().context.cashOutHeld).toBeNull() + }) + + it('a partial WITH an error is a fault (owed the shortfall), not out-of-cash', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 1, rejected: 1 }], + dispenseConfirmed: false, + error: 'jam after first note', + errorCode: 'F56DispenseError', + rawCode: '78 42', + errorClass: 'terminal', + }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' }) + }) + + it('an inventory refusal (nothing asked of the hardware) is outOfCash, and does not latch', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'Insufficient inventory for denomination 20: short 1', + errorCode: 'InsufficientInventory', + errorClass: 'inventory', + }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' }) + expect(actor.getSnapshot().context.cashOutHeld).toBeNull() + }) + + it('a shortfall with NO error is outOfCash', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' }) + }) + + it('a persisted hold restored on boot gates cash-out before any dispense', () => { + const actor = createActor(createATMMachine(mockServices)) + actor.start() + actor.send({ + type: 'CASH_OUT_HELD', + hold: { reason: 'jam', errorCode: 'F56DispenseError', rawCode: '78 42', since: 1791529353 }, + }) + actor.send({ type: 'SELECT_CASH_OUT' }) + expect(actor.getSnapshot().value).toBe('idle') + expect(actor.getSnapshot().context.cashOutHeld?.since).toBe(1791529353) + }) + + it('outOfCash auto-returns after 30s; dispenseFault gives the customer 120s', async () => { + vi.useFakeTimers() + try { + const ooc = await dispenseWith( + { bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], dispenseConfirmed: false }, + true + ) + expect(ooc.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' }) + await vi.advanceTimersByTimeAsync(30000) + expect(ooc.getSnapshot().value).toBe('idle') + + const fault = await dispenseWith( + { + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'jam', + errorCode: 'F56DispenseError', + errorClass: 'recoverable', + }, + true + ) + expect(fault.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' }) + await vi.advanceTimersByTimeAsync(30000) + expect(fault.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' }) + await vi.advanceTimersByTimeAsync(90000) + expect(fault.getSnapshot().value).toBe('idle') + } finally { + vi.useRealTimers() + } + }) + + it('the customer can acknowledge the fault screen or cancel; both return to idle', async () => { + const a = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'jam', + errorClass: 'recoverable', + }) + a.send({ type: 'ACKNOWLEDGE_FAULT' }) + expect(a.getSnapshot().value).toBe('idle') + + const b = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'jam', + errorClass: 'recoverable', + }) + b.send({ type: 'CANCEL' }) + expect(b.getSnapshot().value).toBe('idle') + }) + + it('a hung dispense (timeout) is a terminal fault and latches', async () => { + vi.useFakeTimers() + try { + const hang: ATMServices = { + ...mockServices, + dispenseCash: vi.fn().mockReturnValue(new Promise(() => {})), + } + const actor = createActor(createATMMachine(hang)) + actor.start() + actor.send({ type: 'SELECT_CASH_OUT' }) + await vi.advanceTimersByTimeAsync(100) + actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) + actor.send({ type: 'CONFIRM_AMOUNT' }) + await vi.advanceTimersByTimeAsync(100) + actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'p' }) + await vi.advanceTimersByTimeAsync(120000 + 10) + const s = actor.getSnapshot() + expect(s.value).toMatchObject({ cashOut: 'dispenseFault' }) + expect(s.context.dispenseResult?.errorCode).toBe('DispenseTimeout') + expect(s.context.cashOutHeld).not.toBeNull() + } finally { + vi.useRealTimers() + } }) }) diff --git a/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts index c5d4c1a..c64a87d 100644 --- a/packages/state-machine/src/machine.ts +++ b/packages/state-machine/src/machine.ts @@ -10,6 +10,7 @@ import { type ATMContext, type ATMEvent, type DispenseCashResult, + type CashOutHold, initialContext, type ATMServices, type OfferRequestEvent, @@ -148,8 +149,8 @@ export function createATMMachine( } // Extract payment hash from invoice (simplified - real impl would decode BOLT11) // The service handles the actual extraction - const cleanup = services.watchInvoice(input.invoice, (preimage: string) => { - sendBack({ type: 'PAYMENT_RECEIVED', preimage }) + const cleanup = services.watchInvoice(input.invoice, (preimage, paymentHash) => { + sendBack({ type: 'PAYMENT_RECEIVED', preimage, paymentHash }) }) return cleanup }), @@ -184,6 +185,9 @@ export function createATMMachine( // without this the just-granted session would be wiped. The session is // cleared instead on re-lock (locked's entry), i.e. when access ends. accessSession: context.accessSession, + // The cash-out latch is machine health, not transaction state — it + // survives every reset until an operator op releases it. + cashOutHeld: context.cashOutHeld, cashInSessionId: null, dispenseResult: null, })), @@ -315,6 +319,10 @@ export function createATMMachine( if (event.type !== 'PAYMENT_RECEIVED') return null return event.preimage }, + paymentHash: ({ event }) => { + if (event.type !== 'PAYMENT_RECEIVED') return null + return event.paymentHash ?? null + }, }), setPaymentFailed: assign({ paymentStatus: () => 'failed' as const, @@ -332,6 +340,37 @@ export function createATMMachine( return output?.error ?? null }, }), + // ADR-005 §5: a terminal fault latches cash-out off. Idempotent — an + // existing hold is kept (its `since` is the first fault, which is what + // the operator wants to know). + latchCashOutIfTerminal: assign({ + cashOutHeld: ({ context }) => { + if (context.cashOutHeld) return context.cashOutHeld + const dr = context.dispenseResult + if (dr?.errorClass !== 'terminal') return null + return { + reason: dr.error ?? 'terminal dispenser fault', + errorCode: dr.errorCode ?? null, + rawCode: dr.rawCode ?? null, + since: Math.floor(Date.now() / 1000), + } satisfies CashOutHold + }, + }), + setCashOutHeld: assign({ + cashOutHeld: ({ event }) => (event.type === 'CASH_OUT_HELD' ? event.hold : null), + }), + clearCashOutHeld: assign({ cashOutHeld: null }), + setDispenseTimeoutError: assign({ + error: () => 'Dispense timed out — hardware may be jammed', + dispenseResult: ({ context }) => + context.dispenseResult ?? { + bills: [], + dispenseConfirmed: false, + error: 'Dispense timed out — hardware may be jammed', + errorCode: 'DispenseTimeout', + errorClass: 'terminal' as const, + }, + }), setAmount: assign({ fiatCents: ({ event }) => { if (event.type !== 'SELECT_AMOUNT') return 0 @@ -458,6 +497,18 @@ export function createATMMachine( return principalSats - fee <= context.availableBalance }, // Cash-out guards + // ADR-005 §5: no cash-out while latched. The idle screen shows why. + cashOutAvailable: ({ context }) => context.cashOutHeld === null, + // ADR-005 §3/§4: only value equality completes a dispense. + dispenseConfirmed: ({ event }) => + (event as unknown as { output?: DispenseCashResult }).output?.dispenseConfirmed === true, + // A shortfall WITH a hardware error is a fault (customer paid, is owed); + // a shortfall with none, or an inventory refusal, is out-of-cash. + dispenseHadFault: ({ event }) => { + const out = (event as unknown as { output?: DispenseCashResult }).output + if (!out?.error) return false + return out.errorClass !== 'inventory' + }, hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0, canAddDenomination: ({ context, event }) => { if (event.type !== 'ADD_DENOMINATION') return false @@ -477,7 +528,10 @@ export function createATMMachine( INVOICE_TIMEOUT: 300000, // 5 minutes — waiting for payment COMPLETE_DELAY: 60000, DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond - DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState + DISPENSE_ERROR_TIMEOUT: 30000, // out-of-cash: 30s like brain.js _timedState + // Fault screen: the customer has paid and is owed money; give them time + // to photograph/write down the reference (ADR-005 §4). + DISPENSE_FAULT_TIMEOUT: 120000, // NOTE: idle inactivity re-lock + hard session cap are enforced at the // DOM layer (useSessionSecurity), not as XState `after` delays — see the // idle state comment. No IDLE_LOCK_TIMEOUT delay here by design. @@ -513,6 +567,11 @@ export function createATMMachine( cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction, }), }, + // ADR-005 §5 — the store restores a persisted hold on boot and + // releases it when an operator op lands. Root-level so it applies in + // any state; it only gates entry to cashOut, never an in-flight sale. + CASH_OUT_HELD: { actions: 'setCashOutHeld' }, + CASH_OUT_RELEASED: { actions: 'clearCashOutHeld' }, }, states: { // === ACCESS GATE (ADR-003) === @@ -557,6 +616,7 @@ export function createATMMachine( actions: ['setStartTime', 'setCashInFee'], }, SELECT_CASH_OUT: { + guard: 'cashOutAvailable', target: 'cashOut', actions: ['setStartTime', 'setCashOutFee'], }, @@ -861,13 +921,12 @@ export function createATMMachine( }, dispensingCash: { // Safety timeout: if dispenseCash promise hangs (hardware jam, - // serial port freeze), don't stay here forever. + // serial port freeze), don't stay here forever. A hang is a + // terminal fault — the transport state is unknown. after: { DISPENSE_TIMEOUT: { - target: 'dispenseError', - actions: assign({ - error: () => 'Dispense timed out — hardware may be jammed', - }), + target: 'dispenseFault', + actions: ['setDispenseTimeoutError', 'latchCashOutIfTerminal'], }, }, invoke: { @@ -875,21 +934,31 @@ export function createATMMachine( input: ({ context }) => context.dispenseAmounts, onDone: [ { - guard: ({ event }) => - (event.output as unknown as DispenseCashResult | undefined)?.dispensed === true, + // ADR-005 §3: value equality, nothing else, completes. + guard: 'dispenseConfirmed', target: 'waitingForCashTaken', actions: ['setCashDispensed', 'setDispenseResult'], }, { - // Partial or failed dispense - target: 'dispenseError', + // ADR-005 §4: a hardware error means the customer has paid + // and is owed — the fault screen, with evidence. A terminal + // class also latches cash-out off (§5). + guard: 'dispenseHadFault', + target: 'dispenseFault', + actions: ['setDispenseResult', 'latchCashOutIfTerminal'], + }, + { + // Shortfall with no hardware error, or an inventory refusal + // before anything was asked of the dispenser. + target: 'outOfCash', actions: 'setDispenseResult', }, ], onError: { - // Unexpected crash (not a dispense failure) - target: 'dispenseError', - actions: 'setError', + // The service threw (not a reported dispense failure). No + // per-bay report exists — the store flags counts unverified. + target: 'dispenseFault', + actions: ['setError', 'latchCashOutIfTerminal'], }, }, }, @@ -930,9 +999,26 @@ export function createATMMachine( CANCEL: '#atm.locked', }, }, - dispenseError: { - // Payment received but cash not (fully) dispensed. - // Show error + txid for 30s, then auto-idle (matches brain.js _timedState). + // ADR-005 §4 — two terminal states replace the old dispenseError. + // + // dispenseFault: the dispenser reported an error. The customer HAS + // PAID and is owed the shortfall. The screen carries the txid, the + // payment hash, the amounts and a statement that the operator has + // been notified — this is lamassu's fiatTransactionError ("the + // right prompt when they have paid and are owed money"), not the + // out-of-cash screen a jam used to show. + dispenseFault: { + after: { + DISPENSE_FAULT_TIMEOUT: '#atm.locked', + }, + on: { + ACKNOWLEDGE_FAULT: '#atm.locked', + CANCEL: '#atm.locked', + }, + }, + // outOfCash: the request could not be met and the dispenser + // reported NO error — nothing was charged beyond what was dispensed. + outOfCash: { after: { DISPENSE_ERROR_TIMEOUT: '#atm.locked', }, diff --git a/packages/state-machine/src/types.ts b/packages/state-machine/src/types.ts index fc8d4a7..6935a95 100644 --- a/packages/state-machine/src/types.ts +++ b/packages/state-machine/src/types.ts @@ -21,18 +21,48 @@ export interface CassetteBillResult { rejected: number } -/** Result of a dispense operation (always resolves, never throws) */ +/** How a dispense error should be routed — see @bitSpire/hal dispensers/error-codes.ts */ +export type DispenseErrorClass = 'terminal' | 'recoverable' | 'inventory' + +/** Result of a dispense operation (always resolves, never throws) — ADR-005 §3 */ export interface DispenseCashResult { /** Per-denomination results (what was actually dispensed) */ bills: { denomination: number; dispensed: number; rejected: number }[] - /** Whether the full requested amount was dispensed */ - dispensed: boolean - /** Error message if dispense failed or was partial */ + /** + * Σ(denomination × dispensed) equals the requested fiat value. Computed by + * the HAL on VALUE, never taken from a driver boolean. This is lamassu's + * `dispenseConfirmed` and it is the only thing that routes to `complete`. + */ + dispenseConfirmed: boolean + /** Human-readable message if dispense failed or was partial */ error?: string + /** The error's NAME, machine-readable — e.g. 'F56DispenseError' */ + errorCode?: string + /** Driver-native code for the decode table — e.g. '78 42' */ + rawCode?: string + /** + * terminal → dispenseFault + latch cash-out; recoverable → dispenseFault; + * inventory → outOfCash (nothing was asked of the hardware). + */ + errorClass?: DispenseErrorClass /** Per-cassette detail (position-aware, produced by HAL) */ cassettes?: CassetteBillResult[] } +/** + * Cash-out is latched off after a terminal dispenser fault (ADR-005 §5). + * Set by the machine when a fault lands; persisted and restored by the + * store; cleared only by an operator `recount` or `resume_cash_out` op — + * never by re-initialising the dispenser, which does not move a stuck note. + */ +export interface CashOutHold { + reason: string + errorCode: string | null + rawCode: string | null + /** unix seconds */ + since: number +} + /** Payment methods supported */ export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu' @@ -122,6 +152,12 @@ export interface ATMContext { paymentStatus: PaymentStatus /** Payment preimage (proof of payment) */ preimage: string | null + /** + * Payment hash of the settled invoice — the join key to the LNbits payment + * the operator sees. Shown on the dispense-fault screen (ADR-005 §4) so a + * customer who is owed money leaves with the reference the server indexes. + */ + paymentHash: string | null /** Payment method used */ paymentMethod: PaymentMethod | null /** Pending offer request (Kind 21001 from user's wallet) */ @@ -157,6 +193,8 @@ export interface ATMContext { retryCount: number /** Result from the last dispense operation */ dispenseResult: DispenseCashResult | null + /** Cash-out latched off after a terminal fault; null = available (ADR-005 §5) */ + cashOutHeld: CashOutHold | null // Transaction metadata /** Unique transaction ID */ @@ -199,8 +237,14 @@ export type ATMEvent = | { type: 'BILL_REJECTED'; reason: string } | { type: 'CASH_DISPENSED' } | { type: 'DISPENSE_ERROR'; error: string } + // Customer acknowledges the fault screen ("I've saved this reference") + | { type: 'ACKNOWLEDGE_FAULT' } + // Cash-out latch (ADR-005 §5): the store restores a persisted hold on boot + // and releases it when an operator recount / resume_cash_out op lands. + | { type: 'CASH_OUT_HELD'; hold: CashOutHold } + | { type: 'CASH_OUT_RELEASED' } // Payment events - | { type: 'PAYMENT_RECEIVED'; preimage: string } + | { type: 'PAYMENT_RECEIVED'; preimage: string; paymentHash?: string } | { type: 'PAYMENT_FAILED'; error: string } | { type: 'INVOICE_GENERATED'; invoice: string } | { type: 'OFFER_GENERATED'; offer: string } @@ -245,6 +289,7 @@ export const initialContext: ATMContext = { lnurlWithdraw: null, paymentStatus: null, preimage: null, + paymentHash: null, paymentMethod: null, pendingOfferRequest: null, billsInserted: [], @@ -258,6 +303,7 @@ export const initialContext: ATMContext = { error: null, retryCount: 0, dispenseResult: null, + cashOutHeld: null, txid: null, startedAt: null, cashInSessionId: null, @@ -306,7 +352,10 @@ export interface ATMServices { * Watch an invoice for payment (polling-based) * Calls the callback when paid, returns cleanup function */ - watchInvoice: (paymentHash: string, callback: (preimage: string) => void) => () => void + watchInvoice: ( + invoice: string, + callback: (preimage: string, paymentHash?: string) => void + ) => () => void /** * Get available inventory: denomination -> count */