feat(v2): operator fee-config Nostr publisher (closes #39) #43

Merged
padreug merged 3 commits from feat/fee-transport into v2-bitspire 2026-06-01 18:20:09 +00:00
Owner

Summary

Layer 2 of the operator-configurable fee architecture (parent: #37). Publishes per-machine fee config to bitspire ATMs via NIP-44-v2-encrypted kind-30078 events, pairing with the consumer at aiolabs/lamassu-next#57.

Wire format locked at coord-log §2026-06-01T14:25Z:

{
  "schema_version": 1,
  "cash_in_fee_fraction": 0.0633,
  "cash_out_fee_fraction": 0.1077,
  "components": {
    "super_cash_in": 0.03,
    "super_cash_out": 0.03,
    "operator_cash_in": 0.0333,
    "operator_cash_out": 0.0777
  }
}

Commits

  1. aeaee1f — Refactor: extract shared kind-30078 publish primitives from cassette_transport.py into a new nostr_publish.py module. Two consumers now (cassette + fee), so ~100 lines of duplicate was about to land. Generic helpers (resolve_operator_signer, sign_as_operator, nip44_encrypt_via_signer, nip44_decrypt_via_signer, publish_signed_event) + a high-level publish_encrypted_kind_30078 wrapper. CassetteTransportError becomes a subclass of the new NostrPublishError; existing catches still work via re-export. Behavior unchanged; 164/164 green.

  2. 12f3922FeeConfigPayload + FeePayloadComponents Pydantic models (Pydantic validators enforce the locked invariants: cap ≤ 0.15 per direction, |total - components_sum| < 1e-6 consistency assert, schema_version ≥ 1). fee_transport.py publisher with build_fee_payload(super_config, machine) and publish_fee_config(machine, super_config, operator_user_id). Soft-fail discipline: transport errors log WARN + return None; cap violations hard-raise (indicates an API-guard bypass, not a transient).

  3. 794d7e5 — Wire publish_fee_config into the three trigger endpoints per the #39 spec. All three soft-fail on transport errors — underlying CRUD always succeeds.

Triggers

Endpoint Publish when Skip when
api_create_machine Always after create super_config singleton missing (impossible-state guard)
api_update_machine Either operator_cash_*_fee_fraction in patch Only name/location/wallet/is_active/fiat_code changed
api_update_super_config Either super_cash_*_fee_fraction changed → fleet-wide republish, each machine signed by its own operator Only super_fee_wallet_id changed

Test plan

  • 191/191 tests green (164 baseline + 27 added across the three commits)
  • New test_fee_transport.py — 9 FeeConfigPayload validator cases (well-formed, 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, 5 publish_fee_config soft-fail discipline cases (relay/signer/operator-identity-missing soft-fail, success returns signed event with correct d-tag + recipient + payload shape, cap violation raises before reaching publish)
  • New test_fee_publish_triggers.py — 9 trigger-integration cases covering all three endpoints (publish-on-create with default + nonzero fees, no-super-config short-circuit, publish-on-cash_in-change + publish-on-cash_out-change for update, skip-on-name-only + skip-on-is_active-only for update, fleet-wide publish-per-active-machine on super fraction change with per-machine operator signing, skip-on-wallet-id-only with assertion that list_all_active_machines is never called)
  • Refactor passes existing 164 tests unchanged — cassette_transport.publish_to_atm is now a thin wrapper over the shared helper
  • Joint smoke with physical cash-out — still blocked on the three bitspire-side aiolabs/lamassu-next#57 deploy gaps from coord-log §2026-06-01T18:30Z (relay.aiolabs.dev hardcoded default points at nothing; dead VITE_LNBITS_HTTP_URL echo; operator-fees subscriber doesn't run during awaiting-fees maintenance state). Layer 2 is fully autonomous from bitspire on the publish side — events land on the relay with the correct shape; consumer side fixes are bitspire's lane.

Strict-from-the-start

Per workspace CLAUDE.md "Backwards-compatibility on pre-public-launch code": no compat shims. The cassette_transport refactor removes 5 internal underscore-prefixed helpers in favor of shared public ones (one external caller in tasks.py migrated to import from nostr_publish directly).

Sequencing — what now

  • Joint smoke unblocks once bitspire's #57 deploy gaps are fixed. The publisher already produces correctly-formed events; the issue is on the consumer side (Sintra not subscribing during maintenance + the wrong relay URL).
  • enforce_fee_match Phase 2 (settlement-reject on out-of-tolerance) lands as a follow-up after observability data justifies the tighter posture.
  • republish_operator_configs helper (#41) becomes implementable on top of the fee_transport + cassette_transport primitives — same shape for both doc types.

Out of scope (follow-ups)

  • Phase 2 enforce_fee_match — #38 follow-up
  • #41 republish_operator_configs migration-cascade helper
  • Background catch-up republish task (the spec called this "out of scope for v1" — soft-fail + manual re-trigger via machine edit is the v1 recovery story)
  • Layer 3 bitspire fixes — aiolabs/lamassu-next#57

🤖 Generated with Claude Code

## Summary Layer 2 of the operator-configurable fee architecture (parent: #37). Publishes per-machine fee config to bitspire ATMs via NIP-44-v2-encrypted kind-30078 events, pairing with the consumer at `aiolabs/lamassu-next#57`. Wire format locked at coord-log §`2026-06-01T14:25Z`: ```json { "schema_version": 1, "cash_in_fee_fraction": 0.0633, "cash_out_fee_fraction": 0.1077, "components": { "super_cash_in": 0.03, "super_cash_out": 0.03, "operator_cash_in": 0.0333, "operator_cash_out": 0.0777 } } ``` ## Commits 1. **`aeaee1f`** — Refactor: extract shared kind-30078 publish primitives from `cassette_transport.py` into a new `nostr_publish.py` module. Two consumers now (cassette + fee), so ~100 lines of duplicate was about to land. Generic helpers (`resolve_operator_signer`, `sign_as_operator`, `nip44_encrypt_via_signer`, `nip44_decrypt_via_signer`, `publish_signed_event`) + a high-level `publish_encrypted_kind_30078` wrapper. `CassetteTransportError` becomes a subclass of the new `NostrPublishError`; existing catches still work via re-export. Behavior unchanged; 164/164 green. 2. **`12f3922`** — `FeeConfigPayload` + `FeePayloadComponents` Pydantic models (Pydantic validators enforce the locked invariants: cap ≤ 0.15 per direction, `|total - components_sum| < 1e-6` consistency assert, schema_version ≥ 1). `fee_transport.py` publisher with `build_fee_payload(super_config, machine)` and `publish_fee_config(machine, super_config, operator_user_id)`. Soft-fail discipline: transport errors log WARN + return None; cap violations hard-raise (indicates an API-guard bypass, not a transient). 3. **`794d7e5`** — Wire `publish_fee_config` into the three trigger endpoints per the #39 spec. All three soft-fail on transport errors — underlying CRUD always succeeds. ## Triggers | Endpoint | Publish when | Skip when | |---|---|---| | `api_create_machine` | Always after create | super_config singleton missing (impossible-state guard) | | `api_update_machine` | Either `operator_cash_*_fee_fraction` in patch | Only name/location/wallet/is_active/fiat_code changed | | `api_update_super_config` | Either `super_cash_*_fee_fraction` changed → fleet-wide republish, each machine signed by its own operator | Only `super_fee_wallet_id` changed | ## Test plan - [x] 191/191 tests green (164 baseline + 27 added across the three commits) - [x] New `test_fee_transport.py` — 9 FeeConfigPayload validator cases (well-formed, 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, 5 publish_fee_config soft-fail discipline cases (relay/signer/operator-identity-missing soft-fail, success returns signed event with correct d-tag + recipient + payload shape, cap violation raises before reaching publish) - [x] New `test_fee_publish_triggers.py` — 9 trigger-integration cases covering all three endpoints (publish-on-create with default + nonzero fees, no-super-config short-circuit, publish-on-cash_in-change + publish-on-cash_out-change for update, skip-on-name-only + skip-on-is_active-only for update, fleet-wide publish-per-active-machine on super fraction change with per-machine operator signing, skip-on-wallet-id-only with assertion that list_all_active_machines is never called) - [x] Refactor passes existing 164 tests unchanged — `cassette_transport.publish_to_atm` is now a thin wrapper over the shared helper - [x] **Joint smoke with physical cash-out** — still blocked on the three bitspire-side `aiolabs/lamassu-next#57` deploy gaps from coord-log §`2026-06-01T18:30Z` (`relay.aiolabs.dev` hardcoded default points at nothing; dead `VITE_LNBITS_HTTP_URL` echo; operator-fees subscriber doesn't run during `awaiting-fees` maintenance state). Layer 2 is fully autonomous from bitspire on the publish side — events land on the relay with the correct shape; consumer side fixes are bitspire's lane. ## Strict-from-the-start Per workspace CLAUDE.md "Backwards-compatibility on pre-public-launch code": no compat shims. The cassette_transport refactor removes 5 internal underscore-prefixed helpers in favor of shared public ones (one external caller in `tasks.py` migrated to import from `nostr_publish` directly). ## Sequencing — what now - **Joint smoke** unblocks once bitspire's #57 deploy gaps are fixed. The publisher already produces correctly-formed events; the issue is on the consumer side (Sintra not subscribing during maintenance + the wrong relay URL). - **`enforce_fee_match` Phase 2** (settlement-reject on out-of-tolerance) lands as a follow-up after observability data justifies the tighter posture. - **`republish_operator_configs` helper** (`#41`) becomes implementable on top of the fee_transport + cassette_transport primitives — same shape for both doc types. ## Out of scope (follow-ups) - Phase 2 enforce_fee_match — `#38` follow-up - `#41` republish_operator_configs migration-cascade helper - Background catch-up republish task (the spec called this "out of scope for v1" — soft-fail + manual re-trigger via machine edit is the v1 recovery story) - Layer 3 bitspire fixes — `aiolabs/lamassu-next#57` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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>
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>
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
794d7e5395
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>
padreug merged commit 8a9aa00c20 into v2-bitspire 2026-06-01 18:20:09 +00:00
padreug deleted branch feat/fee-transport 2026-06-01 18:20:10 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/satmachineadmin!43
No description provided.