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
3 changed files with 347 additions and 115 deletions
Showing only changes of commit 3ff86de4ed - Show all commits

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

View file

@ -12,7 +12,7 @@ describe('ATM State Machine', () => {
sendNostrReceipt: vi.fn().mockResolvedValue(undefined), sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
dispenseCash: vi.fn().mockResolvedValue({ dispenseCash: vi.fn().mockResolvedValue({
bills: [{ denomination: 20, dispensed: 1, rejected: 0 }], bills: [{ denomination: 20, dispensed: 1, rejected: 0 }],
dispensed: true, dispenseConfirmed: true,
} satisfies DispenseCashResult), } satisfies DispenseCashResult),
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available
@ -397,127 +397,224 @@ describe('ATM State Machine', () => {
}) })
}) })
describe('dispense error handling', () => { describe('dispense outcome (ADR-005 §3–§5)', () => {
it('should route to waitingForCashTaken when dispenseCash returns dispensed: true', async () => { /** Drive a 1 × $20 cash-out to the dispense and return the actor. */
const machine = createATMMachine(mockServices) async function dispenseWith(result: DispenseCashResult, fake = false) {
const actor = createActor(machine) const services: ATMServices = { ...mockServices, dispenseCash: vi.fn().mockResolvedValue(result) }
const actor = createActor(createATMMachine(services))
actor.start() actor.start()
const tick = async (ms: number) =>
fake ? vi.advanceTimersByTimeAsync(ms) : new Promise((r) => setTimeout(r, ms))
actor.send({ type: 'SELECT_CASH_OUT' }) actor.send({ type: 'SELECT_CASH_OUT' })
await new Promise((resolve) => setTimeout(resolve, 100)) await tick(100)
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'CONFIRM_AMOUNT' }) actor.send({ type: 'CONFIRM_AMOUNT' })
await new Promise((resolve) => setTimeout(resolve, 100)) await tick(100)
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
await new Promise((resolve) => setTimeout(resolve, 100)) await tick(100)
return actor
}
it('completes only on dispenseConfirmed (value equality), never on a driver boolean', async () => {
const actor = await dispenseWith({
bills: [{ denomination: 20, dispensed: 1, rejected: 0 }],
dispenseConfirmed: true,
})
const state = actor.getSnapshot() const state = actor.getSnapshot()
// dispenseCash mock returns { dispensed: true }, so should go to waitingForCashTaken
expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' }) expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' })
expect(state.context.cashDispensed).toBe(true) expect(state.context.cashDispensed).toBe(true)
expect(state.context.dispenseResult?.dispensed).toBe(true) expect(state.context.dispenseResult?.dispenseConfirmed).toBe(true)
expect(state.context.cashOutHeld).toBeNull()
}) })
it('should route to dispenseError when dispenseCash returns dispensed: false', async () => { it('routes a hardware error to dispenseFault — the customer has paid and is owed', async () => {
const failDispenseServices: ATMServices = { const actor = await dispenseWith({
...mockServices, bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseCash: vi.fn().mockResolvedValue({ dispenseConfirmed: false,
bills: [{ denomination: 20, dispensed: 0, rejected: 1 }], error: 'Note stopped at the cassette exit',
dispensed: false, errorCode: 'F56DispenseError',
error: 'Cassette jam', rawCode: '78 42',
} satisfies DispenseCashResult), errorClass: 'terminal',
} })
const machine = createATMMachine(failDispenseServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
await new Promise((resolve) => setTimeout(resolve, 100))
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'CONFIRM_AMOUNT' })
await new Promise((resolve) => setTimeout(resolve, 100))
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
await new Promise((resolve) => setTimeout(resolve, 100))
const state = actor.getSnapshot() const state = actor.getSnapshot()
expect(state.value).toMatchObject({ cashOut: 'dispenseError' }) expect(state.value).toMatchObject({ cashOut: 'dispenseFault' })
expect(state.context.cashDispensed).toBe(false) expect(state.context.cashDispensed).toBe(false)
expect(state.context.dispenseResult?.dispensed).toBe(false) expect(state.context.error).toBe('Note stopped at the cassette exit')
expect(state.context.dispenseResult?.error).toBe('Cassette jam') expect(state.context.dispenseResult?.rawCode).toBe('78 42')
expect(state.context.error).toBe('Cassette jam')
}) })
it('should auto-idle after 30s in dispenseError state', async () => { it('a terminal fault latches cash-out off; idle refuses SELECT_CASH_OUT until released', async () => {
vi.useFakeTimers() const actor = await dispenseWith({
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
const failDispenseServices: ATMServices = { dispenseConfirmed: false,
...mockServices, error: 'jam',
dispenseCash: vi.fn().mockResolvedValue({ errorCode: 'F56DispenseError',
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], rawCode: '78 42',
dispensed: false, errorClass: 'terminal',
error: 'Out of cash', })
} satisfies DispenseCashResult), const hold = actor.getSnapshot().context.cashOutHeld
} expect(hold).not.toBeNull()
expect(hold?.errorCode).toBe('F56DispenseError')
const machine = createATMMachine(failDispenseServices) expect(hold?.rawCode).toBe('78 42')
const actor = createActor(machine) expect(typeof hold?.since).toBe('number')
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' })
actor.send({ type: 'CANCEL' }) actor.send({ type: 'CANCEL' })
expect(actor.getSnapshot().value).toBe('idle') expect(actor.getSnapshot().value).toBe('idle')
// The hold survives resetContext — it is machine health, not transaction state.
expect(actor.getSnapshot().context.cashOutHeld).not.toBeNull()
actor.send({ type: 'SELECT_CASH_OUT' })
expect(actor.getSnapshot().value).toBe('idle')
// Only an operator op releases it (the store sends this on recount / resume_cash_out).
actor.send({ type: 'CASH_OUT_RELEASED' })
expect(actor.getSnapshot().context.cashOutHeld).toBeNull()
actor.send({ type: 'SELECT_CASH_OUT' })
expect(actor.getSnapshot().value).toMatchObject({ cashOut: expect.anything() })
})
it('a hold gates cash-out only — cash-in is unaffected by a dispenser fault', () => {
const actor = createActor(createATMMachine(mockServices))
actor.start()
actor.send({
type: 'CASH_OUT_HELD',
hold: { reason: 'jam', errorCode: 'F56DispenseError', rawCode: '78 42', since: 1 },
})
actor.send({ type: 'SELECT_CASH_IN' })
expect(actor.getSnapshot().value).toMatchObject({ cashIn: expect.anything() })
})
it('a recoverable fault shows the fault screen but does NOT latch', async () => {
const actor = await dispenseWith({
bills: [{ denomination: 20, dispensed: 0, rejected: 1 }],
dispenseConfirmed: false,
error: 'Bill length check failed (long)',
errorCode: 'F56DispenseError',
rawCode: '82 00',
errorClass: 'recoverable',
})
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
expect(actor.getSnapshot().context.cashOutHeld).toBeNull()
})
it('a partial WITH an error is a fault (owed the shortfall), not out-of-cash', async () => {
const actor = await dispenseWith({
bills: [{ denomination: 20, dispensed: 1, rejected: 1 }],
dispenseConfirmed: false,
error: 'jam after first note',
errorCode: 'F56DispenseError',
rawCode: '78 42',
errorClass: 'terminal',
})
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
})
it('an inventory refusal (nothing asked of the hardware) is outOfCash, and does not latch', async () => {
const actor = await dispenseWith({
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseConfirmed: false,
error: 'Insufficient inventory for denomination 20: short 1',
errorCode: 'InsufficientInventory',
errorClass: 'inventory',
})
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' })
expect(actor.getSnapshot().context.cashOutHeld).toBeNull()
})
it('a shortfall with NO error is outOfCash', async () => {
const actor = await dispenseWith({
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseConfirmed: false,
})
expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' })
})
it('a persisted hold restored on boot gates cash-out before any dispense', () => {
const actor = createActor(createATMMachine(mockServices))
actor.start()
actor.send({
type: 'CASH_OUT_HELD',
hold: { reason: 'jam', errorCode: 'F56DispenseError', rawCode: '78 42', since: 1791529353 },
})
actor.send({ type: 'SELECT_CASH_OUT' })
expect(actor.getSnapshot().value).toBe('idle')
expect(actor.getSnapshot().context.cashOutHeld?.since).toBe(1791529353)
})
it('outOfCash auto-returns after 30s; dispenseFault gives the customer 120s', async () => {
vi.useFakeTimers()
try {
const ooc = await dispenseWith(
{ bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], dispenseConfirmed: false },
true
)
expect(ooc.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' })
await vi.advanceTimersByTimeAsync(30000)
expect(ooc.getSnapshot().value).toBe('idle')
const fault = await dispenseWith(
{
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseConfirmed: false,
error: 'jam',
errorCode: 'F56DispenseError',
errorClass: 'recoverable',
},
true
)
expect(fault.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
await vi.advanceTimersByTimeAsync(30000)
expect(fault.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' })
await vi.advanceTimersByTimeAsync(90000)
expect(fault.getSnapshot().value).toBe('idle')
} finally {
vi.useRealTimers()
}
})
it('the customer can acknowledge the fault screen or cancel; both return to idle', async () => {
const a = await dispenseWith({
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseConfirmed: false,
error: 'jam',
errorClass: 'recoverable',
})
a.send({ type: 'ACKNOWLEDGE_FAULT' })
expect(a.getSnapshot().value).toBe('idle')
const b = await dispenseWith({
bills: [{ denomination: 20, dispensed: 0, rejected: 0 }],
dispenseConfirmed: false,
error: 'jam',
errorClass: 'recoverable',
})
b.send({ type: 'CANCEL' })
expect(b.getSnapshot().value).toBe('idle')
})
it('a hung dispense (timeout) is a terminal fault and latches', async () => {
vi.useFakeTimers()
try {
const hang: ATMServices = {
...mockServices,
dispenseCash: vi.fn().mockReturnValue(new Promise(() => {})),
}
const actor = createActor(createATMMachine(hang))
actor.start()
actor.send({ type: 'SELECT_CASH_OUT' })
await vi.advanceTimersByTimeAsync(100)
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
actor.send({ type: 'CONFIRM_AMOUNT' })
await vi.advanceTimersByTimeAsync(100)
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'p' })
await vi.advanceTimersByTimeAsync(120000 + 10)
const s = actor.getSnapshot()
expect(s.value).toMatchObject({ cashOut: 'dispenseFault' })
expect(s.context.dispenseResult?.errorCode).toBe('DispenseTimeout')
expect(s.context.cashOutHeld).not.toBeNull()
} finally {
vi.useRealTimers()
}
}) })
}) })

View file

@ -10,6 +10,7 @@ import {
type ATMContext, type ATMContext,
type ATMEvent, type ATMEvent,
type DispenseCashResult, type DispenseCashResult,
type CashOutHold,
initialContext, initialContext,
type ATMServices, type ATMServices,
type OfferRequestEvent, type OfferRequestEvent,
@ -148,8 +149,8 @@ export function createATMMachine(
} }
// Extract payment hash from invoice (simplified - real impl would decode BOLT11) // Extract payment hash from invoice (simplified - real impl would decode BOLT11)
// The service handles the actual extraction // The service handles the actual extraction
const cleanup = services.watchInvoice(input.invoice, (preimage: string) => { const cleanup = services.watchInvoice(input.invoice, (preimage, paymentHash) => {
sendBack({ type: 'PAYMENT_RECEIVED', preimage }) sendBack({ type: 'PAYMENT_RECEIVED', preimage, paymentHash })
}) })
return cleanup return cleanup
}), }),
@ -184,6 +185,9 @@ export function createATMMachine(
// without this the just-granted session would be wiped. The session is // without this the just-granted session would be wiped. The session is
// cleared instead on re-lock (locked's entry), i.e. when access ends. // cleared instead on re-lock (locked's entry), i.e. when access ends.
accessSession: context.accessSession, accessSession: context.accessSession,
// The cash-out latch is machine health, not transaction state — it
// survives every reset until an operator op releases it.
cashOutHeld: context.cashOutHeld,
cashInSessionId: null, cashInSessionId: null,
dispenseResult: null, dispenseResult: null,
})), })),
@ -315,6 +319,10 @@ export function createATMMachine(
if (event.type !== 'PAYMENT_RECEIVED') return null if (event.type !== 'PAYMENT_RECEIVED') return null
return event.preimage return event.preimage
}, },
paymentHash: ({ event }) => {
if (event.type !== 'PAYMENT_RECEIVED') return null
return event.paymentHash ?? null
},
}), }),
setPaymentFailed: assign({ setPaymentFailed: assign({
paymentStatus: () => 'failed' as const, paymentStatus: () => 'failed' as const,
@ -332,6 +340,37 @@ export function createATMMachine(
return output?.error ?? null return output?.error ?? null
}, },
}), }),
// ADR-005 §5: a terminal fault latches cash-out off. Idempotent — an
// existing hold is kept (its `since` is the first fault, which is what
// the operator wants to know).
latchCashOutIfTerminal: assign({
cashOutHeld: ({ context }) => {
if (context.cashOutHeld) return context.cashOutHeld
const dr = context.dispenseResult
if (dr?.errorClass !== 'terminal') return null
return {
reason: dr.error ?? 'terminal dispenser fault',
errorCode: dr.errorCode ?? null,
rawCode: dr.rawCode ?? null,
since: Math.floor(Date.now() / 1000),
} satisfies CashOutHold
},
}),
setCashOutHeld: assign({
cashOutHeld: ({ event }) => (event.type === 'CASH_OUT_HELD' ? event.hold : null),
}),
clearCashOutHeld: assign({ cashOutHeld: null }),
setDispenseTimeoutError: assign({
error: () => 'Dispense timed out — hardware may be jammed',
dispenseResult: ({ context }) =>
context.dispenseResult ?? {
bills: [],
dispenseConfirmed: false,
error: 'Dispense timed out — hardware may be jammed',
errorCode: 'DispenseTimeout',
errorClass: 'terminal' as const,
},
}),
setAmount: assign({ setAmount: assign({
fiatCents: ({ event }) => { fiatCents: ({ event }) => {
if (event.type !== 'SELECT_AMOUNT') return 0 if (event.type !== 'SELECT_AMOUNT') return 0
@ -458,6 +497,18 @@ export function createATMMachine(
return principalSats - fee <= context.availableBalance return principalSats - fee <= context.availableBalance
}, },
// Cash-out guards // Cash-out guards
// ADR-005 §5: no cash-out while latched. The idle screen shows why.
cashOutAvailable: ({ context }) => context.cashOutHeld === null,
// ADR-005 §3/§4: only value equality completes a dispense.
dispenseConfirmed: ({ event }) =>
(event as unknown as { output?: DispenseCashResult }).output?.dispenseConfirmed === true,
// A shortfall WITH a hardware error is a fault (customer paid, is owed);
// a shortfall with none, or an inventory refusal, is out-of-cash.
dispenseHadFault: ({ event }) => {
const out = (event as unknown as { output?: DispenseCashResult }).output
if (!out?.error) return false
return out.errorClass !== 'inventory'
},
hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0, hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0,
canAddDenomination: ({ context, event }) => { canAddDenomination: ({ context, event }) => {
if (event.type !== 'ADD_DENOMINATION') return false if (event.type !== 'ADD_DENOMINATION') return false
@ -477,7 +528,10 @@ export function createATMMachine(
INVOICE_TIMEOUT: 300000, // 5 minutes — waiting for payment INVOICE_TIMEOUT: 300000, // 5 minutes — waiting for payment
COMPLETE_DELAY: 60000, COMPLETE_DELAY: 60000,
DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond
DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState DISPENSE_ERROR_TIMEOUT: 30000, // out-of-cash: 30s like brain.js _timedState
// Fault screen: the customer has paid and is owed money; give them time
// to photograph/write down the reference (ADR-005 §4).
DISPENSE_FAULT_TIMEOUT: 120000,
// NOTE: idle inactivity re-lock + hard session cap are enforced at the // NOTE: idle inactivity re-lock + hard session cap are enforced at the
// DOM layer (useSessionSecurity), not as XState `after` delays — see the // DOM layer (useSessionSecurity), not as XState `after` delays — see the
// idle state comment. No IDLE_LOCK_TIMEOUT delay here by design. // idle state comment. No IDLE_LOCK_TIMEOUT delay here by design.
@ -513,6 +567,11 @@ export function createATMMachine(
cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction, cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction,
}), }),
}, },
// ADR-005 §5 — the store restores a persisted hold on boot and
// releases it when an operator op lands. Root-level so it applies in
// any state; it only gates entry to cashOut, never an in-flight sale.
CASH_OUT_HELD: { actions: 'setCashOutHeld' },
CASH_OUT_RELEASED: { actions: 'clearCashOutHeld' },
}, },
states: { states: {
// === ACCESS GATE (ADR-003) === // === ACCESS GATE (ADR-003) ===
@ -557,6 +616,7 @@ export function createATMMachine(
actions: ['setStartTime', 'setCashInFee'], actions: ['setStartTime', 'setCashInFee'],
}, },
SELECT_CASH_OUT: { SELECT_CASH_OUT: {
guard: 'cashOutAvailable',
target: 'cashOut', target: 'cashOut',
actions: ['setStartTime', 'setCashOutFee'], actions: ['setStartTime', 'setCashOutFee'],
}, },
@ -861,13 +921,12 @@ export function createATMMachine(
}, },
dispensingCash: { dispensingCash: {
// Safety timeout: if dispenseCash promise hangs (hardware jam, // Safety timeout: if dispenseCash promise hangs (hardware jam,
// serial port freeze), don't stay here forever. // serial port freeze), don't stay here forever. A hang is a
// terminal fault — the transport state is unknown.
after: { after: {
DISPENSE_TIMEOUT: { DISPENSE_TIMEOUT: {
target: 'dispenseError', target: 'dispenseFault',
actions: assign({ actions: ['setDispenseTimeoutError', 'latchCashOutIfTerminal'],
error: () => 'Dispense timed out — hardware may be jammed',
}),
}, },
}, },
invoke: { invoke: {
@ -875,21 +934,31 @@ export function createATMMachine(
input: ({ context }) => context.dispenseAmounts, input: ({ context }) => context.dispenseAmounts,
onDone: [ onDone: [
{ {
guard: ({ event }) => // ADR-005 §3: value equality, nothing else, completes.
(event.output as unknown as DispenseCashResult | undefined)?.dispensed === true, guard: 'dispenseConfirmed',
target: 'waitingForCashTaken', target: 'waitingForCashTaken',
actions: ['setCashDispensed', 'setDispenseResult'], actions: ['setCashDispensed', 'setDispenseResult'],
}, },
{ {
// Partial or failed dispense // ADR-005 §4: a hardware error means the customer has paid
target: 'dispenseError', // and is owed — the fault screen, with evidence. A terminal
// class also latches cash-out off (§5).
guard: 'dispenseHadFault',
target: 'dispenseFault',
actions: ['setDispenseResult', 'latchCashOutIfTerminal'],
},
{
// Shortfall with no hardware error, or an inventory refusal
// before anything was asked of the dispenser.
target: 'outOfCash',
actions: 'setDispenseResult', actions: 'setDispenseResult',
}, },
], ],
onError: { onError: {
// Unexpected crash (not a dispense failure) // The service threw (not a reported dispense failure). No
target: 'dispenseError', // per-bay report exists — the store flags counts unverified.
actions: 'setError', target: 'dispenseFault',
actions: ['setError', 'latchCashOutIfTerminal'],
}, },
}, },
}, },
@ -930,9 +999,26 @@ export function createATMMachine(
CANCEL: '#atm.locked', CANCEL: '#atm.locked',
}, },
}, },
dispenseError: { // ADR-005 §4 — two terminal states replace the old dispenseError.
// Payment received but cash not (fully) dispensed. //
// Show error + txid for 30s, then auto-idle (matches brain.js _timedState). // dispenseFault: the dispenser reported an error. The customer HAS
// PAID and is owed the shortfall. The screen carries the txid, the
// payment hash, the amounts and a statement that the operator has
// been notified — this is lamassu's fiatTransactionError ("the
// right prompt when they have paid and are owed money"), not the
// out-of-cash screen a jam used to show.
dispenseFault: {
after: {
DISPENSE_FAULT_TIMEOUT: '#atm.locked',
},
on: {
ACKNOWLEDGE_FAULT: '#atm.locked',
CANCEL: '#atm.locked',
},
},
// outOfCash: the request could not be met and the dispenser
// reported NO error — nothing was charged beyond what was dispensed.
outOfCash: {
after: { after: {
DISPENSE_ERROR_TIMEOUT: '#atm.locked', DISPENSE_ERROR_TIMEOUT: '#atm.locked',
}, },

View file

@ -21,18 +21,48 @@ export interface CassetteBillResult {
rejected: number rejected: number
} }
/** Result of a dispense operation (always resolves, never throws) */ /** How a dispense error should be routed — see @bitSpire/hal dispensers/error-codes.ts */
export type DispenseErrorClass = 'terminal' | 'recoverable' | 'inventory'
/** Result of a dispense operation (always resolves, never throws) — ADR-005 §3 */
export interface DispenseCashResult { export interface DispenseCashResult {
/** Per-denomination results (what was actually dispensed) */ /** Per-denomination results (what was actually dispensed) */
bills: { denomination: number; dispensed: number; rejected: number }[] bills: { denomination: number; dispensed: number; rejected: number }[]
/** Whether the full requested amount was dispensed */ /**
dispensed: boolean * Σ(denomination × dispensed) equals the requested fiat value. Computed by
/** Error message if dispense failed or was partial */ * the HAL on VALUE, never taken from a driver boolean. This is lamassu's
* `dispenseConfirmed` and it is the only thing that routes to `complete`.
*/
dispenseConfirmed: boolean
/** Human-readable message if dispense failed or was partial */
error?: string error?: string
/** The error's NAME, machine-readable — e.g. 'F56DispenseError' */
errorCode?: string
/** Driver-native code for the decode table — e.g. '78 42' */
rawCode?: string
/**
* terminal → dispenseFault + latch cash-out; recoverable → dispenseFault;
* inventory → outOfCash (nothing was asked of the hardware).
*/
errorClass?: DispenseErrorClass
/** Per-cassette detail (position-aware, produced by HAL) */ /** Per-cassette detail (position-aware, produced by HAL) */
cassettes?: CassetteBillResult[] cassettes?: CassetteBillResult[]
} }
/**
* Cash-out is latched off after a terminal dispenser fault (ADR-005 §5).
* Set by the machine when a fault lands; persisted and restored by the
* store; cleared only by an operator `recount` or `resume_cash_out` op —
* never by re-initialising the dispenser, which does not move a stuck note.
*/
export interface CashOutHold {
reason: string
errorCode: string | null
rawCode: string | null
/** unix seconds */
since: number
}
/** Payment methods supported */ /** Payment methods supported */
export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu' export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu'
@ -122,6 +152,12 @@ export interface ATMContext {
paymentStatus: PaymentStatus paymentStatus: PaymentStatus
/** Payment preimage (proof of payment) */ /** Payment preimage (proof of payment) */
preimage: string | null preimage: string | null
/**
* Payment hash of the settled invoice — the join key to the LNbits payment
* the operator sees. Shown on the dispense-fault screen (ADR-005 §4) so a
* customer who is owed money leaves with the reference the server indexes.
*/
paymentHash: string | null
/** Payment method used */ /** Payment method used */
paymentMethod: PaymentMethod | null paymentMethod: PaymentMethod | null
/** Pending offer request (Kind 21001 from user's wallet) */ /** Pending offer request (Kind 21001 from user's wallet) */
@ -157,6 +193,8 @@ export interface ATMContext {
retryCount: number retryCount: number
/** Result from the last dispense operation */ /** Result from the last dispense operation */
dispenseResult: DispenseCashResult | null dispenseResult: DispenseCashResult | null
/** Cash-out latched off after a terminal fault; null = available (ADR-005 §5) */
cashOutHeld: CashOutHold | null
// Transaction metadata // Transaction metadata
/** Unique transaction ID */ /** Unique transaction ID */
@ -199,8 +237,14 @@ export type ATMEvent =
| { type: 'BILL_REJECTED'; reason: string } | { type: 'BILL_REJECTED'; reason: string }
| { type: 'CASH_DISPENSED' } | { type: 'CASH_DISPENSED' }
| { type: 'DISPENSE_ERROR'; error: string } | { type: 'DISPENSE_ERROR'; error: string }
// Customer acknowledges the fault screen ("I've saved this reference")
| { type: 'ACKNOWLEDGE_FAULT' }
// Cash-out latch (ADR-005 §5): the store restores a persisted hold on boot
// and releases it when an operator recount / resume_cash_out op lands.
| { type: 'CASH_OUT_HELD'; hold: CashOutHold }
| { type: 'CASH_OUT_RELEASED' }
// Payment events // Payment events
| { type: 'PAYMENT_RECEIVED'; preimage: string } | { type: 'PAYMENT_RECEIVED'; preimage: string; paymentHash?: string }
| { type: 'PAYMENT_FAILED'; error: string } | { type: 'PAYMENT_FAILED'; error: string }
| { type: 'INVOICE_GENERATED'; invoice: string } | { type: 'INVOICE_GENERATED'; invoice: string }
| { type: 'OFFER_GENERATED'; offer: string } | { type: 'OFFER_GENERATED'; offer: string }
@ -245,6 +289,7 @@ export const initialContext: ATMContext = {
lnurlWithdraw: null, lnurlWithdraw: null,
paymentStatus: null, paymentStatus: null,
preimage: null, preimage: null,
paymentHash: null,
paymentMethod: null, paymentMethod: null,
pendingOfferRequest: null, pendingOfferRequest: null,
billsInserted: [], billsInserted: [],
@ -258,6 +303,7 @@ export const initialContext: ATMContext = {
error: null, error: null,
retryCount: 0, retryCount: 0,
dispenseResult: null, dispenseResult: null,
cashOutHeld: null,
txid: null, txid: null,
startedAt: null, startedAt: null,
cashInSessionId: null, cashInSessionId: null,
@ -306,7 +352,10 @@ export interface ATMServices {
* Watch an invoice for payment (polling-based) * Watch an invoice for payment (polling-based)
* Calls the callback when paid, returns cleanup function * Calls the callback when paid, returns cleanup function
*/ */
watchInvoice: (paymentHash: string, callback: (preimage: string) => void) => () => void watchInvoice: (
invoice: string,
callback: (preimage: string, paymentHash?: string) => void
) => () => void
/** /**
* Get available inventory: denomination -> count * Get available inventory: denomination -> count
*/ */