feat(v2): operator-side cassette inventory v1.1 + signer.nip44_* migration (#29) #30
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/cassette-config-v1"
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?
Status as of merge
Closes
aiolabs/satmachineadmin#29(per-machine cassette inventory). Paired withaiolabs/lamassu-next#56(ATM-side consumer + bootstrap publisher) andaiolabs/lnbits#38(signer.nip44_*ABC).End-to-end validated through Greg's
RemoteBunkerSigneron regtest 2026-05-31:nip44_encrypt+sign_eventvia bunker → kind-30078 → relay (event_id=04a57d6a6ce2…)nip44_decryptvia bunker →apply_bootstrap_stateupsert →state_event_idadvanced to970c67147e18…bay1:20×20, bay2:20×1006:22Zcoord-logTests: 155 passed, 1 pre-existing async-plugin failure unchanged.
What landed
Three layered scopes, in delivery order:
v1 — denomination-keyed wire (commits 1-9)
Initial PR shape per the design locked in
~/dev/coordination/log.md06:30Z→15:55Zon 2026-05-30:m007cassette_configsschema with reserved v2 reconciliation columnscassette_transportpublish/decrypt/cassettes/publishAPI + UIbg-yellow-1→ inlinebox-shadowaccent to dodge the LNbits theme-vs-Quasar-utility CSS specificity trap)v1.1 — position-keyed wire-shape pivot (commits 10-15)
After Padreug surfaced that v1 wrongly froze per-slot denominations (real operational case: dispatcher swaps a $20 cassette for $50 during refill), wire shape flipped from
{denominations: {<d>: {position, count}}}to{positions: {<p>: {denomination, count}}}. Plus: drop the implicit "one cassette per denomination" invariant — production machines load N same-denom cassettes for cash-out throughput.m008schema flipPK (machine_id, position), no UNIQUE on denomination, newstate_denominationcolumnDecision context: coord-log
2026-05-30T18:30Z/18:45Z. Bitspire mirrored onlamassu-next#56.v1.1 hardening — bunker-mediated nip44 + lint (commits 16-20)
Once
aiolabs/lnbits#38(phase 2.4: bunker-mediatednip44_*via NDK 3.0.3) merged + Greg's account migrated toRemoteBunkerSigner(lnbits coord-log22:00Z), the satmachineadmin transport's directaccount.prvkeyreads stopped working. Migration:cassette_transport._nip44_*_via_signerroutes throughsigner.nip44_encrypt/signer.nip44_decryptvia the post-#38NostrSignerABC#21S7 final AC)NsecBunkerTimeoutError→CassetteEventTransientError(consumer doesn't advancestate_event_id, retries on next poll);NsecBunkerRpcError/SignerUnavailableError→CassetteEventDecodeError(log + skip)try: from lnbits.core.signers import SignerError ...block was permanently failing (those errors live in.base, not the package root) — the "pre-#17 fallback" had been masquerading as the active pathblack+ruff --fix); 2 new mypy regressions fixed;nip44.pyruntime exports narrowed to LocalSigner transitional + test-onlyOut of scope (tracked separately)
aiolabs/lamassu-next#57)#21S7 final ACs#31, root-cause guard at#32, architectural fix at#20(S6)#29comment (mirrors bitspirelamassu-next#56)Commit chain (20)
Test plan
docker compose exec lnbits /app/.venv/bin/python -m pytest tests/→ 155 passed04a57d6a6ce2…lands on relay970c67147e18…applied,cassette_configsadvanced--fixapplied; remaining 26 ruff warnings are stylistic (Pydanticclsvalidators, exception suffix preference, docstring line-length); mypy regressions from this PR fixed🤖 Generated with Claude Code
Wire up the cassette_configs storage layer: - get_cassette_config / list_cassette_configs_for_machine — reads - update_cassette_config — operator UI per-row edit (count + position). Refuses to create new rows; the denomination set is hardware-determined per #29 row lifecycle. - apply_bootstrap_state — consumer-side upsert from an ATM-published kind-30078 bitspire-cassettes-state event. Populates both the operator-believed columns and the v2 reverse-channel columns (state_count, state_at, state_event_id) in one transaction. Returns False on relay re-delivery (any existing row's state_event_id matches the incoming event_id). - _should_apply_bootstrap_state — pure-function dedup gate extracted from apply_bootstrap_state so the relay-re-delivery decision is unit-testable without a database round-trip. 23 new pure-function/model tests in tests/test_cassette_configs.py covering the wire-shape validators (denomination key coercion, no-duplicate- positions, int ranges, wire-dict round-trip) and the dedup-helper logic. DB-touching CRUD follows the existing project convention (see test_deposit_currency.py rationale): smoke-tested manually via the dev container, integration tests deferred. Total: 98 passed, 1 pre-existing async-plugin failure unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The Nostr-wire layer for operator ↔ ATM cassette config. Owns both directions: operator → ATM (publish_to_atm): build PublishCassettesPayload → NIP-44 v2 encrypt to ATM pubkey → sign as operator via _sign_as_operator hybrid → publish through nostrclient.router.nostr_client.relay_manager d-tag: bitspire-cassettes:<atm_pubkey_hex> p-tag: <atm_pubkey_hex> ATM → operator (decrypt_and_parse_state_event): consumer task feeds inbound events (already sig-verified by the subscription layer); we NIP-44 v2 decrypt with operator privkey + event sender pubkey, JSON-parse, validate as PublishCassettesPayload d-tag: bitspire-cassettes-state:<atm_pubkey_hex> p-tag: <operator_pubkey_hex> `_sign_as_operator` recovers the hybrid signer pattern from commits131ff92/e13178d(removed indcd0874for the NIP-78 fleet rip): tries `from lnbits.core.signers import resolve_signer` first (post-#17 path), falls back to a direct `account.prvkey` read for pre-#17 lnbits hosts. Both paths produce identical signed events. Unlike the prior fleet- publish that soft-failed on missing identity (CRUD side-effect), this publish is operator-initiated so missing identity raises OperatorIdentityMissing for the API to surface as 400. `_atm_hex_pubkey(machine)` centralises the `<m>` placeholder rule from the 2026-05-30T11:50Z coord-log entry: always normalize_public_key on machine.machine_npub, NEVER use the internal dca_machines.id UUID. The build_state_d_tags_for_machines helper exposes the canonical d-tag list for the consumer subscription filter to use. Typed errors map cleanly to HTTP statuses in the API caller: - OperatorIdentityMissing → 400 (operator hasn't onboarded) - SignerUnavailable → 503 (signer offline / client-side-only) - RelayUnavailable → 503 (nostrclient not installed) - CassetteEventDecodeError → consumer-side log + skip (never crash) NIP-44 v2 ECDH needs the raw operator scalar, which the signer abstraction's high-level sign_event doesn't expose. v1 reads account.prvkey directly (same surface as the pre-#17 sign fallback); post-bunker (lnbits#18) this becomes a NIP-44-over-bunker RPC and the operator nsec leaves the LNbits host — v2 follow-up. Smoke-tested via docker exec: round-trip publish (build → encrypt → parse) of the realistic {"denominations": {"20": ..., "50": ...}} payload; tamper detection on a corrupted content field; malformed pubkey rejection. Full suite: 132 passed, 1 skipped, 1 pre-existing async-plugin failure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Two operator-scoped endpoints, both gated by check_user_exists + _machine_owned_by: GET /api/v1/dca/machines/{machine_id}/cassettes List the operator-owned machine's cassette_configs rows. Empty list means the ATM hasn't published its bootstrap event yet (or the consumer task hasn't drained it); UI shows a "waiting for ATM" state. POST /api/v1/dca/machines/{machine_id}/cassettes/publish Operator submits the full per-machine cassette state (PublishCassettes Payload) for publish to the ATM. Validates the denomination set matches what's stored (defensive — UI prevents add/remove but API enforces), upserts each row with the operator's user id as audit updated_by, then calls cassette_transport.publish_to_atm to encrypt+ sign+publish kind-30078. The path param `{machine_id}` is satmachineadmin's internal dca_machines.id UUID; the handler fetches Machine and uses machine.machine_npub canonicalised via normalize_public_key as the `<m>` value in the d-tag bitspire-cassettes:<atm_pubkey_hex> per the locked design and the 2026-05-30T11:50Z coord-log nudge. Translation happens inside cassette_transport._atm_hex_pubkey so the API handler stays thin. Error mapping: 400 — payload denomination set doesn't match stored set (operator publishing for a cassette the ATM doesn't have, or no rows exist because the bootstrap hasn't landed) 400 — OperatorIdentityMissing (operator hasn't onboarded a Nostr identity via LNbits Nostr-login) 503 — SignerUnavailable (signer offline / client-side-only) 503 — RelayUnavailable (nostrclient extension not installed) 500 — anything else from the publish path Returns the fresh cassette_configs rows after the upserts so the UI refreshes its table from one round-trip. Total: 146 passed (route registration verified via FastAPI router introspection), 1 skipped (cross-test fixture pending), 1 pre-existing async-plugin failure unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Per workspace CLAUDE.md "Dark-mode color discipline": pale bg-{color}-1 utilities render white-on-cream under the LNbits dark theme. The dirty- row highlight in the Cassettes sub-tab used bg-yellow-1 alone, so the denomination text (rendered as default-coloured <b>) went invisible on the pale yellow background as soon as the operator started editing. Paired with text-grey-9 the way the existing q-banner classes in this file already are (bg-blue-1 text-grey-9, bg-orange-1 text-grey-9, etc). Sintra dispatcher Greg surfaced this during the v1 joint smoke today (coord-log 2026-05-30T17:55Z). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Coordinated v1.1 fix with the ATM side per coord-log 2026-05-30T18:30Z + 18:45Z. The m007 schema's denomination-PK was wrong: - Operators need to swap cartridge denominations during refill (a $20 bay becomes a $50 bay) without re-provisioning. m007 made denomination immutable per slot. - Real machines have N cartridges of the same denomination for cash-out throughput (e.g., four $20 cartridges on a single ATM to avoid mid-day refill). m007 + denomination-PK rejected duplicates. The flip: - PK becomes (machine_id, position). Position is the fixed hardware bay number; denomination + count are operator-editable per row. - No UNIQUE constraint on denomination — multiple same-denom cassettes are operationally valid. - New nullable column state_denomination for v2 reverse-channel reconciliation (operator-believed denomination per slot vs ATM- reported denomination — diff highlighting in v2 UI). SQLite doesn't support ALTER PRIMARY KEY directly; the migration does the standard create-new / backfill-from-old / drop / rename dance. Idempotent via state_denomination column-probe. Backfill choice: in m007 the row's denomination was simultaneously the operator-believed AND the ATM-reported value (only write path was the bootstrap consumer copying state.db verbatim). At migration time state_denomination = current denomination as a best-guess baseline; the next bootstrap event re-populates the state_* columns authoritatively per the v1.1 wire shape. Wire shape, models, CRUD, transport, consumer, API, and UI all flip in subsequent commits. PR #30 will grow from 9 → 14 commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The wire format flips from {"denominations": {"<denom>": {"position", "count"}}} to {"positions": {"<pos>": {"denomination", "count"}}} per coord-log 2026-05-30T18:30Z + 18:45Z. Per-row editable surface changes: - denomination becomes mutable (operator swaps cartridges during refill) - count remains mutable - position becomes the row identity (hardware bay number) Removed: the no_duplicate_positions validator (no longer relevant — position is the key, dups are impossible at the dict level) and the implicit "denomination unique" assumption. Multiple positions with the same denomination are now operationally valid per bitspire 18:45Z. CassetteConfig model adds state_denomination (Optional[int]) for v2 reverse-channel reconciliation diff highlighting. Tests under tests/test_cassette_configs.py and test_cassette_state_consumer.py will fail at this commit — they reference the old denomination-keyed shape. They get rewritten in v1.1 commit f. Branch tip is green only after commit f lands; this and the next 3 commits are intermediate states. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>CRUD layer flips: - get_cassette_config(machine_id, position) — was (..., denomination) - list_cassette_configs_for_machine returns ORDER BY position alone (no secondary denomination ordering — position is the unique key) - update_cassette_config(machine_id, position, data, updated_by) — operator edits denomination + count for a fixed slot - apply_bootstrap_state upserts ON CONFLICT(machine_id, position) iterating payload.positions; populates new state_denomination column from row.denomination alongside state_count cassette_transport.py needs almost no functional change — the wire shape is implicit via PublishCassettesPayload.to_wire_dict (now emits {"positions": {...}}) and decrypt_and_parse_state_event accepts what the model parses. Just the module docstring + the publish log line get updated to reference positions rather than denominations. Tests still red until commit f rewrites them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>API endpoint: - api_publish_machine_cassettes validates incoming payload.positions set matches stored cassette_configs.position set (was: denomination set match). Error message updated to "slot count is hardware-fixed — re-provision the ATM via atm-tui to add/remove physical bays." - Per-row upsert loop iterates payload.positions and passes UpsertCassetteConfigData(denomination=, count=) — operator edits denomination + count for a fixed slot. Bootstrap consumer task: just a log-message field rename (now reports "N cassettes" from len(payload.positions) instead of len(payload. denominations)). Per-event handler already routes through the transport's decrypt_and_parse_state_event, which returns a PublishCassettesPayload that's now position-keyed via the model. Tests still red until commit f. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>UI flips to position-keyed per the v1.1 redesign: - Column order: Bay | Denomination | Count | ATM-reported | Updated (position first, since it's the row identity) - Position becomes read-only: rendered as "Bay N" label - Denomination becomes an editable q-input (with the fiat code as a suffix on the input) - Count remains editable - ATM-reported column now shows "<denom> <fiat> · ×<count>" combining state_denomination + state_count for at-a-glance reconciliation (still v1: only the bootstrap snapshot; v2 reverse-channel makes this live) - Confirm-modal preview list: header is "Bay N", side shows the denomination + count being sent JS: - cassettesTable.columns reordered to put position first - markCassetteDirty pivots on position (the immutable identity) and compares denomination + count against pristine - submitCassettePublish builds {positions: {<pos>: {denomination, count}}} payload instead of {denominations: ...} No "lock icon" on denomination — the previous instinct to add one was based on the m007 misinterpretation. v1.1 design correctly makes denomination operator-editable. Tests still red until commit f. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The wire-shape pivot (m007 denomination-keyed → m008 position-keyed) needs the unit test surface re-written to match: test_cassette_configs.py - PublishCassettesPayload tests pivot to positions-keyed input. Validators reject non-int / non-positive position keys, negative denom, negative count. Zero count allowed (empty cassette). - NEW: test_accepts_multiple_same_denomination_cassettes — pins the v1.1 operational requirement (real machines load 4×$20 for cash-out throughput) per coord-log 18:45Z. No denom-unique validator. - CassettePayloadRow tests pivot to the new field shape (denomination + count, no position). - UpsertCassetteConfigData tests cover edit-denomination (the v1.1 "operator swaps a cartridge during refill" scenario) and edit-count. Position no longer in the model. test_cassette_state_consumer.py - _make_state_event helper builds {"positions": {...}} ciphertext. - Happy-path assertion checks p.positions keys + denomination/count per row. - NEW: test_round_trips_multiple_same_denomination — covers the v1.1 four-of-the-same case through encrypt → decrypt → parse. - All negative paths (tamper, wrong privkey, malformed pubkey, missing fields, garbage JSON, wrong shape) carry over with the new payload shape. - d-tag tests unchanged (position vs denomination isn't on the d-tag). test_nip44_v2.py - TestBitspireCrossTest temporarily re-skipped at the class level: the 13:15Z fixture is encoded with the v1 denomination-keyed shape; bitspire's posting a v1.1 fixture and commit g will swap + unskip. Total: 148 passed, 3 skipped (bitspire cross-test pending the v1.1 fixture from bitspire), 1 pre-existing async-plugin failure unchanged. Branch tip is now functionally green (the pre-existing async failure predates this PR + can't be addressed without a pytest plugin install). Pending commit g for the cross-test fixture re-wire when bitspire posts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>feat(v2): operator-side cassette inventory v1 (#29)to feat(v2): operator-side cassette inventory v1.1 + signer.nip44_* migration (#29)