244 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 9c4d2c1324 |
docs(security-pathway): flag kind:21001 CLINK collision + rotation
S3 settlement-receipt kind was provisionally 21001, but that kind is claimed by CLINK (Offers). Replace the speculative kind text in the cash-out diagram and the S3 row with an explicit DO-NOT-USE alert citing the 2026-06-02 collision, the aiolabs/satmachineadmin#44 rotation tracker, and the 22000-22099 target band. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 8a9aa00c20 |
Merge pull request 'feat(v2): operator fee-config Nostr publisher (closes #39)' (#43) from feat/fee-transport into v2-bitspire
Reviewed-on: #43 |
|||
| 794d7e5395 |
feat(v2): wire fee-config publish into machine + super-config triggers (#39 3/3)
Some checks failed
ci.yml / feat(v2): wire fee-config publish into machine + super-config triggers (#39 3/3) (pull_request) Failing after 0s
Three trigger points wire fee_transport.publish_fee_config into the satmachineadmin API endpoints per the #39 spec. All three soft-fail on transport errors — the underlying CRUD operation (machine create / update / super-config save) succeeds even when the publish couldn't reach the relay or the signer, and the operator can re-trigger by editing again. views_api.py: - api_create_machine — publishes always after create, even when operator fees default to 0/0 (the resulting super-only payload is what unblocks the ATM past its `awaiting-fees` maintenance gate). Reads super_config singleton; if absent (m001 should have inserted it, so this is an impossible state), skips the publish to avoid crashing create. - api_update_machine — publishes only when either operator_cash_*_fee_fraction is in the patch payload. Skip on name/location/wallet_id/is_active/fiat_code edits since those don't affect the fee model the ATM enforces (avoids unnecessary relay churn). - api_update_super_config — publishes to every active machine when either super fraction changes. Per-machine: that machine's operator_user_id is the signer (machines owned by different operators sign with different keys); each soft-fail is independent. Skip if only super_fee_wallet_id changed (no fee-model impact). Tests (9 cases, all green): - 3 create-machine triggers: default 0/0 operator fees still publishes super-only payload, nonzero operator fees publish full payload, None super_config short-circuits without crashing - 4 update-machine triggers: publishes on cash_in change, publishes on cash_out change, skips on name-only, skips on is_active-only - 2 super-config triggers: publishes per-active-machine signed by each machine's operator on fraction change, skips entirely on wallet-id-only change (with an assertion that list_all_active_machines is never called, proving the short-circuit path) 191/191 tests green. Layer 2 (#39) complete; ready for joint smoke once bitspire fixes the three deploy gaps from coord-log §2026-06-01T18:30Z (`relay.aiolabs.dev` default, `VITE_LNBITS_HTTP_URL` dead echo, operator-fees subscriber not running in maintenance state). Refs: aiolabs/satmachineadmin#37 (parent), #39 (closes Layer 2), aiolabs/lamassu-next#57 (Layer 3 consumer — blocked on bitspire-side gaps). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
|||
| 12f39226f0 |
feat(v2): fee_transport — kind-30078 publisher for operator fee config (#39 2/3)
Adds the second operator-pushed kind-30078 document type alongside cassette config (#29). Wire format locked at coord-log §2026-06-01T14:25Z. models.py: - FeePayloadComponents — producer-mandatory `components` sub-object with super + operator splits per direction. Consumer-optional in v1 but ships on every payload from this producer for audit + future- promo extensibility. - FeeConfigPayload — the wire-format envelope. Pydantic validators enforce: cash_*_fee_fraction in [0, 0.15] (cap per direction); |total - (super + operator)| < 1e-6 (consistency assert per the §07:33Z lnbits advisory, mirrored on bitspire's #57 consumer side); schema_version integer ≥ 1. fee_transport.py: - build_fee_payload(super_config, machine) — compose + validate in one call; returned payload is wire-shippable. Raises ValueError (via Pydantic) if the constructed totals violate the cap. That shouldn't happen in practice because the API guards in views_api._assert_machine_fee_cap_safe + _assert_super_config_cap_safe refuse cap-violating writes; if it does, refuse-to-publish rather than ship a malformed event. - publish_fee_config(machine, super_config, operator_user_id) — builds, encrypts, signs, publishes via the shared publish_encrypted_kind_30078 helper from nostr_publish. d-tag is `bitspire-fees:<atm_pubkey_hex>` per spec; recipient is the ATM npub canonicalised to hex; signer is the operator. - Soft-fail discipline matches cassette_transport.publish_to_atm — transport-layer errors (RelayUnavailable / SignerUnavailable / OperatorIdentityMissing) log WARN + return None so trigger callers (api_create_machine etc.) don't break on transient transport hiccups. Cap violations are NOT soft-fail since they indicate an API-guard bypass and need operator attention. Tests (18 cases, all green): - 9 FeeConfigPayload validator cases (well-formed accept, wire round- trip, cap violations per direction, exact-cap acceptance, sum/ components mismatch per direction, schema_version ≥ 1, zero-zero free-charge ATM) - 4 build_fee_payload composition cases (basic, asymmetric directions, super-only-no-operator default, cap violation at build time) - 5 publish_fee_config soft-fail discipline cases (relay unavailable, signer unavailable, operator identity missing, publish success with d-tag + recipient + payload-shape assertions, cap violation raises before reaching publish) 182/182 tests green. Refs: aiolabs/satmachineadmin#37 (parent), #39 (Layer 2), coord-log §2026-06-01T14:25Z (locked wire format), §2026-06-01T07:33Z (lnbits consistency-assert advisory). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
|||
| aeaee1f568 |
refactor(v2): extract kind-30078 publish primitives to nostr_publish.py (#39 1/3)
Layer 2 prep: a second consumer (fee_transport.py for #39) is about to land that uses the same operator-signer + NIP-44 v2 + nostrclient publish flow as cassette_transport.py. Extracting shared primitives now rather than duplicating ~100 lines. New `nostr_publish.py` module: - Error hierarchy: NostrPublishError base + OperatorIdentityMissing, SignerUnavailable, RelayUnavailable subclasses (all transport-layer failures, domain-agnostic). - `resolve_operator_signer(operator_user_id)` — fetch account + resolve to NostrSigner, with the can-sign + has-pubkey checks. - `sign_as_operator(operator_user_id, event)` — wrap signer.sign_event, set created_at before signing. - `nip44_encrypt_via_signer` + `nip44_decrypt_via_signer` — transitional LocalSigner → RemoteBunkerSigner cascade (bunker handles natively; LocalSigner falls back to hand-rolled NIP-44 v2 against the stored prvkey). - `publish_signed_event(signed)` — nostrclient relay-manager publish with lazy import + RelayUnavailable on missing extension. - High-level `publish_encrypted_kind_30078(operator_user_id, recipient_pubkey_hex, d_tag, payload)` — builds event, encrypts via signer, signs, publishes. The whole flow in one call; callers (cassette_transport, soon fee_transport) just specify domain. `cassette_transport.py`: - Imports from nostr_publish; CassetteTransportError becomes a subclass of NostrPublishError so existing catches still work. - `publish_to_atm` reduces to a thin wrapper that builds the cassette-specific payload + d-tag and delegates to `publish_encrypted_kind_30078`. - Consumer path (`decrypt_and_parse_state_event`) still owns cassette-specific decode/transient distinctions; uses imported `nip44_decrypt_via_signer`. - Re-exports OperatorIdentityMissing / SignerUnavailable / RelayUnavailable so views_api can keep importing from cassette_transport without change. `tasks.py` — cassette bootstrap consumer imports `resolve_operator_signer` from nostr_publish directly instead of the cassette_transport underscore-prefixed name. 164/164 tests green; behavior unchanged. Refs: aiolabs/satmachineadmin#37 (parent), #39 (Layer 2, this commit is prep). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
|||
| 52911af7b1 |
Merge pull request 'feat(v2): principal-based fee split + per-direction config (closes #38)' (#42) from feat/principal-based-fees into v2-bitspire
Reviewed-on: #42 |
|||
| 10f4b50ca5 |
feat(v2)(ui): per-direction fee inputs in super-config + machine modals (#38 5/5)
Some checks failed
ci.yml / feat(v2)(ui): per-direction fee inputs in super-config + machine modals (#38 5/5) (pull_request) Failing after 0s
Surfaces the new directional fee fields in the admin dashboard so
operators + the LNbits super can configure cash-in and cash-out fees
independently:
Templates (`templates/satmachineadmin/index.html`):
- Platform fee banner now shows both directional super fractions
side-by-side ("cash-in X% · cash-out Y% of each transaction's
principal"). Wording updated to "principal" not "commission" since
the math is now principal-based.
- Super-fee edit dialog: replaces the single q-input with two
(super_cash_in_fee_fraction + super_cash_out_fee_fraction); each
capped at 0.15 via max attr (visual hint; server enforces).
- Add-machine + edit-machine dialogs both gain operator_cash_in_fee_
fraction + operator_cash_out_fee_fraction inputs with the same 0.15
cap hint. Hint text mentions the "sits on top of platform fee, total
capped at 15% per direction" semantics so operators understand the
layering.
JS (`static/js/index.js`):
- superFeeDialog.data shape switches to the new directional fields.
- openSuperFeeDialog / submitSuperFee load + POST the new shape.
- _emptyMachineForm / _cleanMachineForm pass through operator
directional fields (Number-coerced, default 0).
- openEditMachineDialog / submitEditMachine include the operator fee
fields in the form data + PUT body.
- New computed `superAnyFee` drives the banner styling (sum of both
directional fractions — non-zero → blue active banner; zero → muted
grey "free instance" banner).
All Quasar UMD components use explicit close tags per the UMD-mode
parsing rule.
Migration carry-over verified in dev container: pre-m009
super_fee_fraction=0.33 backfilled to super_cash_in=0.33 +
super_cash_out=0.33 on migrate-up. Note this puts existing dev
instances above the new 15% cap; operators will see the cap
validation error on their next super-config save and must adjust to
≤0.15 per direction. Production aiolabs/server-deploy will land at
0.03 on both directions (well under cap).
164/164 tests green. Layer 1 (#38) complete; Layer 2 (#39) wire-
format publisher is the next milestone.
Refs: aiolabs/satmachineadmin#37 (parent), #38 (closes Layer 1).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
|||
| d9e8a04b8b |
feat(v2): record fee_mismatch_sats per settlement, Phase 1 (#38 4/5)
Phase-1 observability per coord-log §2026-06-01T07:00Z (option A locked: always record, no enforce_fee_match gate): fee_mismatch_sats = bitspire_fee_sats - (platform_fee_sats + operator_fee_sats) Positive = bitspire over-reported; negative = under-reported; zero = exact match. Recorded unconditionally on every settlement; WARN- logged via loguru only when |delta| > tolerance, where tolerance = max(1, int(principal_sats * 0.001)) — 1-sat floor with 0.1% relative ceiling. bitspire.py:parse_settlement: - Computes the delta after split_principal_based returns. - WARN log line carries bitspire_fee_sats / expected / delta / tolerance / principal / both fractions / tx_type / machine-npub prefix for triage queries. - Always stamps fee_mismatch_sats onto CreateDcaSettlementData. - Comment explains the pre-Layer-3 expectation: large deltas are expected while the ATM hardcodes 7.77% cash-out (aiolabs/lamassu- next#57); the data here will quiet once Layer 3 ships. crud.py:create_settlement_idempotent: extends the INSERT to persist the new column. Tests: - tests/conftest.py: `loguru_capture` fixture — loguru routes to a pre-bound stderr sink that pytest's caplog (stdlib only) misses and capsys can't see; the fixture adds a list-sink for the test's duration. Reusable for future log-behavior tests. - tests/test_fee_mismatch_recording.py: 8 cases covering exact-match zero delta, bitspire over- and under-reporting, the pre-Layer-3 large-delta scenario, within-tolerance silence, over-tolerance warning, diagnostic-fields presence in the WARN line, and the 1-sat floor on tiny-principal settlements. 164/164 tests green. Phase 2 (reject on out-of-tolerance) lands as a follow-up once observability data justifies the tighter posture. Refs: aiolabs/satmachineadmin#38 (Layer 1), coord-log §2026-06-01T07:00Z (lnbits advisory + option A lock). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
|||
| 1babdfbf06 |
feat(v2): principal-based fee split — fixes super under-payment (#38 3/5)
Replaces the broken fraction-of-fee math with fraction-of-principal, direction-aware. Pre-#38: super_fee_fraction was interpreted as `round(fee_sats * super_fraction)`, paying super ~13× below intent on every cashout since the bitspire wire-shape landed. Post-#38: super and operator shares are computed independently against principal using the per-direction fractions from SuperConfig + Machine. Per workspace CLAUDE.md "Backwards-compatibility on pre-public-launch code" (v2-bitspire hasn't shipped to users), no compat shims: - calculations.py: delete `split_two_stage_commission` (legacy fraction-of-fee). Keep `split_principal_based` as the sole split fn. - migrations.py m009: extend to also DROP the deprecated `super_fee_fraction` column after backfilling its value into the new directional fields. - models.py: drop `super_fee_fraction` from SuperConfig + UpdateSuperConfigData entirely. - bitspire.py parse_settlement: new signature takes `super_config: SuperConfig` instead of `super_fee_fraction: float`. Resolves directional fractions from super_config + machine by tx_type, then computes via split_principal_based. Raises SettlementInvariantError on unknown tx_type. - tasks.py: pass `super_config` through to parse_settlement; assert non-None (m001 inserts the singleton at install time — None is an impossible state). - partial-dispense ratio path in distribution.py is unchanged — still uses `settlement.platform_fee_sats / settlement.fee_sats` from the landed row, which is the right invariant (lock at landing) and independent of the per-direction config. Tests: - Rename `test_two_stage_split.py` → `test_operator_split_legs.py`. Drop the legacy-function test classes. Keep TestAllocateOperatorSplitLegs (still-production fn) and TestPartialDispenseSplitRatio (inline ratio math in distribution.py). - New `test_principal_based_fees.py`: pure-math tests for `split_principal_based` (six cases including a direct regression test pinning the pre-#38 bug at 240→3000 sats per 100k principal at 3% super), plus parse_settlement directional dispatch tests (cash-in routes through cash-in fractions; cash-out through cash-out; unknown tx_type raises; zero-zero free-charge ATM; cross- direction guard). Migration verified end-to-end via container restart: super_config columns post-m009 = id/super_fee_wallet_id/updated_at/ super_cash_in_fee_fraction/super_cash_out_fee_fraction (no super_fee_fraction). dca_machines + dca_settlements gained the expected new columns. 156/156 tests green. Refs: aiolabs/satmachineadmin#37 (parent), #38 (this layer). Closes the load-bearing super under-payment bug standalone. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
|||
| 4cd0041923 |
feat(v2): CRUD + per-direction fee cap validation (#38 2/5)
Wires the new directional fee fields through the write path and adds the 15%-per-direction cap guard at the API boundary. CRUD: - create_machine INSERT includes operator_cash_in_fee_fraction + operator_cash_out_fee_fraction (Pydantic default 0 covers existing callers). - update_machine + update_super_config already use generic update_data dict, so the new fields flow through without per-call changes. API boundary (views_api.py): - _assert_machine_fee_cap_safe(operator_in, operator_out) — pairs candidates against current super-config, rejects if (super_X + operator_X) > 0.15 for either direction. Called from api_create_machine + api_update_machine (with partial-PATCH semantics: unset fields keep the machine's current value). - _assert_super_config_cap_safe(new_super_in, new_super_out) — fetches every active machine; rejects with offending-machine name in the 400 detail if any (effective_super + operator) > cap. Called from api_update_super_config. Cap rounding: float arithmetic rounds (super + operator) to 4 decimals (DECIMAL(10,4) precision) before comparing, so the IEEE 754 surprise 0.10 + 0.05 = 0.15000000000000002 doesn't trip the cap. Tests (13 cases, all green): both directions hit the cap, exact-cap acceptance, no-super-config degenerate path, partial PATCH on super-config, offending-machine name in error detail, empty-fleet vacuous safety. Refs: aiolabs/satmachineadmin#38 (Layer 1), coord-log §2026-06-01T07:22Z (cap lock at 15% per direction, defense in depth). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
|||
| d87d0db324 |
feat(v2): m009 + models — split fee fractions by direction (#38 1/5)
Adds the schema delta + Pydantic mirror for per-direction fee configuration: - super_config gains super_cash_in_fee_fraction / super_cash_out_fee_fraction (backfilled from the deprecated singleton on migrate-up so live config preserves intent). - dca_machines gains operator_cash_in_fee_fraction / operator_cash_out_fee_fraction (default 0; operator-settable per machine via the upcoming UI). - dca_settlements gains fee_mismatch_sats BIGINT NULL — Phase-1 observability column per coord-log §2026-06-01T07:00Z (lnbits) + option A locked. - MAX_FEE_FRACTION_PER_DIRECTION = 0.15 lives in calculations.py as the single source of truth (defense-in-depth cap, mirrored on the consumer side per aiolabs/lamassu-next#57). Pydantic validators on the new fields keep [0, 1] range checks; the per-direction cap validation lives on the CRUD path in the next commit (needs cross-row context: super-config change must validate against all machines, machine change against current super-config). Closes one step of #38 (Layer 1 of the operator-configurable fee architecture, parent #37). Subsequent commits add CRUD, principal-based split math (fixes the load-bearing super under-payment bug), and the UI surface. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
|||
| e9f81d0cbb |
Merge pull request 'feat(v2): nostr-transport roster-resolver hook — path B wallet routing (#20)' (#36) from feat/roster-resolver into v2-bitspire
Reviewed-on: #36 |
|||
| 99efa52b69 |
feat(v2): nostr-transport roster-resolver hook (#20 path-B)
Some checks failed
ci.yml / feat(v2): nostr-transport roster-resolver hook (#20 path-B) (pull_request) Failing after 0s
Exposes `resolve(sender_pubkey_hex) -> RouteHit | None` and a
`register_with_lnbits()` helper that lazily-imports + soft-fails on
lnbits versions without `register_roster_resolver`. Wired into
`satmachineadmin_start()`.
The hook delivers the path-B outcome ("cash-out sats go to the
operator's wallet, not an auto-created machine wallet") once the
lnbits side ships its half. Shape contract `(operator_user_id,
wallet_id, source_extension)` frozen per coord-log 2026-05-31T15:25Z.
Branch held local until lnbits lands the registry — no behaviour
change on the current lnbits version, just the future-ready handoff
+ a benign INFO log on boot.
Boot-smoked in dev container: extension loads, registration logs the
documented soft-fail message, invoice listener + cassette consumer
unchanged. 6 new unit tests cover happy path, miss, bech32 +
uppercase canonicalisation, fail-closed on malformed input, and the
soft-fail register branch.
|
|||
| 213f95bab7 |
Merge pull request 'feat(v2): collision guard — refuse machines whose npub matches an operator account (closes #32)' (#33) from feat/collision-detection into v2-bitspire
Reviewed-on: #33 |
|||
| 7bab182abb |
chore(tests): remove stale test_init.py cookiecutter leftover
Some checks failed
ci.yml / chore(tests): remove stale test_init.py cookiecutter leftover (pull_request) Failing after 0s
The lone test (`test_router`) has been failing for the lifetime of the repo. It's `@pytest.mark.asyncio`-decorated but the project deliberately doesn't ship `pytest-asyncio` (newer tests use `asyncio.run` inside the body — see `test_cassette_state_consumer.py` header), so the test is uncollectable in our runner. The body asserts nothing the extension-load boot path doesn't already cover. After this: full suite is 155/155 green. Closes #34. |
|||
| 05c1105897 |
feat(v2): collision guard — refuse machines whose npub matches an operator account (#32)
Some checks failed
ci.yml / feat(v2): collision guard — refuse machines whose npub matches an operator account (#32) (pull_request) Failing after 0s
Adds `_assert_no_pubkey_collision` to `views_api`, wired into `api_create_machine` between the wallet-ownership guard and the `create_machine` CRUD call. Refuses with HTTP 400 + operator-actionable error message if the supplied `machine_npub` matches any existing LNbits operator account's `accounts.pubkey`. ## Why this matters Reproducer 2026-05-30T21:33Z (coord-log archive `2026-05-31-pre-rotation.md`): Greg's operator account `accounts.pubkey` had been seeded as the same value as Sintra's `dca_machines.machine_npub` (`522a4538…`) during manual setup. The collision masked the routing bug for days — lnbits' nostr-transport `auth.py:resolve_nostr_auth` was routing inbound kind-21000 RPCs from the ATM directly to Greg's wallet *by coincidence* of the matching pubkey. When Greg's account migrated to `RemoteBunkerSigner` and got a fresh pubkey, the coincidence broke + `auto-account-from-npub` fired for the orphaned ATM npub. A real $20 test cash-out silently landed on a fresh auto-account wallet (`a94b564f…`); satmachineadmin lost the settlement entirely — no `dca_settlements` row, no DCA distribution, no commission split. The proper architectural fix is path B / `aiolabs/satmachineadmin#20` (S6, in-progress with lnbits — coord-log `2026-05-31T15:25Z`). This guard is the complementary preventive layer: stops a future operator from re-entering the broken state by registering a machine whose npub collides with an existing account. ## What's in this commit - **`views_api._assert_no_pubkey_collision`** — canonicalises the input npub (accepts hex or `npub1…` bech32) via `normalize_public_key`, queries `lnbits.core.crud.users.get_account_by_pubkey` (which itself lowercases internally), raises HTTPException(400) on hit. Error message names the canonical pubkey prefix, explains the pubkey-collision dependency that breaks on operator pubkey rotation, + points to the `lamassu-next provision-atm` remediation path + this issue for context. - **Wired into `api_create_machine`** after `_assert_wallet_owned_by` + before `create_machine`. `api_update_machine` is unaffected because `UpdateMachineData` doesn't allow npub changes on existing rows. - **`tests/test_collision_guard.py`** — 7 unit tests covering hex / bech32 / uppercase-hex inputs all canonicalise to the same lookup, the no-collision case returns silently, error message asserts (truncated pubkey + remediation hint). Uses pytest monkeypatch to isolate the assertion logic from a live `get_account_by_pubkey` DB call — matches the assertion-style pattern of `tests/test_nostr_attribution.py`. - **`CLAUDE.md`** — new "No-collision invariant" subsection under Security Considerations: documents the rule + the SQL check operators can run on existing installs + the `ATM_PRIVATE_KEY`-unset remediation + cross-refs to `#20` and `#32`. ## Regtest SQL check result Ran the diagnostic SQL against the regtest LNbits + satmachineadmin DBs: - 1 active `dca_machines.machine_npub`: `522a4538…` (Greg's Sintra) - 1 collision found: the auto-account orphan `a94b564f…` (username = None — auto-account signature) created during yesterday's silent-drop failure mode. NOT a legitimate operator account. Greg's actual operator account `ac35c9fc…` carries pubkey `197a4cf4…` post-bunker migration, no collision there. The orphan is operational cleanup (sweep + delete), separate from this code fix. No real-operator collisions remain on the regtest instance. ## Test status 162 passed, 1 pre-existing async-plugin failure unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 44f6c0b1bd |
Merge pull request 'feat(v2): operator-side cassette inventory v1.1 + signer.nip44_* migration (#29)' (#30) from feat/cassette-config-v1 into v2-bitspire
Reviewed-on: #30 |
|||
| 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> |
|||
| 58a0974117 |
chore: ignore uv.lock until PEP 621 migration
uv.lock is a header-only file (no deps pinned) because pyproject.toml still uses [tool.poetry] tables that uv can't read. Ignore for now. Real fix tracked at aiolabs/satmachineadmin#28. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| cf6c0b4b7a |
docs: security pathway write-up + printable PDF
Adds the bitSpire ↔ LNbits security pathway document drafted at the start of v2 hardening — state-of-the-union, threat model, audit findings, and the layered Nostr-native defence proposal (S0–S8). Markdown source + printable A4 PDF + the CSS used by pandoc to render. Linked from MEMORY index for future sessions to consult when reviewing security work. Carries the original Sprint-1 plan (NIP-26 delegation, NIP-40 expiration, NIP-78 fleet roster, etc.); subsequent work pivoted NIP-26 → NIP-46 (bunker) per lnbits#18 and ripped out the public NIP-78 publishing per the privacy-by-default operator preference. Treat the doc as a frozen snapshot of the design at v1 — the architectural framing remains useful even where individual sub-issues have moved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| ecf432c6a0 |
feat(v2)(ui): tx_type chip in operator settlements table (S8 UI)
Adds a "Direction" column to the per-machine settlements table that
renders a coloured Quasar chip with a directional icon:
- cash-out (green-8, south_west arrow) — customer paid ATM invoice in
BTC, operator wallet received sats. Principal distributes to LPs.
- cash-in (orange-8, north_east arrow) — customer redeemed LNURL-
withdraw at the ATM, operator wallet sent sats. No DCA leg;
liquidity stays in the operator wallet.
Tooltips spell out the meaning so the operator doesn't have to
remember the canonical mapping (cash_out ↔ inbound, cash_in ↔ outbound)
on sight. Defaults to cash_out for any unknown / legacy row, which is
safe because pre-S6 rows are all cash_out and the rejection-record
path also stamps cash_out.
Closes the UI half of aiolabs/satmachineadmin#22 (S8 cash-in path);
the structural half (direction discriminator + DCA skip) shipped in
|
|||
| eca6e961b7 |
feat(v2): wire cash-in routing — direction discriminator + DCA skip
Structural half of S8 (aiolabs/satmachineadmin#22). Listener now accepts BOTH inbound and outbound payments instead of filtering on `is_in=True`; distribution gates the DCA leg on tx_type so the liquidity-flow direction at the ATM drives behaviour, not the Lightning protocol direction at the operator's wallet. tasks.py: - Drop the `if not payment.is_in` pre-filter; keep `payment.success`. - Pair-name the two axes (`is_lightning_inbound`/`_outbound` for protocol vs `tx_type ∈ {cash_out, cash_in}` for business) per the naming-inversion memory. - Outbound payments need `extra.source == "bitspire"` before we touch them — without it we can't tell the operator paying their landlord from a cash-in settlement; skip silently. - Cross-axis sanity gate: refuse to process when protocol direction disagrees with business direction (cash_out must be inbound, cash_in must be outbound). Catches a buggy/malicious upstream stamping `type=cash_out` on an outbound payment. distribution.py: - Gate `_pay_dca_distributions` on `tx_type == "cash_out"`. Cash-in liquidity stays in the operator's wallet — there's no LP share to distribute. Skipped leg is written as an audit row via `_record_skipped_leg` so the dashboard surfaces "DCA intentionally skipped" instead of a phantom missing leg. Still pending in S8: the UI marker (cash_in tx_type chip in the operator settlements table) and end-to-end test against a real LNURL-withdraw redemption. Tests: 75 passed (no regression vs prior green state; `test_router` remains a pre-existing pytest-asyncio plugin issue). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| dcd08748a7 |
revert(v2): drop NIP-78 fleet publishing (privacy by default)
Pulls the kind:30078 per-machine config + fleet roster publish path
introduced at
|
|||
| e13178d3ac |
feat(v2): route nostr_publish signing through lnbits#17 signer abstraction (hybrid)
Responds to the lnbits session's 19:30Z coordination-log flag: PR #17
will NULL `accounts.prvkey` on cascade via the m002 classify job, which
would break the S4 fleet-roster publishing path (`
|
|||
| 131ff92aa8 |
feat(v2): publish operator-signed kind:30078 fleet roster + per-machine config (S4)
Closes aiolabs/satmachineadmin#18 (S4 — NIP-78 per-machine config +
fleet roster). On every machine create/update/delete, publish two
operator-signed kind:30078 (NIP-78 addressable) events via the
`nostrclient` LNbits extension:
- `bitspire-config:<machine_id>` — per-machine config event, one
per machine. Tagged with `p=<atm_npub>` so external observers
can filter by ATM pubkey: `{"#p": ["<atm_npub>"]}`.
- `bitspire-fleet` — aggregate roster across the operator's
active fleet. Lists every machine's atm_pubkey + display fields.
Tagged with `p=<atm_npub>` per active machine.
Delete path tombstones the per-machine config (replaceable kind:30078
with `content.deleted=true`) and re-publishes the roster without the
machine — external readers see the tombstone OR the absence from the
roster.
Implementation choice — direct in-process singleton import (path b
from the pre-flight check, not the WebSocket path a):
from nostrclient.router import nostr_client
nostr_client.relay_manager.publish_message(json.dumps(["EVENT", e]))
Bypasses the public/private WebSocket entirely. Cleaner than going
through `wss://localhost/nostrclient/api/v1/<encrypted_ws_id>`. Same
cross-extension import pattern lnbits core uses for
nostrmarket.services + nostrrelay.crud (guarded by try/except).
Soft-failure throughout:
- nostrclient extension not installed → log warning + skip.
- Operator account has no Nostr keypair on file (account never went
through Nostr-login flow, or post-bunker future where nsec is
moved off-disk per lnbits#18) → log warning + skip.
- The settlement / distribution path does NOT depend on the publish
— these events exist for external observers, not internal flow
control.
Out of scope (intentionally):
- ATM-side consumer in lamassu-next (forward-looking, will read
`#p=<atm_npub>` to learn its operator's config).
- LNbits-server-side roster-gating in the nostr-transport handler
(S6 / lnbits#14 Item 3 — needs satmachineadmin to publish first;
this commit lays the groundwork).
- Operator's NIP-65 relay list as the publish target (today we use
whatever nostrclient is configured with; future per-operator
relay lists can live on accounts.relays or similar).
m006 (the canonical-vocabulary rename migration shipped at
|
|||
| d717a6e214 |
refactor(v2): canonical sat-amount vocabulary + delete Lamassu-era reverse-derivation
Cross-codebase decision logged at memory `reference_sat_amount_vocabulary.md`
and at `~/dev/coordination/log.md` (2026-05-26). Canonical names with
explicit units across satmachineadmin, lamassu-next, atm-tui:
- `wire_sats` — actual Lightning payment amount (direction-agnostic;
was `gross_sats`, only "gross" for cash-out)
- `principal_sats` — market-rate sats before commission (unchanged)
- `fee_sats` — commission (was `commission_sats` internally;
already the wire format)
- `fee_fraction` — commission rate as unit fraction in [0, 1]
(was `*_pct` / `fee_percent`; eliminates the
latent 100x bug from `feePercent * 100` on the
lamassu-next side)
Invariants enforced in bitspire._assert_sat_invariants on every
parsed settlement — range (all sats >= 0, 0 <= fee_fraction <= 1) +
direction-specific sum:
- cash-out: wire_sats == principal_sats + fee_sats
- cash-in: wire_sats == principal_sats - fee_sats
AND fee_sats <= principal_sats
Breaches raise SettlementInvariantError; tasks._handle_payment
records the row as `status='rejected'` with the exception message
and skips distribution. Attribution failure path symmetric.
Schema changes (m001 + m006):
- dca_settlements.gross_sats -> wire_sats
- dca_settlements.commission_sats -> fee_sats
- super_config.super_fee_pct -> super_fee_fraction
- dca_commission_splits.pct -> fraction
- dca_machines.fallback_commission_pct DROPPED (obsolete)
- dca_settlements.used_fallback_split DROPPED (obsolete)
m006 idempotently renames + drops columns on existing installs;
m001 lays down the canonical schema for fresh installs.
Obsolete code removed (Lamassu-era reverse-derivation):
- calculations.calculate_commission — back-derived principal+fee
from gross-with-commission-baked-in. v2 stamps both directly.
- calculations.calculate_exchange_rate — bitSpire stamps directly.
- bitspire._parse_fallback — sole caller of calculate_commission.
- Machine.fallback_commission_fraction — only read by _parse_fallback.
- DcaSettlement.used_fallback_split — only written by _parse_fallback.
parse_settlement now raises SettlementMetadataError if Payment.extra
lacks the bitSpire stamp or required absolute sat fields. No silent
back-derivation; upstream-bug surfacing via dashboard rejection.
Frontend (JS + Quasar templates) updated for the column renames and
the removed fallback fields. Settlements table renders "Wire" + "Fee"
columns; the "(fallback split)" warning badge is gone.
Tests:
- test_calculations.py: kept distribution tests; deleted
calculate_commission + calculate_exchange_rate tests.
- test_two_stage_split.py: renamed variables; rewrote docstring
value literals (e.g. `super_fee_fraction=0.30` not `=30%`).
- test_nostr_attribution.py: dropped fallback_commission_fraction
from machine fixture.
- 72/72 pass on regtest container.
Cross-codebase follow-ups tracked in coordination log:
- lamassu-next: rename `fee_percent` -> `fee_fraction` on
Payment.extra + state.db; drop the `* 100` at lightning.ts:780.
- atm-tui: read `fee_fraction` column in db.zig.
Memory artefacts:
- reference_sat_amount_vocabulary.md (canonical + invariants)
- feedback_pct_to_fraction_renames_need_value_sweep.md (gotcha)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 6348c55e37 |
fix(v2)(ui): split v-text from <q-chip> children in deposit dialog
Vue compile error 56: `v-text` on an element with explicit children
(the `<q-tooltip>` slot) is conflicting — v-text replaces innerHTML,
so the tooltip would be silently discarded and Vue refuses to compile
the template at all.
Move the currency-code text into a `<span v-text="...">` sibling of
the `<q-tooltip>` inside the chip. Same render output; valid template.
Regression from
|
|||
| d2e682712d |
feat(v2): lock deposit currency to machine.fiat_code (closes #26)
Each machine handles exactly one currency today (operator-set on
`dca_machines.fiat_code`). The deposit's currency is fully determined
by the machine it's recorded against, so it shouldn't be operator-
chooseable in the first place.
Surfaced during 2026-05-16 E2E testing: Jordan had a "15 USD" deposit
recorded against an EUR Sintra (operator typo in the freeform currency
input). The balance summary is currency-blind (`SUM(amount)` over
mixed currencies), so on the next cash-out the system distributed
15 EUR worth of sats on the strength of that 15 USD row. Worked out
by chance; could have over-paid by ~10% if the actual EUR/USD rate
had been further off.
Fix:
- `CreateDepositData` / `UpdateDepositData` no longer carry a
`currency` field. Any client-submitted value is silently dropped
at Pydantic validation, before reaching the handler.
- `api_create_deposit` resolves the machine's `fiat_code` and
passes it to `create_deposit(..., currency=...)` as a required
keyword arg. The deposit row's `currency` column always matches
the machine going forward.
- UI: the freeform `<q-input label="Currency">` becomes a read-only
`<q-chip>` slot on the amount field, sourced from the new
`depositMachineFiatCode` computed (resolves via the selected
client's machine).
- `m005_lock_deposit_currency_to_machine_fiat_code` migration
backfills existing rows: every `dca_deposits.currency` gets
rewritten to match its joined `dca_machines.fiat_code`. Greg's
stray `15 USD` row becomes `15 EUR` (the right answer at today's
invariant).
Multi-currency-per-machine support is explicitly out of scope here;
when hardware ships that reads multiple denominations across
currencies, the relevant changes are documented in issue #26's
"Future" section (dca_machines.fiat_codes set, currency-aware
balance summary, etc.). The current fix is "lock the input side";
that future work is "unlock it but constrained to the machine's
declared set".
3 new unit tests (`tests/test_deposit_currency.py`) lock in the
model-contract guarantees. Total suite 89 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| da25d2e1f8 |
fix(v2): read fiat_amount directly from Payment.extra (bill-validator truth)
Pairs with aiolabs/lamassu-next@8318489 which now stamps the customer- transacted fiat amount as a top-level field on Payment.extra, sourced directly from bitSpire's bill validator / dispenser ledger. Previously `_parse_extra` computed `fiat_amount = gross_sats / exchange_rate` (which is wrong — that's the fiat-equivalent of the gross including commission, not the customer's transaction value) or `principal_sats / exchange_rate` (close but assumes commission lives entirely in BTC and accumulates rounding from floor() in the bitSpire-side principalSats calc). Both are derivations from adjacent quantities; the bill validator already knows the answer. Now: read `extra.get("fiat_amount")` verbatim. Source of truth ends up on the settlement row exactly as the machine recorded it. Surfaced during the 2026-05-16 cash-out E2E test: 20 EUR customer transaction was rendering as 21.55 EUR in the Fiat column — that 21.55 was the fiat-equivalent of the gross sats including commission, not the cash that physically came out of the machine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| cfad4e341c |
feat(v2)(ui): operator-side LP UI matches the new dca_lp authority split
Operator no longer chooses the LP's wallet / DCA mode / autoforward — those belong to the LP, written via satmachineclient. The Add LP / Edit LP dialogs reduce to (machine, user_id, optional username, status). The clients table loses the wallet / mode / autoforward columns and gains an "Onboarded" column showing whether the LP has a `dca_lp` row yet (server-side LEFT JOIN; `DcaClient.lp_onboarded`). Deposit creation gate (the structural enforcement of "must onboard first"): - Picker annotates each LP option with "— pending onboarding" and disables un-onboarded LP rows. - Selecting an un-onboarded LP shows an inline deep-orange banner explaining the LP needs to open satmachineclient once. - The Record button is `:disable`d in that state. The backend refuses with HTTP 422 anyway (see previous commit) — UI is just the first line of feedback. Backend wiring: - `DcaClient` model gains `lp_onboarded: bool = False`, populated at SELECT time via a shared `_CLIENT_SELECT` / `_CLIENT_FROM` fragment that LEFT JOINs `dca_lp` on `user_id`. All four list/ single-row read paths use it: by-id, by-(machine,user), by-machine, by-operator, by-user. No extra round-trip per row. - CSV export drops the removed columns; adds `lp_onboarded`. All 86 unit tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
|||
| 80b5a6d785 |
refactor(v2): hoist LP state (wallet, mode, autoforward) into dca_lp table
LP-level preferences were denormalised across every `dca_clients` row
of a given user. Every LP enrolment carried its own wallet_id /
dca_mode / fixed_mode_daily_limit / autoforward_ln_address /
autoforward_enabled — and satmachineclient's `update_lp_autoforward`
did a multi-row UPDATE to keep them in sync. That sync dance was the
smell: user-level intent stored at machine-enrolment granularity.
New shape:
dca_lp (user_id PK, dca_wallet_id, default_dca_mode,
fixed_mode_daily_limit, autoforward_ln_address,
autoforward_enabled, ...)
dca_clients (id, machine_id, user_id, username, status, ...)
// pure (machine, LP) enrolment — wallet/mode/autoforward gone
Authority split:
- LP writes dca_lp via satmachineclient (Phase 2, separate commit).
- Operator writes dca_clients via satmachineadmin. They cannot
choose the LP's destination wallet — it's resolved from dca_lp
at distribution time. Better trust hygiene.
Onboarding gate:
- `api_create_deposit` refuses (HTTP 422) when the target LP has
no dca_lp row. Forces every LP through a "yes, I am here and
this is where I want my sats" gesture via satmachineclient
before any fiat starts accumulating against them.
Schema:
- m001 canonical schema updated: slim `dca_clients`, new `dca_lp`.
Fresh installs land here directly.
- m004 idempotent migration for installs that already have the
legacy `dca_clients.wallet_id` column: creates dca_lp,
backfills from the latest dca_clients row per user (window
function), then DROP COLUMN on the moved fields. Greg's live
test data survives the upgrade.
Distribution:
- `get_flow_mode_clients_for_machine` INNER JOINs dca_lp so
un-onboarded LPs are filtered out (no destination wallet).
- `_pay_one_dca_leg`, `_attempt_autoforward`, `settle_lp_balance`
all fetch `dca_lp` via the new `get_dca_lp(user_id)` helper.
Wallet + autoforward read from prefs, not from client.
Models:
- `DcaClient` loses 5 fields. `CreateDcaClientData` reduces to
(machine_id, user_id, username). `UpdateDcaClientData` keeps
only operator-controlled fields (username, status).
- New `DcaLpPreferences` + `UpsertDcaLpData` models for the
per-user surface (satmachineclient writes these in Phase 2).
CRUD:
- New: `get_dca_lp`, `lp_is_onboarded`, `upsert_dca_lp` (the
latter takes a `fallback_wallet_id` for first-onboarding when
satmachineclient auto-seeds from the LP's default LNbits wallet).
- `create_dca_client` insert reduces to the new column set.
Tests: 86 unit tests still green.
Next:
- Phase 1c (this repo): UI simplification for operator's
Add/Edit LP dialogs + deposit-gating UX.
- Phase 2 (satmachineclient): own dca_lp writes + auto-init with
the LP's default LNbits wallet on first dashboard visit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|||
| 1feaba80ed |
refactor(v2): rename net_sats → principal_sats for semantic clarity
`net` is financial-accounting ambiguous (net of what?). In the
bitSpire/DCA context this column is specifically the principal the
operator distributes to LPs (gross − commission), not a generic net
amount. Renaming locally before any bitSpire firmware locks the
wire-level name; lamassu-next#44 should adopt the same name.
Scope:
- migrations.py: m003 ALTER TABLE … RENAME COLUMN, idempotent probe
pattern matching m002. Also updates the m001 canonical schema so
fresh installs land on the new column directly.
- models.py: `CreateDcaSettlementData.principal_sats` /
`DcaSettlement.principal_sats`. Field-doc comment updated.
- bitspire.py: both happy path and fallback path return
`principal_sats=…`. Reads `extra.get("principal_sats")` from the
bitSpire payload (lamassu-next#44 should follow this rename).
- crud.py: INSERT column list + `apply_partial_dispense(
new_principal_sats=…)` keyword.
- distribution.py: every `settlement.net_sats` → `settlement.
principal_sats`; partial-dispense memo + helper signatures updated;
the leg-order docblock at the top reads "principal_sats".
- tasks.py: landed-settlement log line.
- static/js/index.js: settlements-table column `principal_sats` with
label "Principal (→ LPs)".
- templates/satmachineadmin/index.html: q-td key + binding.
All 86 unit tests still pass. No backwards-compat shim — v2-bitspire
isn't released; the rename is a clean break.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|