fix(machine): credit bills on stacked-confirmation, not stack command (#58)

Backports the legacy brain.js escrow interlock (from the public-domain
lamassu-machine tree at c0b69d1, see CLAUDE.md provenance):

- The id003/ebds drivers' `billsValid` event (bill physically reached
  the stacker) is now the credit trigger. hal-service tracks
  escrow → in-flight and fires onBillInserted only on confirmation;
  hal:stack-bill no longer synthesizes the credit at command time.
- New BILL_PENDING machine event marks the in-flight bill;
  FINISH_INSERTING is guard-blocked while one is pending, so "done"
  pressed mid-stack can no longer mint an LNURL that includes a bill
  still sitting in escrow (the aiolabs/bitspire#58 loss).
- BILL_INSERTED now requires a matching pending bill (stray or
  out-of-state confirmations are never credited) and BILL_REJECTED
  clears the in-flight marker — a failed/returned stack was never
  credited, so nothing to unwind.
- Escrow decision is fail-closed (legacy _billsRead parity): bill read
  outside insertingBills, or with unknown rate/balance, is returned to
  the customer instead of stacked-and-swallowed (closes the #35 gap at
  the decision point that physically takes the money).
- CashInView disables "Done" and shows a processing hint while a bill
  is in flight; the dev simulator drives the same guarded two-event
  path.

Both loss directions verified against the legacy semantics:
operator-pays-for-unstacked-cash and customer-bill-swallowed-uncredited.

6 new state-machine interlock tests; 27 state-machine + 43 machine-app
tests pass; full build (vue-tsc + vite + electron tsc) clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-04 01:07:58 +02:00
commit 7a67c2182f
8 changed files with 294 additions and 24 deletions

View file

@ -130,12 +130,17 @@ describe('ATM State Machine', () => {
// Wait for rate fetch
await new Promise((resolve) => setTimeout(resolve, 50))
// Real hardware flow: escrow accepted (stack commanded) →
// stacked-confirmation credits the bill.
actor.send({ type: 'BILL_PENDING', denomination: 20 })
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
actor.send({ type: 'BILL_PENDING', denomination: 10 })
actor.send({ type: 'BILL_INSERTED', denomination: 10 })
const context = actor.getSnapshot().context
expect(context.billsInserted).toEqual([20, 10])
expect(context.fiatCents).toBe(3000) // $30 in cents
expect(context.billPending).toBeNull()
})
it('should return to idle on CANCEL', async () => {
@ -152,6 +157,112 @@ describe('ATM State Machine', () => {
})
})
describe('cash-in escrow credit interlock (aiolabs/bitspire#58)', () => {
// Legacy brain.js parity: a bill is credited only on the validator's
// stacked-confirmation, and the insert phase cannot finish while a
// bill is between the stack command and that confirmation.
async function startInserting() {
const machine = createATMMachine(mockServices)
const actor = createActor(machine)
actor.start()
actor.send({ type: 'SELECT_CASH_IN' })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(actor.getSnapshot().value).toEqual({ cashIn: 'insertingBills' })
return actor
}
it('blocks FINISH_INSERTING while a bill is in flight', async () => {
const actor = await startInserting()
actor.send({ type: 'BILL_PENDING', denomination: 20 })
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
// Second bill enters flight; user mashes "done" before it settles.
actor.send({ type: 'BILL_PENDING', denomination: 1 })
actor.send({ type: 'FINISH_INSERTING' })
// Still inserting — the in-flight bill holds the door.
const snap = actor.getSnapshot()
expect(snap.value).toEqual({ cashIn: 'insertingBills' })
expect(snap.context.billPending).toBe(1)
// Nothing was credited for the in-flight bill.
expect(snap.context.fiatCents).toBe(2000)
})
it('allows FINISH_INSERTING once the in-flight bill is confirmed', async () => {
const actor = await startInserting()
actor.send({ type: 'BILL_PENDING', denomination: 1 })
actor.send({ type: 'FINISH_INSERTING' }) // blocked
actor.send({ type: 'BILL_INSERTED', denomination: 1 }) // confirmation lands
actor.send({ type: 'FINISH_INSERTING' })
await new Promise((resolve) => setTimeout(resolve, 50))
const snap = actor.getSnapshot()
expect(snap.value).not.toEqual({ cashIn: 'insertingBills' })
expect(snap.context.fiatCents).toBe(100)
expect(snap.context.billPending).toBeNull()
})
it('BILL_REJECTED clears the in-flight bill without crediting', async () => {
const actor = await startInserting()
actor.send({ type: 'BILL_PENDING', denomination: 20 })
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
actor.send({ type: 'BILL_PENDING', denomination: 10 })
// Stack failed — validator returned the bill.
actor.send({ type: 'BILL_REJECTED', reason: 'returned' })
const snap = actor.getSnapshot()
expect(snap.context.billPending).toBeNull()
expect(snap.context.fiatCents).toBe(2000) // only the confirmed bill
expect(snap.context.billsInserted).toEqual([20])
// And the door is open again.
actor.send({ type: 'FINISH_INSERTING' })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(actor.getSnapshot().value).not.toEqual({ cashIn: 'insertingBills' })
})
it('does not credit a BILL_INSERTED with no matching pending bill', async () => {
const actor = await startInserting()
// Stray stacked-confirmation (no stack was commanded).
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
const snap = actor.getSnapshot()
expect(snap.context.fiatCents).toBe(0)
expect(snap.context.billsInserted).toEqual([])
})
it('drops the credit too when BILL_PENDING was refused (over balance)', async () => {
const actor = await startInserting()
// Mock balance is 1M sats @ 2500 sats/USD → $400 ceiling. $500 bill
// exceeds it: pending is guard-refused, so its confirmation (which a
// correctly-driven validator would never send) is not credited either.
actor.send({ type: 'BILL_PENDING', denomination: 500 })
actor.send({ type: 'BILL_INSERTED', denomination: 500 })
const snap = actor.getSnapshot()
expect(snap.context.billPending).toBeNull()
expect(snap.context.fiatCents).toBe(0)
})
it('CANCEL with a bill in flight lands in confirmAbandon with pending cleared', async () => {
const actor = await startInserting()
actor.send({ type: 'BILL_PENDING', denomination: 20 })
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
actor.send({ type: 'BILL_PENDING', denomination: 10 })
actor.send({ type: 'CANCEL' })
const snap = actor.getSnapshot()
expect(snap.value).toEqual({ cashIn: 'confirmAbandon' })
expect(snap.context.billPending).toBeNull()
})
})
describe('cash-out flow (ATM-driven amount selection)', () => {
it('should transition to cashOut on SELECT_CASH_OUT', async () => {
const machine = createATMMachine(mockServices)

View file

@ -181,6 +181,15 @@ export function createATMMachine(
setCashOutFee: assign({
feeFraction: ({ context }) => context.cashOutFeeFraction,
}),
setBillPending: assign({
billPending: ({ context, event }) => {
if (event.type !== 'BILL_PENDING') return context.billPending
return event.denomination
},
}),
clearBillPending: assign({
billPending: null,
}),
addBill: assign({
billsInserted: ({ context, event }) => {
if (event.type !== 'BILL_INSERTED') return context.billsInserted
@ -190,6 +199,7 @@ export function createATMMachine(
if (event.type !== 'BILL_INSERTED') return context.fiatCents
return context.fiatCents + event.denomination * 100
},
billPending: null,
}),
calculateSats: assign({
satsAmount: ({ context }) => {
@ -367,13 +377,26 @@ export function createATMMachine(
},
guards: {
hasInsertedBills: ({ context }) => context.billsInserted.length > 0,
// Legacy brain.js parity: "send coins" is a no-op while a bill is
// between the stack command and the validator's stacked-confirmation.
canFinishInserting: ({ context }) =>
context.billsInserted.length > 0 && context.billPending === null,
// A credit event must correspond to the bill we commanded to stack —
// a stray stacked-confirmation with no pending bill is not credited.
billMatchesPending: ({ context, event }) => {
if (event.type !== 'BILL_INSERTED') return false
return context.billPending === event.denomination
},
hasSufficientAmount: ({ context }) => context.fiatCents >= 100, // $1 minimum
hasExchangeRate: ({ context }) => context.exchangeRate > 0,
canRetry: ({ context }) => context.retryCount < 3,
hasUserNpub: ({ context }) => context.userNpub !== null,
hasOfferRequest: ({ context }) => context.pendingOfferRequest !== null,
// Gate at the PENDING (pre-stack) decision, matching legacy
// _billsRead: balance/rate checks happen before the bill is
// physically committed. Once stacked, credit is unconditional.
billWithinBalance: ({ context, event }) => {
if (event.type !== 'BILL_INSERTED') return false
if (event.type !== 'BILL_PENDING') return false
// Reject bills if balance or rate is unknown — don't risk accepting
// cash we can't cover with sats
if (context.availableBalance <= 0 || context.exchangeRate === 0) return false
@ -476,15 +499,25 @@ export function createATMMachine(
],
},
on: {
BILL_INSERTED: {
// Escrow accepted → stack commanded. Marks the bill in
// flight; credit waits for the stacked-confirmation.
BILL_PENDING: {
guard: 'billWithinBalance',
actions: 'setBillPending',
},
// Validator confirmed the bill reached the stacker. The
// cash is physically in the box — credit unconditionally.
BILL_INSERTED: {
guard: 'billMatchesPending',
actions: ['addBill', 'calculateSats'],
},
BILL_REJECTED: {
// Stay in state, maybe show message
// Bill returned to customer (stack failed or refused) —
// it was never credited; just clear the in-flight marker.
actions: 'clearBillPending',
},
FINISH_INSERTING: {
guard: 'hasInsertedBills',
guard: 'canFinishInserting',
target: 'generatingNdebit',
},
CANCEL: [
@ -549,6 +582,9 @@ export function createATMMachine(
confirmAbandon: {
// Warning: cash is in the machine, abandoning forfeits it.
// Auto-clear after 60s so the machine self-recovers if customer walked away.
// A bill still in flight when the user bails is forfeited
// like the rest — clear the marker so nothing blocks on it.
entry: 'clearBillPending',
after: {
60000: '#atm.idle',
},

View file

@ -91,6 +91,13 @@ export interface ATMContext {
// Hardware state
/** Bills inserted during cash-in */
billsInserted: number[]
/**
* Bill in flight: stack commanded, awaiting the validator's
* stacked-confirmation. Credit (BILL_INSERTED) only lands once the
* bill physically reached the stacker; FINISH_INSERTING is blocked
* while a bill is in flight (legacy brain.js 'billsRead' interlock).
*/
billPending: number | null
/** Whether cash has been dispensed */
cashDispensed: boolean
/** Dispense amounts for cash-out */
@ -140,6 +147,7 @@ export type ATMEvent =
| { type: 'CLEAR_SELECTION' }
| { type: 'CONFIRM_AMOUNT' }
// Hardware events
| { type: 'BILL_PENDING'; denomination: number }
| { type: 'BILL_INSERTED'; denomination: number }
| { type: 'BILL_REJECTED'; reason: string }
| { type: 'CASH_DISPENSED' }
@ -187,6 +195,7 @@ export const initialContext: ATMContext = {
paymentMethod: null,
pendingOfferRequest: null,
billsInserted: [],
billPending: null,
cashDispensed: false,
dispenseAmounts: [],
cashOutSelection: [],