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

@ -36,6 +36,11 @@ export interface HalConfig {
export interface ValidatorCallbacks {
shouldAcceptBill: (denomination: number) => boolean | 'hold'
onBillRead?: (denomination: number) => void
/**
* Fires on the validator's stacked-confirmation (`billsValid`) — the
* bill physically reached the stacker. This is the CREDIT event; it is
* NOT emitted at stack-command time (a stack can still fail/return).
*/
onBillInserted: (denomination: number) => void
onBillRejected: (reason: string) => void
onError: (error: string) => void
@ -142,6 +147,14 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
count: c.count ?? 0,
}))
// Escrow / in-flight bookkeeping (legacy brain.js `billsRead` interlock):
// `escrowDenomination` = bill held in escrow awaiting a stack/reject
// decision; `inFlightDenomination` = stack commanded, awaiting the
// validator's `billsValid` stacked-confirmation. onBillInserted (the
// credit event) fires only on that confirmation.
let escrowDenomination: number | null = null
let inFlightDenomination: number | null = null
return {
connectValidator: (callbacks: ValidatorCallbacks) => {
if (!validator) {
@ -153,10 +166,11 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
const decision = callbacks.shouldAcceptBill(data.denomination)
if (decision === 'hold') {
console.log('[HAL] Bill in escrow:', data.denomination)
escrowDenomination = data.denomination
callbacks.onBillRead?.(data.denomination)
} else if (decision) {
inFlightDenomination = data.denomination
validator.stack()
callbacks.onBillInserted(data.denomination)
} else {
console.log('[HAL] Bill rejected: insufficient balance for', data.denomination)
validator.reject()
@ -168,7 +182,23 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
}
})
// Stacked-confirmation → the credit event.
validator.on('billsValid', () => {
if (inFlightDenomination === null) {
console.warn('[HAL] billsValid with no bill in flight — ignoring')
return
}
const denomination = inFlightDenomination
inFlightDenomination = null
console.log('[HAL] Bill stacked (confirmed):', denomination)
callbacks.onBillInserted(denomination)
})
validator.on('billsRejected', (data?: { reason: string; code: number | null }) => {
// Covers both an escrow refusal and a failed/returned stack —
// either way nothing was credited and nothing is in flight.
escrowDenomination = null
inFlightDenomination = null
callbacks.onBillRejected(data?.reason ?? 'unknown')
})
@ -195,8 +225,19 @@ export async function initializeHal(config: HalConfig): Promise<HalInstance> {
validator?.lightOff()
},
stackBill: () => validator?.stack(),
rejectBill: () => validator?.reject(),
stackBill: () => {
if (escrowDenomination === null) {
console.warn('[HAL] stackBill with no bill in escrow — ignoring')
return
}
inFlightDenomination = escrowDenomination
escrowDenomination = null
validator?.stack()
},
rejectBill: () => {
escrowDenomination = null
validator?.reject()
},
dispenseCash: async (amounts): Promise<DispenseResult> => {
console.log('[HAL] Dispensing:', amounts)