feat(state-machine): dispenseConfirmed on value, dispenseFault vs outOfCash, cash-out latch (ADR-005 §3–§5)

DispenseCashResult.dispensed (a driver boolean) is replaced by
dispenseConfirmed — Σ(denomination × dispensed) equals the requested
value, computed by the HAL — plus errorCode / rawCode / errorClass.
dispensingCash.onDone guards on dispenseConfirmed and nothing else.

The single dispenseError state becomes two. dispenseFault: the dispenser
reported an error, the customer has paid and is owed — 120 s screen with
evidence, ACKNOWLEDGE_FAULT to dismiss. outOfCash: a shortfall with no
hardware error or an inventory refusal — 30 s. A hung dispense is a
terminal fault.

A terminal errorClass latches cash-out off: context.cashOutHeld, set by
latchCashOutIfTerminal, preserved across resetContext (it is machine
health, not transaction state), guarding idle's SELECT_CASH_OUT. Cash-in
is unaffected. Only CASH_OUT_RELEASED clears it — the store sends that
when an operator recount or resume_cash_out op lands; re-initialising
the dispenser never does, because re-init does not move a stuck note.
CASH_OUT_HELD lets the store restore a persisted hold on boot.

PAYMENT_RECEIVED now carries the payment hash into context.paymentHash
so the fault screen can show the reference the server indexes.

Tests: the dispense section is rewritten around outcomes — value
confirmation, fault vs out-of-cash routing, terminal latch + release,
recoverable does not latch, partial-with-error is a fault, inventory
refusal is out-of-cash, boot-restored hold gates, 30 s vs 120 s timers,
acknowledge/cancel, timeout latches. 46/46.
This commit is contained in:
Padreug 2026-10-10 21:25:51 +02:00
commit 3ff86de4ed
3 changed files with 347 additions and 115 deletions

View file

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

View file

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

View file

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