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

Merged
padreug merged 1 commit from fix/escrow-credit-interlock into dev 2026-07-25 20:44:12 +00:00
Owner

Problem (root cause of #58)

Bill credit fired at stack-command time: hal:stack-bill issued the serial stack and immediately synthesized hal:bill-inserted → BILL_INSERTED → fiatCents credited. The validator's actual stacked-confirmation (billsValid, emitted by both the id003 and ebds drivers) was never subscribed anywhere. Both loss directions were live:

  • Operator loss (observed batm3 Austin, #58): bill credited, stack fails or bill returned, customer presses "done" → LNURL minted for cash the machine never received. No un-credit path exists.
  • Customer loss (inverse): BILL_INSERTED arriving after the machine left insertingBills (done pressed during the escrow round-trip) was silently dropped by XState while the bill continued into the cashbox — stacked, never credited, no record.

Verified against the legacy public-domain lamassu-machine tree (c0b69d1, see the CLAUDE.md provenance correction on dev): brain.js credits only in the billsValid handler (updateBillsScreen → Tx.billUpdate), holds a billsRead interim state that makes "send coins" a no-op while a bill is in flight, and rejects bills read outside accepting states.

Fix — the legacy interlock, ported

  • HAL layer (electron/hal-service.ts + renderer fallback src/services/hal.ts): escrow → in-flight tracking; onBillInserted (the credit callback) fires only on billsValid. billsRejected clears both markers — a failed stack was never credited. hal:stack-bill (main.ts) no longer synthesizes the credit.
  • State machine: new BILL_PENDING event + billPending context field. FINISH_INSERTING is guarded by canFinishInserting (no bill in flight). BILL_INSERTED requires a matching pending bill; BILL_REJECTED and confirmAbandon entry clear it. Balance/rate gating moved to the BILL_PENDING (pre-stack) decision — once a bill physically stacked, credit is unconditional.
  • Escrow decision fail-closed (was fail-open "no rate yet, accept anyway"): bill read outside insertingBills or with unknown rate/balance is returned to the customer, closing the #35 gap at the point that physically takes money.
  • UI: "Done Inserting" disabled + "Processing bill…" hint while a bill is in flight; dev simulator drives the same guarded two-event path.

Tests

  • 6 new state-machine tests: finish blocked mid-flight / unblocked after confirmation, rejected-stack never credited + reopens finish, stray confirmation not credited, guard-refused pending drops its confirmation, cancel-mid-flight clears pending in confirmAbandon.
  • 27 state-machine + 43 machine-app tests pass; full build (vue-tsc + vite + electron tsc + fund-atm bundle) clean.

Behavior notes for review

  • A bill in flight when the user cancels/abandons is forfeited uncredited (matches existing abandon semantics; the abandon-accounting gap is a separate finding).
  • If billsValid never arrives (validator dies mid-stack), billPending blocks "done" until the 3-min inactivity timeout routes to confirmAbandon — self-recovering, and strictly better than crediting a phantom bill. A dedicated stack-timeout is possible follow-up work alongside the ERROR-event handling gap.
  • Pre-existing failure, untouched: pnpm test at the root fails on @bitSpire/hal ("No test files found") — same hygiene family as #53.

🤖 Generated with Claude Code

## Problem (root cause of #58) Bill credit fired at stack-**command** time: `hal:stack-bill` issued the serial stack and immediately synthesized `hal:bill-inserted` → `BILL_INSERTED` → `fiatCents` credited. The validator's actual stacked-confirmation (`billsValid`, emitted by both the id003 and ebds drivers) was never subscribed anywhere. Both loss directions were live: - **Operator loss** (observed batm3 Austin, #58): bill credited, stack fails or bill returned, customer presses "done" → LNURL minted for cash the machine never received. No un-credit path exists. - **Customer loss** (inverse): `BILL_INSERTED` arriving after the machine left `insertingBills` (done pressed during the escrow round-trip) was silently dropped by XState while the bill continued into the cashbox — stacked, never credited, no record. Verified against the legacy public-domain lamassu-machine tree (`c0b69d1`, see the CLAUDE.md provenance correction on dev): `brain.js` credits only in the `billsValid` handler (`updateBillsScreen` → `Tx.billUpdate`), holds a `billsRead` interim state that makes "send coins" a no-op while a bill is in flight, and rejects bills read outside accepting states. ## Fix — the legacy interlock, ported - **HAL layer** (`electron/hal-service.ts` + renderer fallback `src/services/hal.ts`): escrow → in-flight tracking; `onBillInserted` (the credit callback) fires only on `billsValid`. `billsRejected` clears both markers — a failed stack was never credited. `hal:stack-bill` (`main.ts`) no longer synthesizes the credit. - **State machine**: new `BILL_PENDING` event + `billPending` context field. `FINISH_INSERTING` is guarded by `canFinishInserting` (no bill in flight). `BILL_INSERTED` requires a matching pending bill; `BILL_REJECTED` and `confirmAbandon` entry clear it. Balance/rate gating moved to the `BILL_PENDING` (pre-stack) decision — once a bill physically stacked, credit is unconditional. - **Escrow decision fail-closed** (was fail-open "no rate yet, accept anyway"): bill read outside `insertingBills` or with unknown rate/balance is **returned to the customer**, closing the #35 gap at the point that physically takes money. - **UI**: "Done Inserting" disabled + "Processing bill…" hint while a bill is in flight; dev simulator drives the same guarded two-event path. ## Tests - 6 new state-machine tests: finish blocked mid-flight / unblocked after confirmation, rejected-stack never credited + reopens finish, stray confirmation not credited, guard-refused pending drops its confirmation, cancel-mid-flight clears pending in `confirmAbandon`. - 27 state-machine + 43 machine-app tests pass; full build (vue-tsc + vite + electron tsc + fund-atm bundle) clean. ## Behavior notes for review - A bill in flight when the user cancels/abandons is forfeited uncredited (matches existing abandon semantics; the abandon-accounting gap is a separate finding). - If `billsValid` never arrives (validator dies mid-stack), `billPending` blocks "done" until the 3-min inactivity timeout routes to `confirmAbandon` — self-recovering, and strictly better than crediting a phantom bill. A dedicated stack-timeout is possible follow-up work alongside the ERROR-event handling gap. - Pre-existing failure, untouched: `pnpm test` at the root fails on `@bitSpire/hal` ("No test files found") — same hygiene family as #53. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
padreug deleted branch fix/escrow-credit-interlock 2026-07-25 20:44:12 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/bitspire!77
No description provided.