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

3 commits

Author SHA1 Message Date
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>
2026-06-01 20:07:56 +02:00
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>
2026-06-01 20:00:29 +02:00
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>
2026-06-01 19:54:08 +02:00