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:
parent
47151ebe8c
commit
7a67c2182f
8 changed files with 294 additions and 24 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue