Cash-in stalled on the batm3: a note reached escrow and was read, but the
acceptor never stacked or returned it, and the customer was never
credited. Root cause: EBDS carries the stack/return decision as bits in
the omnibus *poll* command, but the driver sent stack()/reject() as a
single one-shot frame while a free-running 100ms poller kept sending
plain polls. The lone stack frame races/collides with the poller (or its
ack desyncs), gets dropped, and the device holds the note in escrow
indefinitely.
- ebds-rs232: latch the escrow decision (`pendingAction`) into the poll
command byte and re-assert it on every poll until the device leaves
escrow (cleared in _process when `!escrowed`). A dropped frame is now
simply retried on the next poll.
- hal-service: return an escrowed note on disableValidator() — disable
alone does not release it on EBDS, so an inactivity timeout / cancel
previously stranded the bill in the transport (observed on the batm3).
- atm store: stringify the `[ATM] Sending event` / `[ATM] State` logs —
they were printing `[object Object]`, which blinded the cash-in trace.
Verified: hal builds, machine app typechecks. Hardware behaviour to be
confirmed on the batm3 (no unit tests exist for this serial driver).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Mirrors satmachineadmin's PR #30 v1.1 commits (df6e8e0..1cebefc). Three
load-bearing corrections from the v1.0 implementation:
1. **Wire shape flips from denomination-keyed to position-keyed**
(`{positions: {<pos>: {denomination, count}}}`). The original `#56`
spec was position-keyed; my `06:40Z` audit-and-flip was wrong on
both the load-bearingness of the ATM denom-PK invariant AND on the
operational requirement (per-slot denomination must be operator-
editable for swap-during-refill).
2. **Drop "one cassette per denomination" invariant.** Real production
machines load multiple cassettes with the same denomination for
cash-out throughput on a single bill class (4 × $20 cassettes on
Tejo/batm3 are normal). NO unique index on denomination.
3. **HAL refactor for per-position state + greedy distribution.** When
asked for N of denomination D, iterate matching bays in position
order draining greedy until the request is satisfied or all matching
bays empty. Surfaces "Insufficient inventory for denomination D:
short K" rather than crashing on the first under-stocked bay.
Schema migration v8 → v9: rebuild `cassettes` with `position INTEGER
PRIMARY KEY`, `denomination INTEGER NOT NULL`, `count INTEGER NOT NULL
DEFAULT 0`. SQLite create-copy-drop-rename per the v4→v5 precedent
(FKs off during, no data loss). Existing rows backfill column-by-column.
`setCassettes()` upserts `ON CONFLICT(position)`. `updateCassetteCount
(denomination, delta)` → `updateCassetteCountByPosition(position, delta)`
since the dispenser returns per-position results. `getInventory()`
boundary stays denomination-keyed (sums across matching bays) for
backwards compat with renderer callers.
HAL `inventory: Record<denom, count>` + `cassetteDenominations: number[]`
collapse into a single `bays: {position, denomination, count}[]` array.
Dispense per-bay note assignment + per-bay decrement on result. Bay
ordering by position throughout.
Operator-config consumer (`operator-config.ts`) flips both the apply
direction (`{positions: ...}` parse + validate position-set equality +
denom/count int checks, NO denom-uniqueness) and the bootstrap publish
direction (position-keyed payload encoding).
IPC type signatures updated in `preload.ts` + `types/electron.d.ts` for
both the new `OperatorCassettesPayload` shape and the per-position
`halReloadCassettes` argument.
`atm-tui` schema flip + handler updates land in a separate commit on
`aiolabs/atm-tui` (this commit's changes are limited to lamassu-next).
Bumping the atm-tui flake input on `deploy/server-deploy` (or the local
flake.lock here) after the atm-tui push reaches the sintra closure.
12/12 typecheck, 18/18 state-machine tests, 11/11 clink, 11/11 lnbits,
11/11 nostr-client all green.
Design history: `~/dev/coordination/log.md` entries 2026-05-30T06:30Z →
20:55Z. Satmachineadmin counterpart at PR #30. Issue body refreshed.
refs: aiolabs/lamassu-next#56, aiolabs/satmachineadmin#29, aiolabs/satmachineadmin PR #30 (commits df6e8e0..1cebefc)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the ATM-side consumer of operator-driven cassette config per
aiolabs/lamassu-next#56 v1. Operator → ATM only, with a one-shot ATM
bootstrap hello-event so satmachineadmin can auto-populate
`cassette_configs` rows on first boot.
Transport (decision rationale in coordination log 2026-05-30 entries):
- kind=30078 (NIP-78 replaceable), ["p", atm_npub]-tagged, ["d",
"bitspire-cassettes:<machine_id>"], NIP-44 v2 encrypted content,
authored by operator. Subscribed via filter
{kinds:[30078], "#p":[my_npub], "#d":[...], authors:OPERATOR_PUBKEYS}
- machine_id = ATM hex pubkey (no extra provisioning step)
Wire payload is denomination-keyed (per satmachineadmin's 06:40Z
audit of the ATM stack — every layer beneath the wire keys on
denomination, position is a sortable display column):
{ "denominations": { "<denom>": { "position": N, "count": M } } }
Validation:
- event signature + author in VITE_OPERATOR_PUBKEYS allowlist
- replay protection via meta.lastKnownConfigCreatedAt (drops events
re-delivered on relay reconnect or after restart)
- clock-skew defense: reject created_at > now + 60s
- denomination key set EXACTLY equal to state.db denominations
(no add/remove cassettes from the dashboard)
- per-row position positive int, count non-negative int
Apply in a single SQLite transaction (cassettes upsert by denomination
PK + meta watermark update), then hot-reload HAL via new IPC
`hal:reload-cassettes` so dispense math picks up the new layout
without restarting the bitspire service.
Bootstrap hello-event (one-shot):
- on init, if meta.bootstrapPublishedAt IS NULL AND cassettes
non-empty, publish kind=30078 with d=bitspire-cassettes-state:<id>,
encrypted to operator pubkey, signed by ATM
- on success set meta.bootstrapPublishedAt; on failure leave null and
retry next boot (best-effort; doesn't block service startup)
Schema v7 → v8: adds meta rows lastKnownConfigCreatedAt + bootstrap-
PublishedAt. Fresh installs at v8 seed via INSERT OR IGNORE.
HAL service grows setCassettes(cassettes) — closes + re-inits the
dispenser, rebuilds the inventory map + cassetteDenominations index.
Exposed as `hal:reload-cassettes` IPC + window.electronAPI.halReload-
Cassettes for the renderer.
Out of scope (v2 / separate issue):
- continuous ATM-state reverse-channel publish (dashboard
reconciliation + ✅/⏳ apply confirmation + safe "Add N bills" UX)
12/12 typecheck + 18/18 state-machine + 11/11 clink + 11/11 lnbits
suites pass.
refs: aiolabs/lamassu-next#56, aiolabs/satmachineadmin#29,
~/dev/coordination/log.md 2026-05-30 entries (06:30Z, 06:40Z, 07:30Z,
07:50Z, 07:55Z), ~/dev/CLAUDE.md (Nostr architecture → "Respect
protocol semantics over friction reduction")
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The HAL init hung indefinitely when the validator device didn't exist
or failed to respond — blocking the entire init including the dispenser
and Lightning connection.
Now the validator is optional:
- Checks device exists (fs.existsSync) before attempting to open
- 15s timeout on validator.run() to prevent hanging
- On failure, logs warning and proceeds with dispenser-only mode
- All validator methods guarded with null checks
This enables the BATM3 to run cash-out only when the MEI validator
is not connected (e.g., during initial setup or testing).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port MEI CashFlow SC / BNR Advance EBDS protocol from lamassu-machine
to TypeScript HAL. Adds 'batm3' machine model preset (EBDS validator +
F56 dispenser). Fixes hardcoded 'id003' validator type in device config
overrides so model presets correctly propagate their validator type.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Failed dispenses (sats debited, cash not dispensed) were invisible —
transactions only recorded on 'complete'. Now records on 'dispenseError'
with status ('dispense_error'|'partial'|'complete'), error message, and
per-cassette detail.
Also fixes a bug in both HAL services where dispense results were mapped
by amounts-array index instead of cassette position, causing swapped
denomination counts when cassette order differs from request order.
Schema v3→v4: adds status/error columns to transactions, new
cassette_bills table for per-cassette provisioned/dispensed/rejected.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After a dispense error, the F56/Puloon drivers call close() which sets
initialized=false. The next dispense would fail on a closed serial port.
Now checks dispenser.initialized before each dispense and re-inits if
needed, matching the lazy re-init pattern from brain.js (line 4072).
Closes#29
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The hal:dispense IPC handler in main.ts did not return the result of
dispenseCash(), causing the state machine guard to crash on undefined
output. This left the UI stuck on "Dispensing cash..." after successful
dispense.
- hal-service.ts: return DispenseResult instead of void/throwing
- main.ts: add missing return in IPC handler
- machine.ts: defensive guard (?. instead of .) as safety net
Bug found with the aid of Seoyoung at Trece Cielos.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
HAL hardware drivers (serialport) run in the main process since they
need Node.js. The renderer communicates via IPC for all hardware ops.
- hal-service.ts: bridge between HAL drivers and Electron IPC
- main.ts: HAL IPC handlers (init, dispense, validator stack/reject)
- preload.ts: expose HAL API to renderer via contextBridge
- atm.ts: IPC-based production init with validator event wiring
- hal.ts: add 'hold' mode for escrow (async stack/reject decision)
- electron.d.ts: HAL type declarations for window.electronAPI
Bills go to escrow first; the renderer checks balance before accepting.
Falls back to Lightning-only mock mode if HAL init fails.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>