bug(cash-in): bill in escrow can be abandoned when user presses "done" before stack confirms #58
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
A bill that has reached escrow but has not yet been confirmed stacked by the validator (
billsValidEBDS event) can be left in escrow when the user presses "done" / triggersFINISH_INSERTING. The state machine transitions togeneratingNdebit, disables the validator, generates an LNURL for the full intended amount, and the bill sits in escrow indefinitely (or until autonomous MEI grace expires).If the customer claims the LNURL, the operator is paid out for cash they never actually received.
Observed (batm3 Austin, 2026-06-01)
Customer inserted two $1 bills, then pressed "done". LNURL generated for 2653 sats (≈ 2 × $1 net of fee, 1396 sats/USD). Bill 1 was stacked cleanly. Bill 2 reached escrow but was never stacked — it sat in MEI's escrow from 07:47:50 until 10:24:35 (~2.5 hours), when a manual
rejectwas sent to clear it.Bill 1 — clean (EBDS journal):
Bill 2 — broken (EBDS journal):
Bill 2 reached
billsRead(escrow), the renderer calledhalStackBill, main process calledvalidator.stack(), and the renderer firedBILL_INSERTEDto the state machine. But the EBDS layer never reported theescrowed → stacking → billsValidtransitions that bill 1 went through. The state machine treated the bill as stacked anyway, becauseBILL_INSERTEDis its only signal.Confirmation that bill 2 was physically in escrow
The remediation
rejectissued at 10:24:35 produced this trace:Notably no
stackedflicker — distinct from the phantom-escrow reject earlier the same morning (07:40:14) which cycledreturned → stacked → idling. Thereturnedflag withoutstackedis the signature of a real bill being physically returned, confirming bill 2 had been sitting in escrow the entire time.Root cause
apps/machine/src/services/hal.ts:202-217andapps/machine/electron/main.ts:368-378:BILL_INSERTEDis sent to the state machine immediately aftervalidator.stack()is called, not after MEI confirms withbillsValid. The renderer never observes whether the stack actually completed.packages/state-machine/src/machine.ts:448-493: theinsertingBillsstate'sBILL_INSERTEDhandler doesaddBill+calculateSatsonly — there is no separate "in-flight" state, noBILL_STACKEDevent, and no guard onFINISH_INSERTINGother thanhasInsertedBills(count > 0). A pending bill is structurally indistinguishable from a stacked one.Result: if MEI fails to stack (wire drop, ack-toggle desync, autonomous return mid-cycle, unknown), the renderer silently believes the bill is stacked.
FINISH_INSERTINGis honored unconditionally, the validator is disabled, and the bill is abandoned in escrow.Proposed fix
Two-part change:
1. Separate "bill accepted" from "bill stacked"
Wire
billsValid(EBDS event signalling the stack physically completed) through the HAL → IPC → state machine, separately frombillsRead:billsAccepted(already exists),billsRead(already exists), and additionally surfacebillsValidupstream.hal:bill-stackedIPC alongsidehal:bill-inserted. Rename or repurposeBILL_INSERTEDso the state machine has two discrete events: bill in escrow vs. bill physically in cashbox.{ denomination, status: 'pending' | 'stacked' }(or two arrays), withaddBillputting it inpendingand a newmarkStackedaction moving it tostackedonBILL_STACKED.2. Guard
FINISH_INSERTINGon no pending billsIn
insertingBills:If a bill is still pending when "done" is pressed:
BILL_STACKEDthen auto-advance.waitingForStacksubstate that shows a spinner and auto-advances onBILL_STACKED(with a watchdog timeout that auto-rejects if stack doesn't confirm within ~3s).Option B is friendlier — the user gets feedback instead of a button that does nothing.
3. Watchdog on stuck escrow during cash-in
While we're here: if a bill in
pendingstate doesn't transition tostackedwithin N polls (say 1.5s — well past bill 1's 80ms reference), log a warning and issue an auto-reject. This prevents the silent-abandon failure mode even if the rest of this fix has a hole.Open question — wire-level
A secondary concern: why did bill 2's stack not produce EBDS transitions?
validator.stack()was called (the renderer's path traces all the way throughBILL_INSERTED). Possible causes worth investigating once the state machine is hardened:ackcounter diverged from MEI's view (e.g., due to a dropped poll response), the stack frame would be treated as a retransmission and ignored.validator.stack()being called and MEI processing it, MEI might have rejected the command. (Disabling sets mask=0 and polls — shouldn't cancel a stack, but worth checking the EBDS spec.)Fixing the state machine alone is enough to prevent money loss; investigating the wire-level cause is hardening on top.
Net impact (this incident)
Severity
High. This bug silently loses money on every fast double-bill cash-in. The Austin incident only cost $1 because of small denominations and a single customer; the same pattern with a $20 bill would have lost $20.
References
apps/machine/src/services/hal.ts:202-222apps/machine/electron/main.ts:291-310,:368-378apps/machine/src/stores/atm.ts:1096-1127packages/state-machine/src/machine.ts:448-493packages/hal/src/validators/ebds/ebds-fsm.ts(wherebillsValidis emitted)Root cause identified on
devand fixed in PR #77.Mechanism: credit fired at stack-command time —
hal:stack-billissued the serial stack and immediately synthesizedhal:bill-inserted→BILL_INSERTED→fiatCentscredited. The validator's stacked-confirmation event (billsValid, emitted by both the id003 and ebds drivers) was never subscribed anywhere, andFINISH_INSERTINGhad no in-flight-bill guard — so "done" pressed while a bill was between escrow and stacker minted an LNURL that included the unstacked bill. Exactly the observed Austin incident.Fix (PR #77) ports the legacy brain.js interlock from the public-domain lamassu-machine tree (
c0b69d1): credit only onbillsValid,FINISH_INSERTINGblocked while a bill is in flight (billPendingcontext + guard),billsRejectedclears the in-flight marker (a failed stack was never credited), and bills read outsideinsertingBillsare returned to the customer instead of stacked.The inverse loss direction was also live and is covered: a
BILL_INSERTEDarriving after the machine leftinsertingBillswas silently dropped by XState while the bill continued into the cashbox (stacked, never credited).Leaving open until verified on Sintra hardware. Note the fix is on
dev(LNbits branch) —main/batm3 where this was observed still carries the old behavior.