ATM-side consumer: operator-driven cassette inventory config #56
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?
Today, cassette denomination + count on a deployed ATM are set in three places, none of which are operator-facing from a remote dashboard:
atm-tui(interactive TUI on the ATM, requires SSH + sudo) —~/dev/bitspire/atm-tuiVITE_LAMASSU_CASSETTESenv var (only seeds an empty DB on first run; never overrides existing rows) —apps/machine/electron/main.ts:622/var/lib/bitspire/state.db(escape hatch)For dev (sintra), in-person is fine. For prod operation, the operator needs to manage cassette inventory from the satmachineadmin dashboard without SSH-ing to the box.
This issue tracks the ATM-side consumer of operator-driven cassette config. The operator-side producer + dashboard UI lives at aiolabs/satmachineadmin#29 (filed in parallel).
Decisions (2026-05-30; v1.1 revision after design audit)
After scoping conversation across
bitspire ↔ satmachineadminsessions and a v1.1 correction (see~/dev/coordination/log.mdentries 06:30Z → 18:45Z), the design is:Transport: encrypted kind-30078
kind = 30078(NIP-78 arbitrary application data, replaceable)["d", "bitspire-cassettes:<machine_id>"]tag (operator → ATM direction)["p", <atm_npub>]tagaiolabs/lnbits#26cascade)Ruled out CLINK kind-21003 per workspace
~/dev/CLAUDE.md"Respect protocol semantics over friction reduction": CLINK is a payment-flow protocol (Offer/Debit/Manage of payment offers), documented dormant on the dev branch — conceptually different from operator → ATM config.Ruled out LNbits-sidecar-bunker-query: expands LNbits' role beyond payments, couples operator-config availability to LNbits availability, routes a different threat-model relationship through the wallet-ops bus.
Wire shape: position-keyed (v1.1 correction from earlier denomination-keyed attempt)
The earlier
06:40Zaudit recommended denomination-keyed wire under the (mistaken) assumption that the ATM stack's denomination-PK invariant was load-bearing AND that one-cassette-per-denomination was a real operational constraint. Both were wrong:So
positionis the addressable unit (matches the hardware bay layout), anddenominationis a mutable per-row field. Two rows may carry the same denomination.Wire payload (kind-30078 content, NIP-44 v2 encrypted):
positionskeys MUST be exactly the set of positions currently present in the ATM'sstate.db(no add, no remove from this path — the bay count is hardware-determined). Denomination + count per row are both operator-mutable. NO denomination-uniqueness constraint — duplicate denominations across positions are intentionally permitted.Editable surface — what the dashboard can change
VITE_LAMASSU_CASSETTESor atm-tui. Changing it requires a service tech / hardware event.Bounds blast radius even under satmachineadmin compromise: an attacker can shift positions or counts within the existing N slots but cannot add phantom cartridges that don't exist physically.
Existing ATM-side state
cassettestable in/var/lib/bitspire/state.db: post-v9 migration the PK isposition INTEGER PRIMARY KEY;denominationis a regular column (allows duplicates across rows);count INTEGER NOT NULL DEFAULT 0metatable extends withlastKnownConfigCreatedAt(v7→v8) andbootstrapPublishedAt(v7→v8); v8→v9 rebuildscassettesPKsetCassettes()inapps/machine/electron/state-store.tsis the canonical write path (ON CONFLICT(position) DO UPDATE)loadCassettes()is read at HAL init (main.ts:381) — HAL needs per-position layouthal-service.ts) tracks per-bay state and distributes a denomination ask across all matching bays greedily on dispensestate:load-cassettes,state:set-cassettes,hal:reload-cassettesVersioning — v1 ships first, v2 before prod fleet
v1 (this issue's primary scope, with v1.1 wire-shape correction) — operator → ATM publish + one-shot ATM bootstrap publish
["d", "bitspire-cassettes:<machine_id>"]), validates, applies to state.db, hot-reloads HALcassettes.countdecrements locally on cash-out (existing path, now per-position via dispenser results)["d", "bitspire-cassettes-state:<machine_id>"]) so satmachineadmin can auto-populatecassette_configsfor the machine before the operator's first config publish — see "Bootstrap" section belowShippable for dev (Sintra-style: operator can SSH to verify state.db). Not safe for multi-machine prod.
v2 (filed as
aiolabs/lamassu-next#57when ready, with satmachineadmin counterpart) — continuous ATM-state reverse channelATM publishes the same
["d", "bitspire-cassettes-state:<machine_id>"]event continuously: on every count change (cash-out, count refresh) + periodic heartbeat. Carries observed per-position state +applied_config_event_id. Enables dashboard reconciliation, ✅/⏳ apply confirmation, safe "Add N bills" UX.Bootstrap hello-event (v1, satmachineadmin asks)
A fresh ATM's
state.db.cassettesis seeded at provisioning (viaVITE_LAMASSU_CASSETTESor atm-tui) but satmachineadmin'scassette_configsis empty for that machine. Without a bootstrap, the operator's first config publish has nothing to match against.On first boot (
meta.bootstrapPublishedAtis null ANDstate.db.cassettesis non-empty):{ "positions": { "<pos>": { "denomination": <denom>, "count": <count> }, ... } }kind=30078,["d", "bitspire-cassettes-state:<machine_id>"],["p", <operator_pubkey>], NIP-44 v2 encrypted content, signed by ATMmeta.bootstrapPublishedAt = nowafter publish succeedsBootstrap is one-shot per ATM. If satmachineadmin loses its
cassette_configsrows, the recovery path is on-box only in v1:v1 caveat — operator account requires a local nsec
Decryption of inbound
bitspire-cassettes-state:events on the satmachineadmin side uses the operator's localaccounts.prvkey(the LNbits Nostr-login flow populates this). Operator MUST onboard via Nostr-login before the consumer can decrypt bootstrap events. This violates the "no nsec at rest on LNbits" architectural rule but is the working path untillnbitsphase 2.4 (bunker-mediatednip44_decrypt, gated onnsecbunkerd'screate_new_policyrule-kind fix) ships. Phase 2.4 will let the consumer route throughsigner.nip44_decrypt(...)instead of readingaccount.prvkeydirectly. See~/dev/coordination/log.md2026-05-30T17:25Z for the architectural gap diagnosis.v1 reconciliation gotcha (call out in operator UX)
Cash-out decrements
cassettes.countlocally on the ATM. If the operator publishes a new config without accounting for cash-outs since their last baseline, the ATM count gets clobbered to the operator's stated value. Mitigations in v1:07:50Z).Resolved in v2 once the continuous reverse channel + dashboard reconciliation surface lands.
ATM-side acceptance criteria (v1)
Subscription path
event.pubkeynot inVITE_OPERATOR_PUBKEYSmeta.lastKnownConfigCreatedAt:event.created_at <= meta.lastKnownConfigCreatedAt(includes stale events re-delivered on relay reconnect / after restart)event.created_at > now + MAX_FUTURE_SKEW_S(clock-skew / future-stamping defense)@bitSpire/nostr-client)positionskeys are exactly the set of positions currently instate.db.cassettes(reject unknown positions; reject any update that omits a known position — partial updates not supported)denominationvalues are positive ints; allcountvalues are non-negative intscassettesrows onpositionPK (denomination + count both mutate per row) + updatemeta.lastKnownConfigCreatedAthal:reload-cassettesIPC (rebuild per-bay state + re-init dispenser)persistedInventoryref refreshes after applyBootstrap publish path
meta.bootstrapPublishedAt IS NULLANDstate.db.cassetteshas at least one row:VITE_OPERATOR_PUBKEYS[0](first operator pubkey)["d", "bitspire-cassettes-state:<machine_id>"],["p", <operator_pubkey>]meta.bootstrapPublishedAt = unix_now()bootstrapPublishedAtnull and retry next boot. Don't block ATM startup.bootstrapPublishedAt) ifVITE_OPERATOR_PUBKEYSis empty, so retry happens on next boot once operator pubkeys are configured.HAL per-position dispense
getInventory()boundary stays denomination-keyed (sums across matching bays) for backwards compat with the rendererOut of scope
VITE_LAMASSU_CASSETTES/ atm-tuirecordTransaction→ per-position count decrement)Coordination
~/dev/CLAUDE.md(Nostr architecture → "Respect protocol semantics over friction reduction" — workspace-level rule grounded in this decision)~/dev/bitspire/atm-tui~/dev/coordination/log.mdentries at 2026-05-30T06:30Z → 19:00ZCross-comment from
aiolabs/satmachineadmin#29reviewReviewed
#29(operator-side producer) against the current ATM stack and surfaced a set of cross-codebase decisions both issues need to converge on before implementation. Full write-up lives at satmachineadmin#29 comment. Summary of the points that land on the ATM side:Wire-shape change:
{position → {denomination, count}}→{denomination → {position, count}}Wire payload in this issue is keyed on
position, but the ATM stack is denomination-keyed at every layer below the wire:cassettes (denomination INTEGER PRIMARY KEY)—~/dev/bitspire/atm-tui/src/db.zig:31apps/machine/electron/state-store.ts:54setCassettesupsertsON CONFLICT(denomination)—state-store.ts:265hal-service.ts:116cassetteDenominations.indexOf(denomination)—hal-service.ts:189(first match wins; duplicates silently collapse)The ATM stack carries a strong implicit invariant: one cassette per denomination per machine. A position-keyed wire allows two operator-side rows with the same denomination, which the ATM silently collapses on apply. No error surfaced.
Recommend flipping the wire to
{denomination → {position, count}}rather than bumping the ATM schema + HAL to a position-keyed model. The cost on the satmachineadmin side is a schema choice (PK becomes(machine_id, denomination)withpositionas a column); the cost on the ATM side is zero code change. Full trade-off table in the linked comment.Hello-event in scope here?
#29proposed two options for how the operator-sidecassette_configsrows first appear (auto from ATM hello-event vs operator hand-entry at onboarding). Recommend locking to hello-event auto-populate. Since#56is wiring publish-from-ATM anyway, sending an initial kind-30078 advertising the machine's current denomination + position + count set during the same work is the natural place to source the canonical truth. Worth confirming whether the hello-event lands in this issue's scope or filed as a follow-up.Ack shape: kind-30078 from ATM, not status column
Both issues' AC left ack/error shape undecided between two incompatible options ("publish a status event back OR write to a status column"). Recommend the ATM publishes a kind-30078 ack with
["d", "bitspire-cassettes-ack:<machine_id>"]carrying last-applied content hash + timestamp + per-position applied counts. Keeps the flow event-shaped and replaceable-event-symmetric with the operator publish.Reject option 3 stopgap
The
operator_commandskind-21003 queue route is a trap. Once cassettes ride on it, migrating to kind-30078 later is uphill. Ifaiolabs/lnbits#18isn't ready when this lands, prefer waiting over shipping option 3. Cassettes are config (replaceable state); kind-30078 is the right shape regardless of timing.Coordination
Paired entries in
~/dev/coordination/log.md:2026-05-30T06:30Z— initial cross-session message to bitspire/atm-tui session2026-05-30T06:40Z— follow-up correcting the schema recommendation (A → B) after deeper investigation of the HAL layerRefs:
aiolabs/satmachineadmin#29(paired operator-side producer + full review comment), commitsatmachineadmin:dcd0874(privacy-by-default architecture),aiolabs/lnbits#18(bunker integration — option 1 dependency).Status 2026-06-16 — bootstrap hello-event does not re-publish on re-pair
Reproduced on demo today: an ATM that published its
bitspire-cassettes-statehello-event while paired to one operator/relay never re-publishes after being re-pointed at a new operator/server.maybePublishBootstrap(operator-config.ts) early-returns whenstate.db meta.bootstrapPublishedAt !== '', andstate.dbsurvives restarts/re-provisioning. Net effect: the operator dashboard sits on "waiting for the ATM's bootstrap state event" forever, even though relay, npub, subscription and fee-config delivery are all correct.Manual workaround:
UPDATE meta SET value='' WHERE key='bootstrapPublishedAt'+ restart.Proper fix belongs in the S0 pairing consumer (spirekeeper#9 / bitspire#52): consuming a new/changed seed resets
bootstrapPublishedAt(and re-publishes operator-scoped state) under the new relationship. Alternatively the v2 continuous reverse channel (publish on count change + heartbeat) noted in this issue's header removes the one-shot gate entirely. Either subsumes the standalone bug.