feat(v2): operator-side cassette inventory v1.1 + signer.nip44_* migration (#29) #30
Merged
padreug
merged 19 commits from 2026-05-31 13:54:19 +00:00
feat/cassette-config-v1 into v2-bitspire
19 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| d448fab0d2 |
chore(v2): lint pass — black + ruff auto-fix + mypy regressions (#29 v1.1)
Some checks failed
ci.yml / chore(v2): lint pass — black + ruff auto-fix + mypy regressions (#29 v1.1) (pull_request) Failing after 0s
Pre-merge lint hygiene on the PR #30 touched files:
- `black` reformatted 9 files (cassette_transport, crud, models, tasks,
views_api, nip44, all 3 cassette test files, migrations). Cosmetic:
line lengths, trailing commas, multi-line argument layout.
- `ruff check --fix` cleared 176 of 202 errors auto-fixed. Mostly
`UP006` `typing.Optional` → `| None` modernization, `I001` import
sort order, `UP035` typing-extensions cleanup.
- Two new mypy regressions introduced by the migration commit
|
|||
| dcb7de0c27 |
refactor(v2): cassette transport — signer.nip44_* migration (#29 v1.1 / closes #21 partial)
Some checks failed
ci.yml / refactor(v2): cassette transport — signer.nip44_* migration (#29 v1.1 / closes #21 partial) (pull_request) Failing after 0s
Migrates the cassette transport's encrypt/decrypt paths off direct `account.prvkey` reads to `signer.nip44_encrypt` / `signer.nip44_decrypt` on the NostrSigner ABC landed by aiolabs/lnbits PR #38 (phase 2.4). Closes the operator-side regression flagged at coord-log 2026-05-31T06:50Z: Greg's RemoteBunkerSigner-migrated account had `accounts.prvkey IS NULL` post-bunker, which the old code couldn't handle — consumer was logging WARN every poll cycle and skipping every inbound state event. ## What changed ### cassette_transport.py - New imports: `resolve_signer`, `SignerError`, `SignerUnavailableError`, `NsecBunkerTimeoutError`, `NsecBunkerRpcError` from the post-#38 lnbits surface. (The `try: from lnbits.core.signers import SignerError` block in the old code was permanently failing because `SignerError` actually lives in `lnbits.core.signers.base`, not the package root — fixed.) - New `_resolve_operator_signer(operator_user_id)`: single source of truth for "give me the operator's account + NostrSigner, or raise an operator-facing error." Used by both the publish path and the consumer task. - New `_nip44_encrypt_via_signer(account, signer, plaintext, peer)` and `_nip44_decrypt_via_signer(...)`: route through `signer.nip44_*` first; on `SignerUnavailableError` from a LocalSigner stub (the post-#38 ABC has LocalSigner raise on nip44_* explicitly — bunker migration required for NIP-44 v2), fall back to the hand-rolled impl against `account.prvkey`. Transitional until every operator on the instance is bunker-backed (S7). - `_sign_as_operator` simplified: now `await signer.sign_event(event)` (the ABC is async; the old code passed `signer.sign_event` to the caller without await, returning a coroutine — also broken but never hit because the ImportError fallback fired first). - `publish_to_atm` flow: `_resolve_operator_signer` → `_nip44_encrypt_ via_signer` → `_sign_as_operator` → publish. Each step maps bunker / signer errors to `OperatorIdentityMissing` (400) / `SignerUnavailable` (503) / `CassetteTransportError` (500) for the API handler. - `decrypt_and_parse_state_event` now `async` and takes `(event, account, signer)` instead of `(event, operator_privkey_hex)`. Maps `NsecBunkerTimeoutError` → `CassetteEventTransientError` (caller should retry on next poll, NOT advance `state_event_id`). `NsecBunkerRpcError` / `SignerUnavailableError` / `Nip44Error` / etc. → `CassetteEventDecodeError` (terminal — caller logs + skips). - New `CassetteEventTransientError` class for the bunker-timeout case. Distinct from `CassetteEventDecodeError` so the consumer can log at INFO + retry vs WARNING + advance. - Deleted `_get_operator_privkey_hex` (no longer needed). ### tasks.py — _handle_cassette_state_event - Resolves the signer via `_resolve_operator_signer(machine.operator_ user_id)`. On `CassetteTransportError` (OperatorIdentityMissing / SignerUnavailable), logs + skips. - Awaits `decrypt_and_parse_state_event(event_obj, account, signer)`. On `CassetteEventTransientError`, logs at INFO + returns (state_event_ id NOT advanced → consumer retries on next poll cycle). On `CassetteEventDecodeError`, logs at WARNING + returns (still state_event_id NOT advanced for v1; the WARN log surfaces the underlying issue for operator triage). ### tests/test_cassette_state_consumer.py — rewritten - Three test doubles: `_FakeBunkerSigner` (working nip44_decrypt via hand-rolled impl), `_FakeLocalSignerStub` (raises like the post-#38 LocalSigner stub), `_FakeRaisingSigner` (configurable exception). - `_fake_account` helper using SimpleNamespace — the code under test only reads `.signer_type` + `.prvkey`. - Five test classes covering: bunker-signer happy path (incl. multi- same-denom round-trip), LocalSigner transitional fallback, bunker-error mapping (timeout → transient, rpc reject → decode), payload validation (tamper / wrong-key / missing-fields / garbage JSON / wrong shape), d-tag construction (unchanged, kept as regression guard). - Async coroutines driven via `asyncio.run` — matches the existing project pattern (no pytest-asyncio plugin in CI; see test_init.py failure mode). ### nip44.py — docstring update Added a "Runtime status (post lnbits PR #38, 2026-05-31)" section documenting that runtime usage moved to `signer.nip44_*` and this module's role narrowed to (a) the LocalSigner transitional fallback called from `cassette_transport`, and (b) test-only fixtures in test_nip44_v2.py for spec-vector + bitspire cross-test validation. "Don't add new runtime call sites here. The signer abstraction is the path." ## Verification - 155 passed, 1 pre-existing async-plugin failure unchanged. The 19 consumer tests cover bunker happy path + LocalSigner fallback + bunker error mapping + payload validation + d-tag construction. - Live smoke against Greg's RemoteBunkerSigner-migrated account on the regtest container: consumer correctly resolves the bunker signer, fires `NIP-46 rpc -> method=nip44_decrypt`, catches the resulting `NsecBunkerTimeoutError` (the local nsecbunkerd is not responding within 15s — separate operational concern), maps to `CassetteEventTransientError`, logs at INFO with "will retry next poll", and crucially does NOT advance `state_event_id` on the cassette_configs rows. Retry semantics preserved. ## Outstanding - The bunker timeout itself is an operational issue (nsecbunkerd config / policy / process state for kind-less nip44_decrypt RPC) — not a satmachineadmin code concern; surface to the nsecbunkerd / lnbits sessions if it persists. - Once every operator on the instance is on RemoteBunkerSigner (S7 fully landed), the `_nip44_*_via_signer` helpers collapse to a direct `await signer.nip44_*` call, the LocalSigner fallback can be deleted, and `nip44.py`'s runtime exports retire (test-only). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 4b128ca53c |
test(v2): re-wire bitspire cross-test fixture for v1.1 positions-keyed shape (#29 v1.1)
Some checks failed
ci.yml / test(v2): re-wire bitspire cross-test fixture for v1.1 positions-keyed shape (#29 v1.1) (pull_request) Failing after 0s
Replaces the v1 fixture (denominations-keyed, 2026-05-30T13:15Z) with
bitspire's v1.1 fixture (positions-keyed, 2026-05-30T19:00Z log entry).
Drops the class-level @pytest.mark.skip from commit
|
|||
| 1cebefcde5 |
test(v2): rewrite cassette tests for v1.1 position-keyed wire shape (#29 v1.1)
Some checks failed
ci.yml / test(v2): rewrite cassette tests for v1.1 position-keyed wire shape (#29 v1.1) (pull_request) Failing after 0s
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>
|
|||
| 3014962563 |
refactor(v2)(ui): denomination editable per slot, position read-only (#29 v1.1)
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>
|
|||
| 34e324b4c5 |
refactor(v2): cassette consumer + API endpoint — position-keyed (#29 v1.1)
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>
|
|||
| 5dbd7314f4 |
refactor(v2): cassette CRUD + transport — position-keyed (#29 v1.1)
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>
|
|||
| 427cad33de |
refactor(v2): cassette models — position-keyed wire shape (#29 v1.1)
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>
|
|||
| df6e8e0a22 |
feat(v2): m008 flip cassette_configs PK to (machine_id, position) (#29 v1.1)
Some checks failed
ci.yml / feat(v2): m008 flip cassette_configs PK to (machine_id, position) (#29 v1.1) (pull_request) Failing after 0s
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>
|
|||
| 23b615601f |
fix(v2)(ui): dirty cassette row — inset shadow accent, not bg-color class
Some checks failed
ci.yml / fix(v2)(ui): dirty cassette row — inset shadow accent, not bg-color class (pull_request) Failing after 0s
Previous attempt (commit
|
|||
| 5f9c84b6e8 |
fix(v2)(ui): dirty cassette row needs explicit text-grey-9 under dark theme
Some checks failed
ci.yml / fix(v2)(ui): dirty cassette row needs explicit text-grey-9 under dark theme (pull_request) Failing after 0s
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>
|
|||
| 5631246337 |
test(v2): wire bitspire's NIP-44 v2 cross-test fixture from coord log (#29 v1)
Some checks failed
ci.yml / test(v2): wire bitspire's NIP-44 v2 cross-test fixture from coord log (#29 v1) (pull_request) Failing after 0s
Bitspire posted the sample event at ~/dev/coordination/log.md
2026-05-30T13:15Z — encrypted via @bitSpire/nostr-client's
encryptContentV2 + createSignedEvent (the same production code path
the ATM bootstrap publish uses), round-tripped on bitspire side
before posting.
Replaces the @pytest.mark.skip stub from commit
|
|||
| 407149137a |
feat(v2)(ui): cassette sub-tab in machine detail + overwrite-confirm modal (#29 v1)
Some checks failed
ci.yml / feat(v2)(ui): cassette sub-tab in machine detail + overwrite-confirm modal (#29 v1) (pull_request) Failing after 0s
The operator-facing surface for #29 v1. Two changes: 1. Sub-tab inside the existing machine-detail modal (`templates/satmachineadmin/index.html`): - q-tabs strip with Settlements + Cassettes inside the machine detail q-card-section, wrapping the existing Settlements content in a q-tab-panel name="settlements" and adding a new q-tab-panel name="cassettes" - Cassettes panel renders the cassette_configs rows from GET /api/v1/dca/machines/{id}/cassettes: - One row per denomination (read-only label) - Editable q-input for count + position (the only operator- editable fields per the locked design) - ATM-reported count column (read-only, shows the v2 reverse- channel state_count when populated; v1 only populates on bootstrap) - Last-updated timestamp - Dirty rows highlighted bg-yellow-1 - "Revert" + "Publish to ATM" buttons in the header; both disabled until at least one row is dirty - "Waiting for ATM bootstrap" banner when cassette_configs is empty (the bootstrap consumer hasn't received the ATM's state event yet) 2. Confirm-on-publish modal (per coord-log `07:50Z`): - Yellow warning banner: "This publish will overwrite the ATM's currently-tracked counts. If the ATM has dispensed cash since your last refill, those decrements will be lost. Publish only after a physical refill (a known total), not to 'tweak' counts mid-day. v2 reconciliation will replace this modal with reconciled state display." - Per-denomination preview list of what's being sent - Cancel + Publish-to-ATM buttons Vue 3 + Quasar UMD compliance per workspace CLAUDE.md: explicit-close tags (no self-closing), v-model.number on the numeric inputs, @update:model-value to trigger dirty-tracking, JSON-clone for the pristine snapshot. JS additions in `static/js/index.js`: - machineDetail.cassetteEdits / .cassettesPristine / .cassettesDirty / .cassettesLoading / .cassettesPublishing / .cassettesError state - cassettesTable.columns (no pagination — small fleets) - cassettePublishConfirm.show - loadMachineCassettes — fetches + sets pristine snapshot - markCassetteDirty — compares to pristine, toggles _dirty + the overall cassettesDirty flag - revertCassetteEdits — deep-clone pristine back into edits - openCassettePublishConfirm — opens the modal - submitCassettePublish — builds PublishCassettesPayload from edits, POSTs to /machines/{id}/cassettes/publish, refreshes from the response, closes modal on success, surfaces 400/503 errors in the inline banner reloadMachineDetail now also calls loadMachineCassettes so the Cassettes tab is pre-populated and tab-switching is flicker-free. viewMachine resets the cassette state (edits, pristine, dirty, error, activeTab) on each open. This is the final commit in the #29 v1 chain. PR #30 is ready for review once the build + manual smoke pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| f8042f8e4d |
feat(v2): POST cassettes/publish API endpoint + ownership guard (#29 v1)
Some checks failed
ci.yml / feat(v2): POST cassettes/publish API endpoint + ownership guard (#29 v1) (pull_request) Failing after 0s
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>
|
|||
| e57a73083e |
feat(v2): bootstrap consumer task — auto-populate cassette_configs (#29 v1)
Some checks failed
ci.yml / feat(v2): bootstrap consumer task — auto-populate cassette_configs (#29 v1) (pull_request) Failing after 0s
Long-running task wired into satmachineadmin_start that subscribes to kind-30078 bitspire-cassettes-state:<atm_pubkey_hex> events from every active machine's ATM and upserts cassette_configs via apply_bootstrap_state on receipt. Pairs with bitspire's one-shot bootstrap publish in aiolabs/lamassu-next#56 — operator's first config publish then validates against a non-empty denomination set. Pattern mirrors wait_for_paid_invoices (try/except per event, never lets the loop die). Uses the same nostr_client.relay_manager singleton that cassette_transport.publish_to_atm uses, just on the subscribe side. Implementation: poll the singleton NostrRouter.received_subscription_events dict keyed by our subscription_id (satmachineadmin-cassette-bootstrap). This is the same drain pattern nostrclient's per-WebSocket NostrRouter uses; since we use a distinct sub_id, no cross-contamination with WebSocket-connected clients of nostrclient. Filter is re-derived from active machines each tick — newly-added machines start receiving bootstrap events without an LNbits restart. Soft-fail surfaces (none crash the listener): - nostrclient extension not installed → log + 30s backoff - inbound event sig-verify fails → log + skip - sender pubkey not in dca_machines → log + skip (relay noise) - operator privkey not on file → log + skip - NIP-44 v2 decrypt / payload validation fails → log + skip - apply_bootstrap_state error → log + skip Per-event handler routes to the right operator's privkey by looking up the machine via get_machine_by_atm_pubkey_hex (O(N) over active machines — fine for small fleets; if fleets grow, normalize machine_npub at write + add an index). CRUD additions: - list_all_active_machines: cross-operator query for the subscription filter - get_machine_by_atm_pubkey_hex: route inbound events to the right machine row + operator account; accepts hex or bech32 storage 14 tests in test_cassette_state_consumer.py covering: - decrypt_and_parse_state_event happy path + 6 negative paths (tamper, wrong privkey, malformed pubkey, missing fields, garbage JSON, wrong-shape payload) - d-tag construction regression guard (REGRESSION GUARD: d-tag uses ATM hex pubkey not internal UUID — pins the load-bearing detail from coord-log 11:50Z) - build_state_d_tags_for_machines + bech32 → hex canonicalisation Full handler dispatch (verify_event → get_machine_by_atm_pubkey_hex → apply_bootstrap_state) needs a live LNbits DB; smoke-tested manually per the existing project convention. Total: 146 passed, 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> |
|||
| b9d5ea3c57 |
feat(v2): cassette_transport — kind-30078 publish + decrypt (#29 v1)
Some checks failed
ci.yml / feat(v2): cassette_transport — kind-30078 publish + decrypt (#29 v1) (pull_request) Failing after 0s
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 commits
|
|||
| da07bae554 |
feat(v2): hand-rolled NIP-44 v2 crypto + reference-vector tests (#29 v1)
Some checks failed
ci.yml / feat(v2): hand-rolled NIP-44 v2 crypto + reference-vector tests (#29 v1) (pull_request) Failing after 0s
LNbits ships only NIP-04 (AES-CBC) in lnbits.utils.nostr.encrypt_content,
but the locked design at #29 (paired with lamassu-next#56) wires kind-30078
cassette config with NIP-44 v2 content per the privacy-by-default
architecture (
|
|||
| 9b8008db1f |
feat(v2): cassette_configs CRUD + unit tests (#29 v1)
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>
|
|||
| 13684e7134 |
feat(v2): m007 cassette_configs schema + Pydantic models (#29 v1)
Add the operator-side schema for per-machine ATM cassette inventory (aiolabs/satmachineadmin#29). Schema choice mirrors the ATM-side denomination-as-key invariant audited at coord-log 2026-05-30T06:40Z across bitspire/atm-tui/src/db.zig:31, lamassu-next state-store.ts:54, and hal-service.ts:116/189 — every ATM layer keys on denomination, so the operator-side PK is (machine_id, denomination) to make duplicate-denomination payloads impossible at the schema boundary. Reserved nullable columns (state_count, state_at, state_event_id) hold the latest bitspire-cassettes-state:<atm_pubkey_hex> event the ATM publishes. v1 populates them on bootstrap-event receipt; v1 UI doesn't render reconciliation. v2 reconciliation UI consumes them without a migration. Pydantic models in this commit: - CassetteConfig — read model for a stored row - UpsertCassetteConfigData — operator-edit form (count and/or position) - CassettePayloadRow — one denomination's wire-format values - PublishCassettesPayload — the full kind-30078 content payload, bidirectional (operator → ATM and ATM → operator share the shape). Validates int-coerced denomination keys, positive ints, no duplicate positions, and exposes to_wire_dict() that re-stringifies keys for JSON compatibility. CRUD + transport + API + UI land in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |