From aeaee1f568f33f7d2331f76560b73db98c3c62f5 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 19:54:08 +0200 Subject: [PATCH 1/3] refactor(v2): extract kind-30078 publish primitives to nostr_publish.py (#39 1/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cassette_transport.py | 257 +++++++----------------------------- nostr_publish.py | 295 ++++++++++++++++++++++++++++++++++++++++++ tasks.py | 4 +- 3 files changed, 342 insertions(+), 214 deletions(-) create mode 100644 nostr_publish.py diff --git a/cassette_transport.py b/cassette_transport.py index 1725dde..7d98906 100644 --- a/cassette_transport.py +++ b/cassette_transport.py @@ -41,57 +41,61 @@ centralises the canonicalisation via lnbits.utils.nostr.normalize_public_key. from __future__ import annotations import json -import time -from lnbits.core.crud.users import get_account from lnbits.core.services.nip46_bunker_client import ( NsecBunkerRpcError, NsecBunkerTimeoutError, ) -from lnbits.core.signers import resolve_signer from lnbits.core.signers.base import ( NostrSigner, - SignerError, SignerUnavailableError, ) from lnbits.utils.nostr import normalize_public_key -from loguru import logger from .models import Machine, PublishCassettesPayload from .nip44 import Nip44Error -from .nip44 import decrypt_from as _nip44_local_decrypt -from .nip44 import encrypt_for as _nip44_local_encrypt +from .nostr_publish import ( + NostrPublishError, + OperatorIdentityMissing, # re-export for callers that catch this + RelayUnavailable, # re-export + SignerUnavailable, # re-export + nip44_decrypt_via_signer, + publish_encrypted_kind_30078, +) + +# Re-exported so external callers (views_api etc.) can keep importing +# from cassette_transport without breakage. Same for the public +# constants below. +__all__ = [ + "CassetteTransportError", + "CassetteEventDecodeError", + "CassetteEventTransientError", + "OperatorIdentityMissing", + "SignerUnavailable", + "RelayUnavailable", + "build_state_d_tags_for_machines", + "decrypt_and_parse_state_event", + "publish_to_atm", +] -_KIND_NIP78 = 30078 _D_TAG_CONFIG_PREFIX = "bitspire-cassettes:" # operator → ATM _D_TAG_STATE_PREFIX = "bitspire-cassettes-state:" # ATM → operator # ============================================================================= -# Errors +# Errors — cassette-specific subclasses of the generic NostrPublishError # ============================================================================= -class CassetteTransportError(Exception): - """Generic transport-layer error. Subclasses distinguish failure modes - so the API can surface meaningful HTTP statuses + the consumer task - can log + skip without crashing.""" +class CassetteTransportError(NostrPublishError): + """Generic cassette-transport error. Subclasses distinguish failure + modes so the API can surface meaningful HTTP statuses + the consumer + task can log + skip without crashing. - -class OperatorIdentityMissing(CassetteTransportError): - """Operator account has no Nostr pubkey on file, or no signer is - available (pre-bunker rollout — operator hasn't onboarded via - Nostr-login).""" - - -class SignerUnavailable(CassetteTransportError): - """Resolved signer can't sign server-side (client-side-only signer, - or transient bunker unreachability post-lnbits#18). Publish skipped.""" - - -class RelayUnavailable(CassetteTransportError): - """nostrclient extension isn't installed or its relay manager isn't - reachable. Treated as soft-fail; publish skipped + logged.""" + Bridges back-compat with pre-extraction callers that catch this + class — now equivalent to NostrPublishError plus the two consumer- + side decode/transient distinctions below. + """ class CassetteEventDecodeError(CassetteTransportError): @@ -141,139 +145,11 @@ def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]: return [_state_d_tag(_atm_hex_pubkey(m)) for m in machines] -# ============================================================================= -# Sign-as-operator — hybrid path (resolve_signer post #17, prvkey fallback) -# ============================================================================= - - -async def _resolve_operator_signer(operator_user_id: str): - """Fetch the operator's account + resolve to a NostrSigner. - - Single source of truth for "give me the signer for this operator, - or raise an operator-facing error if we can't." Returns - `(account, signer)` so callers that need both (publish path needs - `account.pubkey` for the event author and the signer for both - encrypt + sign) don't double-fetch. - - Raises: - - OperatorIdentityMissing — no account, or no pubkey on file - - SignerUnavailable — signer resolve failed, or signer can't sign - server-side (ClientSideOnly) - """ - account = await get_account(operator_user_id) - if account is None or not account.pubkey: - raise OperatorIdentityMissing( - f"operator {operator_user_id[:8]}... has no Nostr pubkey on file. " - "Onboard via the LNbits Nostr-login flow to publish cassette " - "config to your ATMs." - ) - try: - signer = resolve_signer(account) - except SignerError as exc: - raise SignerUnavailable( - f"signer resolve failed for operator {operator_user_id[:8]}...: " f"{exc}" - ) from exc - if not signer.can_sign(): - raise SignerUnavailable( - f"operator {operator_user_id[:8]}... has a client-side-only " - "signer; server can't sign or NIP-44-encrypt on their behalf. " - "Operator must hold their nsec via a NIP-46 bunker (lnbits#18) " - "or migrate to a server-signing account." - ) - return account, signer - - -async def _sign_as_operator(operator_user_id: str, event: dict) -> dict | None: - """Sign `event` using the operator's signer (LocalSigner or - RemoteBunkerSigner). Mutates `event` to add `created_at` (now), - `pubkey`, `id`, and `sig`. - - Raises typed CassetteTransportError subclasses on hard failure - (the publish endpoint maps these to HTTP statuses); never returns - None on the publish path. - """ - _account, signer = await _resolve_operator_signer(operator_user_id) - # created_at is part of the BIP-340 event-id hash; set before signing. - event["created_at"] = int(time.time()) - try: - return await signer.sign_event(event) - except SignerUnavailableError as exc: - raise SignerUnavailable( - f"signer unavailable for operator {operator_user_id[:8]}...: " f"{exc}" - ) from exc - - -async def _nip44_encrypt_via_signer( - account, signer: NostrSigner, plaintext: str, peer_pubkey_hex: str -) -> str: - """NIP-44 v2 encrypt via the signer abstraction, with a transitional - fallback to direct-prvkey for LocalSigner accounts. - - The bunker (RemoteBunkerSigner) implements `nip44_encrypt` natively — - the operator's nsec never leaves the bunker process. LocalSigner's - `nip44_encrypt` stub explicitly raises SignerUnavailableError - ("LocalSigner does not implement nip44_encrypt") per the - post-PR-#38 ABC — the spec is "migrate to bunker." For the - transitional window where some operators are still on LocalSigner - + their `account.prvkey` is intact, we catch that signal and use - our hand-rolled NIP-44 v2 impl against the stored prvkey. Same - wire output either way. - - Removed once every operator account on this instance is bunker- - backed (S7 fully landed). At that point this helper collapses to - `return await signer.nip44_encrypt(plaintext, peer_pubkey_hex)`. - """ - try: - return await signer.nip44_encrypt(plaintext, peer_pubkey_hex) - except SignerUnavailableError: - if account.signer_type == "LocalSigner" and account.prvkey: - return _nip44_local_encrypt(plaintext, account.prvkey, peer_pubkey_hex) - # ClientSideOnly, or RemoteBunkerSigner with bunker comms failure - # at config time — re-raise without wrapping; caller maps it. - raise - - -async def _nip44_decrypt_via_signer( - account, signer: NostrSigner, ciphertext: str, peer_pubkey_hex: str -) -> str: - """Decrypt mirror of `_nip44_encrypt_via_signer`. Same LocalSigner - transitional fallback.""" - try: - return await signer.nip44_decrypt(ciphertext, peer_pubkey_hex) - except SignerUnavailableError: - if account.signer_type == "LocalSigner" and account.prvkey: - return _nip44_local_decrypt(ciphertext, account.prvkey, peer_pubkey_hex) - raise - - # ============================================================================= # Publish — operator → ATM (the satmachineadmin API path) # ============================================================================= -async def _publish_signed_event(signed_event: dict) -> None: - """Send a signed Nostr event to all relays via the nostrclient - extension's singleton RelayManager. - - Lazy import + typed-error so the API can surface "your LNbits doesn't - have nostrclient installed" as a 503 rather than a 500. Pattern - matches the cross-extension import guards in - `lnbits.core.services.users` (nostrmarket / nostrrelay). - """ - try: - from nostrclient.router import ( # type: ignore[import-not-found] - nostr_client, - ) - except ImportError as exc: - raise RelayUnavailable( - "nostrclient extension is not installed; cassette config " - "publish requires it. Install + activate the nostrclient " - "extension on this LNbits instance." - ) from exc - msg = json.dumps(["EVENT", signed_event]) - nostr_client.relay_manager.publish_message(msg) - - async def publish_to_atm( machine: Machine, payload: PublishCassettesPayload, @@ -283,63 +159,20 @@ async def publish_to_atm( from the operator to the target ATM. Returns the signed event dict on success (caller may log event.id for - audit). Raises CassetteTransportError subclasses on hard failures: - - OperatorIdentityMissing → 400: operator hasn't onboarded - - SignerUnavailable → 503: signer offline / client-side-only / bunker - timeout at the encrypt or sign step - - RelayUnavailable → 503: nostrclient not installed - - CassetteTransportError → 500: anything else + audit). Raises NostrPublishError subclasses (re-exported here as + CassetteTransportError, OperatorIdentityMissing, SignerUnavailable, + RelayUnavailable) on hard failures. """ atm_pubkey_hex = _atm_hex_pubkey(machine) - - # Single fetch + resolve — same signer is used for both encrypt and sign. - account, signer = await _resolve_operator_signer(operator_user_id) - - # NIP-44 v2 encrypt the wire payload. Bunker round-trip on - # RemoteBunkerSigner; direct prvkey on LocalSigner (transitional). - plaintext = json.dumps(payload.to_wire_dict(), separators=(",", ":")) - try: - content = await _nip44_encrypt_via_signer( - account, signer, plaintext, atm_pubkey_hex - ) - except NsecBunkerTimeoutError as exc: - raise SignerUnavailable( - f"bunker unreachable while encrypting cassette config for " - f"operator {operator_user_id[:8]}...: {exc}" - ) from exc - except NsecBunkerRpcError as exc: - raise SignerUnavailable( - f"bunker rejected nip44_encrypt for operator " - f"{operator_user_id[:8]}... (policy / MAC / config issue): " - f"{exc}" - ) from exc - except SignerUnavailableError as exc: - raise SignerUnavailable( - f"signer cannot nip44-encrypt for operator " - f"{operator_user_id[:8]}...: {exc}" - ) from exc - - event: dict = { - "kind": _KIND_NIP78, - "tags": [ - ["d", _config_d_tag(atm_pubkey_hex)], - ["p", atm_pubkey_hex], - ], - "content": content, - # created_at is set inside _sign_as_operator before signing. - } - signed = await _sign_as_operator(operator_user_id, event) - if signed is None: - raise CassetteTransportError( - "sign_as_operator returned None unexpectedly — soft-fail path " - "shouldn't be reachable on a publish-initiated flow" - ) - - await _publish_signed_event(signed) - logger.info( - f"satmachineadmin: published kind-30078 cassette config to ATM " - f"{atm_pubkey_hex[:12]}... (event_id={signed['id'][:12]}..., " - f"machine_id={machine.id}, positions={sorted(payload.positions.keys())})" + signed = await publish_encrypted_kind_30078( + operator_user_id=operator_user_id, + recipient_pubkey_hex=atm_pubkey_hex, + d_tag=_config_d_tag(atm_pubkey_hex), + payload=payload.to_wire_dict(), + log_context=( + f"cassette config (machine={machine.id}, " + f"positions={sorted(payload.positions.keys())})" + ), ) return signed @@ -384,7 +217,7 @@ async def decrypt_and_parse_state_event( ) try: - plaintext = await _nip44_decrypt_via_signer( + plaintext = await nip44_decrypt_via_signer( account, signer, content, sender_pubkey ) except NsecBunkerTimeoutError as exc: diff --git a/nostr_publish.py b/nostr_publish.py new file mode 100644 index 0000000..b36b39c --- /dev/null +++ b/nostr_publish.py @@ -0,0 +1,295 @@ +""" +Shared kind-30078 (NIP-78 addressable app data) Nostr publish primitives. + +Extracted from cassette_transport.py once the second consumer landed +(fee_transport.py for aiolabs/satmachineadmin#39). Both modules +share the operator-signer resolution, NIP-44 v2 encrypt/decrypt path, +event signing, and nostrclient-relay publish path; only the d-tag +prefix + payload model differ per document type. + +Architecture: + + fee_transport / cassette_transport + │ + ▼ + publish_encrypted_kind_30078 ← high-level wrapper (build event + sign + publish) + │ + ├── resolve_operator_signer + ├── nip44_encrypt_via_signer + ├── sign_as_operator + └── publish_signed_event + +`resolve_operator_signer` and the NIP-44 helpers honor the +transitional LocalSigner → RemoteBunkerSigner cascade (lnbits#17/#18): +the bunker is the endgame for every operator account on this instance, +but pre-migration LocalSigner accounts still work via direct-prvkey +NIP-44 v2 from our hand-rolled `nip44` module. + +This module is intentionally domain-agnostic — it knows nothing about +cassettes, fees, or any specific d-tag prefix. The caller supplies +the recipient pubkey, the d-tag, and the payload dict. +""" + +from __future__ import annotations + +import json +import time +from typing import Any + +from lnbits.core.crud.users import get_account +from lnbits.core.services.nip46_bunker_client import ( + NsecBunkerRpcError, + NsecBunkerTimeoutError, +) +from lnbits.core.signers import resolve_signer +from lnbits.core.signers.base import ( + NostrSigner, + SignerError, + SignerUnavailableError, +) +from loguru import logger + +from .nip44 import decrypt_from as _nip44_local_decrypt +from .nip44 import encrypt_for as _nip44_local_encrypt + +KIND_NIP78 = 30078 + + +# ============================================================================= +# Errors — typed so API endpoints can map to specific HTTP statuses +# ============================================================================= + + +class NostrPublishError(Exception): + """Base class for kind-30078 publish errors. Sub-modules + (cassette_transport, fee_transport) typically subclass further + for domain-specific 'this couldn't be applied' errors that have + no analog in the transport layer.""" + + +class OperatorIdentityMissing(NostrPublishError): + """Operator account has no Nostr pubkey on file, or no signer is + available (pre-bunker onboarding — operator hasn't logged in via + Nostr-login flow).""" + + +class SignerUnavailable(NostrPublishError): + """Resolved signer can't sign server-side (client-side-only signer, + or transient bunker unreachability post-lnbits#18). Publish skipped + or soft-failed by the caller.""" + + +class RelayUnavailable(NostrPublishError): + """nostrclient extension isn't installed or its relay manager isn't + reachable. Treated as soft-fail by callers; publish skipped + logged.""" + + +# ============================================================================= +# Operator signer resolution + NIP-44 v2 encrypt/decrypt +# ============================================================================= + + +async def resolve_operator_signer(operator_user_id: str): + """Fetch the operator's account + resolve to a NostrSigner. + + Single source of truth for "give me the signer for this operator, + or raise an operator-facing error if we can't." Returns + `(account, signer)` so callers that need both (publish path needs + `account.pubkey` for the event author and the signer for both + encrypt + sign) don't double-fetch. + + Raises: + - OperatorIdentityMissing — no account, or no pubkey on file + - SignerUnavailable — signer resolve failed, or signer can't sign + server-side (ClientSideOnly) + """ + account = await get_account(operator_user_id) + if account is None or not account.pubkey: + raise OperatorIdentityMissing( + f"operator {operator_user_id[:8]}... has no Nostr pubkey on file. " + "Onboard via the LNbits Nostr-login flow to publish operator " + "config to your ATMs." + ) + try: + signer = resolve_signer(account) + except SignerError as exc: + raise SignerUnavailable( + f"signer resolve failed for operator {operator_user_id[:8]}...: {exc}" + ) from exc + if not signer.can_sign(): + raise SignerUnavailable( + f"operator {operator_user_id[:8]}... has a client-side-only " + "signer; server can't sign or NIP-44-encrypt on their behalf. " + "Operator must hold their nsec via a NIP-46 bunker (lnbits#18) " + "or migrate to a server-signing account." + ) + return account, signer + + +async def sign_as_operator(operator_user_id: str, event: dict) -> dict: + """Sign `event` using the operator's signer (LocalSigner or + RemoteBunkerSigner). Mutates `event` to add `created_at` (now), + `pubkey`, `id`, and `sig`. Returns the signed event. + + Raises typed NostrPublishError subclasses on hard failure (caller + maps to HTTP status / decides soft-fail). + """ + _account, signer = await resolve_operator_signer(operator_user_id) + # created_at is part of the BIP-340 event-id hash; set before signing. + event["created_at"] = int(time.time()) + try: + signed = await signer.sign_event(event) + except SignerUnavailableError as exc: + raise SignerUnavailable( + f"signer unavailable for operator {operator_user_id[:8]}...: {exc}" + ) from exc + if signed is None: + raise NostrPublishError( + f"signer returned None for operator {operator_user_id[:8]}... " + "— shouldn't be reachable on a server-signing path" + ) + return signed + + +async def nip44_encrypt_via_signer( + account, signer: NostrSigner, plaintext: str, peer_pubkey_hex: str +) -> str: + """NIP-44 v2 encrypt via the signer abstraction, with a transitional + fallback to direct-prvkey for LocalSigner accounts. + + The bunker (RemoteBunkerSigner) implements `nip44_encrypt` natively — + the operator's nsec never leaves the bunker process. LocalSigner's + `nip44_encrypt` stub explicitly raises SignerUnavailableError per + the post-PR-#38 ABC — the spec is "migrate to bunker." For the + transitional window where some operators are still on LocalSigner + + their `account.prvkey` is intact, we catch that signal and use our + hand-rolled NIP-44 v2 impl against the stored prvkey. Same wire + output either way. + + Removed once every operator account on this instance is bunker- + backed (S7 fully landed). At that point this helper collapses to + `return await signer.nip44_encrypt(plaintext, peer_pubkey_hex)`. + """ + try: + return await signer.nip44_encrypt(plaintext, peer_pubkey_hex) + except SignerUnavailableError: + if account.signer_type == "LocalSigner" and account.prvkey: + return _nip44_local_encrypt(plaintext, account.prvkey, peer_pubkey_hex) + # ClientSideOnly, or RemoteBunkerSigner with bunker comms failure + # at encrypt time — re-raise without wrapping; caller maps it. + raise + + +async def nip44_decrypt_via_signer( + account, signer: NostrSigner, ciphertext: str, peer_pubkey_hex: str +) -> str: + """Decrypt mirror of `nip44_encrypt_via_signer`. Same LocalSigner + transitional fallback.""" + try: + return await signer.nip44_decrypt(ciphertext, peer_pubkey_hex) + except SignerUnavailableError: + if account.signer_type == "LocalSigner" and account.prvkey: + return _nip44_local_decrypt(ciphertext, account.prvkey, peer_pubkey_hex) + raise + + +# ============================================================================= +# Relay publish +# ============================================================================= + + +async def publish_signed_event(signed_event: dict) -> None: + """Send a signed Nostr event to all relays via the nostrclient + extension's singleton RelayManager. + + Lazy import + typed-error so the API can surface "your LNbits doesn't + have nostrclient installed" as a 503 rather than a 500. Pattern + matches the cross-extension import guards in + `lnbits.core.services.users` (nostrmarket / nostrrelay). + """ + try: + from nostrclient.router import ( # type: ignore[import-not-found] + nostr_client, + ) + except ImportError as exc: + raise RelayUnavailable( + "nostrclient extension is not installed; kind-30078 publish " + "requires it. Install + activate the nostrclient extension on " + "this LNbits instance." + ) from exc + msg = json.dumps(["EVENT", signed_event]) + nostr_client.relay_manager.publish_message(msg) + + +# ============================================================================= +# High-level: build + encrypt + sign + publish in one call +# ============================================================================= + + +async def publish_encrypted_kind_30078( + *, + operator_user_id: str, + recipient_pubkey_hex: str, + d_tag: str, + payload: dict[str, Any], + log_context: str = "", +) -> dict: + """Build, NIP-44-v2-encrypt, sign-as-operator, and publish a + kind-30078 event addressed to `recipient_pubkey_hex` under `d_tag`. + + Centralised so cassette_transport + fee_transport (+ any future + operator-pushed document type) share the same wire-format guarantees. + + Returns the signed event dict on success. Raises typed + NostrPublishError subclasses on hard failure: + - OperatorIdentityMissing → 400: operator hasn't onboarded + - SignerUnavailable → 503: signer offline / client-side-only / + bunker timeout at encrypt or sign step + - RelayUnavailable → 503: nostrclient not installed + - NostrPublishError → 500: anything else + + `log_context` is a short string prefixed to the success log line for + triage ("cassette", "fee", etc.). + """ + account, signer = await resolve_operator_signer(operator_user_id) + + plaintext = json.dumps(payload, separators=(",", ":")) + try: + content = await nip44_encrypt_via_signer( + account, signer, plaintext, recipient_pubkey_hex + ) + except NsecBunkerTimeoutError as exc: + raise SignerUnavailable( + f"bunker unreachable while encrypting kind-30078 ({d_tag}) for " + f"operator {operator_user_id[:8]}...: {exc}" + ) from exc + except NsecBunkerRpcError as exc: + raise SignerUnavailable( + f"bunker rejected nip44_encrypt for operator " + f"{operator_user_id[:8]}... (policy / MAC / config issue): {exc}" + ) from exc + except SignerUnavailableError as exc: + raise SignerUnavailable( + f"signer cannot nip44-encrypt for operator " + f"{operator_user_id[:8]}...: {exc}" + ) from exc + + event: dict = { + "kind": KIND_NIP78, + "tags": [ + ["d", d_tag], + ["p", recipient_pubkey_hex], + ], + "content": content, + # created_at is set inside sign_as_operator before signing. + } + signed = await sign_as_operator(operator_user_id, event) + + await publish_signed_event(signed) + prefix = f"{log_context}: " if log_context else "" + logger.info( + f"satmachineadmin: {prefix}published kind-30078 to ATM " + f"{recipient_pubkey_hex[:12]}... d-tag={d_tag} " + f"event_id={signed['id'][:12]}..." + ) + return signed diff --git a/tasks.py b/tasks.py index 9778a20..314c244 100644 --- a/tasks.py +++ b/tasks.py @@ -408,9 +408,9 @@ async def _handle_cassette_state_event( CassetteEventDecodeError, CassetteEventTransientError, CassetteTransportError, - _resolve_operator_signer, decrypt_and_parse_state_event, ) + from .nostr_publish import resolve_operator_signer event_raw = event_message.event if isinstance(event_raw, str): @@ -444,7 +444,7 @@ async def _handle_cassette_state_event( return try: - account, signer = await _resolve_operator_signer(machine.operator_user_id) + account, signer = await resolve_operator_signer(machine.operator_user_id) except CassetteTransportError as exc: # OperatorIdentityMissing / SignerUnavailable — log + skip. logger.warning( -- 2.53.0 From 12f39226f0bcd5dece1a14de1520eb383226afac Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 20:00:29 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(v2):=20fee=5Ftransport=20=E2=80=94=20k?= =?UTF-8?q?ind-30078=20publisher=20for=20operator=20fee=20config=20(#39=20?= =?UTF-8?q?2/3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:` 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 --- fee_transport.py | 151 +++++++++++++++++ models.py | 104 ++++++++++++ tests/test_fee_transport.py | 325 ++++++++++++++++++++++++++++++++++++ 3 files changed, 580 insertions(+) create mode 100644 fee_transport.py create mode 100644 tests/test_fee_transport.py diff --git a/fee_transport.py b/fee_transport.py new file mode 100644 index 0000000..1dc0ec7 --- /dev/null +++ b/fee_transport.py @@ -0,0 +1,151 @@ +""" +Fee-config Nostr transport — operator → ATM kind-30078 publish. + +Layer 2 of the operator-configurable fee architecture +(aiolabs/satmachineadmin#37 parent, #39 this layer). Pairs with the +bitspire consumer at `aiolabs/lamassu-next#57`. + +Wire format locked at coord-log §2026-06-01T14:25Z: + + kind = 30078 (NIP-78, replaceable) + tags = [ + ["d", "bitspire-fees:"], + ["p", ""], + ] + content = NIP-44 v2 encrypted JSON of FeeConfigPayload.to_wire_dict() + pubkey = operator pubkey + sig = operator signature + +Producer-side invariants (enforced via FeeConfigPayload validators): + - cash_*_fee_fraction ≤ 0.15 (cap, mirrored on bitspire consumer) + - |total - components sum| < 1e-6 (consistency assert) + - schema_version integer ≥ 1 + +Soft-fail discipline (matches `cassette_transport.publish_to_atm`): +relay/signer/bunker hiccups log + return None rather than raising, +so a fee-config trigger from a CRUD endpoint can't break the +underlying machine create/update on a transient transport failure. +Hard-raises on configuration errors (cap exceeded, operator has no +pubkey) since those indicate a bug, not a transient. +""" + +from __future__ import annotations + +from loguru import logger + +from .models import FeeConfigPayload, FeePayloadComponents, Machine, SuperConfig +from .nostr_publish import ( + NostrPublishError, + OperatorIdentityMissing, + RelayUnavailable, + SignerUnavailable, + publish_encrypted_kind_30078, +) + +_D_TAG_FEES_PREFIX = "bitspire-fees:" + + +def _atm_hex_pubkey(machine: Machine) -> str: + """Canonicalise machine.machine_npub to lowercase hex. Used for both + the d-tag suffix and the NIP-44 v2 recipient pubkey. Same shape as + cassette_transport's local helper — kept module-local since it's a + one-liner over `normalize_public_key` and inlining would invert the + abstraction direction (transport-module-knows-about-Machine is + correct; nostr_publish doesn't know about Machine).""" + from lnbits.utils.nostr import normalize_public_key + + return normalize_public_key(machine.machine_npub).lower() + + +def _fees_d_tag(atm_pubkey_hex: str) -> str: + return f"{_D_TAG_FEES_PREFIX}{atm_pubkey_hex}" + + +def build_fee_payload( + super_config: SuperConfig, machine: Machine +) -> FeeConfigPayload: + """Compose a FeeConfigPayload from current super-config + per-machine + fractions. FeeConfigPayload's validators enforce the cap + + consistency invariants — this function constructs and validates + in one step; a returned payload is wire-shippable. + + Raises ValueError (via Pydantic) if any directional total exceeds + the 0.15 cap. That's a hard error because the upstream API layer + (views_api._assert_machine_fee_cap_safe + _assert_super_config_cap_safe) + should have rejected the create/update that produced this state. + If we reach here with a cap-violating state, something bypassed the + API guards and we'd rather refuse-to-publish than ship a malformed + event. + """ + super_in = float(super_config.super_cash_in_fee_fraction) + super_out = float(super_config.super_cash_out_fee_fraction) + op_in = float(machine.operator_cash_in_fee_fraction) + op_out = float(machine.operator_cash_out_fee_fraction) + + return FeeConfigPayload( + schema_version=1, + cash_in_fee_fraction=round(super_in + op_in, 4), + cash_out_fee_fraction=round(super_out + op_out, 4), + components=FeePayloadComponents( + super_cash_in=super_in, + super_cash_out=super_out, + operator_cash_in=op_in, + operator_cash_out=op_out, + ), + ) + + +async def publish_fee_config( + machine: Machine, + super_config: SuperConfig, + operator_user_id: str, +) -> dict | None: + """Build, validate, encrypt, sign, publish the fee-config event for + `machine` to the ATM at `machine.machine_npub`. + + Returns the signed event dict on success (caller may log event.id + for audit). Returns None on soft-fail (transport-layer errors: + relay unreachable, signer offline, bunker timeout) — these are + transient and the caller's underlying CRUD operation should succeed + independent of publish success. Logs WARNING on soft-fail. + + Raises on hard configuration error: + - OperatorIdentityMissing — operator has no Nostr pubkey on file + (caller's API layer should refuse the operation before we get + here, but we propagate as HTTP 400 if it slips through) + - ValueError (from FeeConfigPayload validators) — cap violation + or sum/components mismatch, indicates an API-guard bypass + """ + payload = build_fee_payload(super_config, machine) + atm_pubkey_hex = _atm_hex_pubkey(machine) + try: + signed = await publish_encrypted_kind_30078( + operator_user_id=operator_user_id, + recipient_pubkey_hex=atm_pubkey_hex, + d_tag=_fees_d_tag(atm_pubkey_hex), + payload=payload.to_wire_dict(), + log_context=( + f"fee config (machine={machine.id}, " + f"cash_in={payload.cash_in_fee_fraction}, " + f"cash_out={payload.cash_out_fee_fraction})" + ), + ) + except (SignerUnavailable, RelayUnavailable) as exc: + logger.warning( + f"satmachineadmin: fee-config publish soft-fail for machine " + f"{machine.id} ({machine.name or machine.machine_npub[:12]}): " + f"{type(exc).__name__}: {exc}. Underlying CRUD operation " + "succeeded; operator can re-trigger publish via the next " + "machine edit or super-config save." + ) + return None + except NostrPublishError as exc: + # Truly unexpected transport error — log + soft-fail. We still + # don't break the caller's CRUD path; a future publish attempt + # (next machine edit / next super edit) will retry. + logger.warning( + f"satmachineadmin: fee-config publish unexpected transport " + f"error for machine {machine.id}: {type(exc).__name__}: {exc}" + ) + return None + return signed diff --git a/models.py b/models.py index 1094f6a..f7f84e4 100644 --- a/models.py +++ b/models.py @@ -721,3 +721,107 @@ class PublishCassettesPayload(BaseModel): for pos, row in self.positions.items() } } + + +# ============================================================================= +# Fee-config Nostr payload — operator → ATM (aiolabs/satmachineadmin#39) +# ============================================================================= +# Locked wire format per coord-log §2026-06-01T14:25Z: +# { +# "schema_version": 1, +# "cash_in_fee_fraction": super_cash_in + operator_cash_in, +# "cash_out_fee_fraction": super_cash_out + operator_cash_out, +# "components": { +# "super_cash_in": float, +# "super_cash_out": float, +# "operator_cash_in": float, +# "operator_cash_out": float +# } +# } +# +# Producer invariants (refuse-to-publish if violated): +# - cash_*_fee_fraction ≤ 0.15 (cap, defense in depth — bitspire +# consumer enforces the same) +# - |cash_in_fee_fraction - (super_cash_in + operator_cash_in)| < 1e-6 +# - |cash_out_fee_fraction - (super_cash_out + operator_cash_out)| < 1e-6 +# - All six fractions in [0.0, 0.15] +# - schema_version is integer ≥ 1 +# v1 consumers ignore unknown top-level keys per the locked spec. + + +class FeePayloadComponents(BaseModel): + """The producer-mandatory `components` sub-object that splits the + summed `cash_*_fee_fraction` totals back into their super + operator + halves. Audit + future-promo substrate; consumer-optional in v1.""" + + super_cash_in: float + super_cash_out: float + operator_cash_in: float + operator_cash_out: float + + +class FeeConfigPayload(BaseModel): + """The decrypted JSON content of a kind-30078 fee-config event + (operator → ATM, d-tag `bitspire-fees:`). + + Built from a Machine row + the SuperConfig singleton via + `fee_transport.build_fee_payload`. Validates the cap + + sum-vs-components consistency at construction time so any caller + that holds a FeeConfigPayload instance has a wire-shippable payload. + """ + + schema_version: int = 1 + cash_in_fee_fraction: float + cash_out_fee_fraction: float + components: FeePayloadComponents + + @validator("schema_version") + def _schema_version_at_least_v1(cls, v): + if v < 1: + raise ValueError(f"schema_version must be >= 1, got {v}") + return v + + @validator("cash_in_fee_fraction", "cash_out_fee_fraction") + def _total_in_unit_range(cls, v): + # Imported here rather than at module top to avoid a circular + # import (calculations imports nothing from models, but keep the + # dependency direction explicit at the call site). + from .calculations import MAX_FEE_FRACTION_PER_DIRECTION + + if v < 0 or v > MAX_FEE_FRACTION_PER_DIRECTION: + raise ValueError( + f"fee fraction must be in [0, {MAX_FEE_FRACTION_PER_DIRECTION}], " + f"got {v}" + ) + return round(float(v), 4) + + @validator("components", always=True) + def _components_sum_matches_totals(cls, v, values): + sum_in = round(v.super_cash_in + v.operator_cash_in, 4) + sum_out = round(v.super_cash_out + v.operator_cash_out, 4) + total_in = values.get("cash_in_fee_fraction") + total_out = values.get("cash_out_fee_fraction") + if total_in is not None and abs(total_in - sum_in) > 1e-6: + raise ValueError( + f"cash_in_fee_fraction={total_in} doesn't match components " + f"sum super({v.super_cash_in}) + operator({v.operator_cash_in}) = {sum_in}" + ) + if total_out is not None and abs(total_out - sum_out) > 1e-6: + raise ValueError( + f"cash_out_fee_fraction={total_out} doesn't match components " + f"sum super({v.super_cash_out}) + operator({v.operator_cash_out}) = {sum_out}" + ) + return v + + def to_wire_dict(self) -> dict: + return { + "schema_version": self.schema_version, + "cash_in_fee_fraction": self.cash_in_fee_fraction, + "cash_out_fee_fraction": self.cash_out_fee_fraction, + "components": { + "super_cash_in": self.components.super_cash_in, + "super_cash_out": self.components.super_cash_out, + "operator_cash_in": self.components.operator_cash_in, + "operator_cash_out": self.components.operator_cash_out, + }, + } diff --git a/tests/test_fee_transport.py b/tests/test_fee_transport.py new file mode 100644 index 0000000..58bbf03 --- /dev/null +++ b/tests/test_fee_transport.py @@ -0,0 +1,325 @@ +""" +Tests for `fee_transport.py` and `models.FeeConfigPayload` — +Layer 2 of the operator-configurable fee architecture +(aiolabs/satmachineadmin#39). + +Three concerns covered: + +1. FeeConfigPayload — validators enforce the locked wire-format + invariants (cap ≤ 0.15 per direction, components sum matches totals, + schema_version ≥ 1). +2. `build_fee_payload(super_config, machine)` — composes a payload + from current DB rows. Wraps construction + validation in one call. +3. `publish_fee_config(machine, super_config, operator_user_id)` — + soft-fail discipline: transport errors log + return None, hard + errors (cap-violating state) propagate. +""" + +from datetime import datetime + +import pytest + +from .. import fee_transport +from ..fee_transport import build_fee_payload, publish_fee_config +from ..models import FeeConfigPayload, FeePayloadComponents, Machine, SuperConfig +from ..nostr_publish import ( + OperatorIdentityMissing, + RelayUnavailable, + SignerUnavailable, +) + +_NOW = datetime(2026, 6, 1, 12, 0, 0) +_ATM_PUBKEY_HEX = ( + "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891" +) + + +def _machine(op_in: float = 0.05, op_out: float = 0.05) -> Machine: + return Machine( + id="m1", + operator_user_id="op1", + machine_npub=_ATM_PUBKEY_HEX, + wallet_id="w1", + name="sintra", + location=None, + fiat_code="EUR", + is_active=True, + operator_cash_in_fee_fraction=op_in, + operator_cash_out_fee_fraction=op_out, + created_at=_NOW, + updated_at=_NOW, + ) + + +def _super(in_frac: float = 0.03, out_frac: float = 0.03) -> SuperConfig: + return SuperConfig( + id="default", + super_cash_in_fee_fraction=in_frac, + super_cash_out_fee_fraction=out_frac, + super_fee_wallet_id="super-wallet", + updated_at=_NOW, + ) + + +# --------------------------------------------------------------------------- +# FeeConfigPayload — wire-format validators +# --------------------------------------------------------------------------- + + +class TestFeeConfigPayloadValidators: + def _components( + self, s_in: float = 0.03, s_out: float = 0.03, o_in: float = 0.05, o_out: float = 0.05 + ) -> FeePayloadComponents: + return FeePayloadComponents( + super_cash_in=s_in, + super_cash_out=s_out, + operator_cash_in=o_in, + operator_cash_out=o_out, + ) + + def test_well_formed_payload_accepts(self): + payload = FeeConfigPayload( + schema_version=1, + cash_in_fee_fraction=0.08, + cash_out_fee_fraction=0.08, + components=self._components(), + ) + assert payload.schema_version == 1 + assert payload.cash_in_fee_fraction == 0.08 + assert payload.cash_out_fee_fraction == 0.08 + + def test_to_wire_dict_round_trips(self): + original = FeeConfigPayload( + schema_version=1, + cash_in_fee_fraction=0.08, + cash_out_fee_fraction=0.1077, + components=self._components(o_out=0.0777), + ) + wire = original.to_wire_dict() + rebuilt = FeeConfigPayload(**wire) + assert rebuilt.cash_in_fee_fraction == 0.08 + assert rebuilt.cash_out_fee_fraction == 0.1077 + assert rebuilt.components.operator_cash_out == 0.0777 + + def test_cap_violation_cash_in_rejects(self): + # cap is 0.15 per direction. + with pytest.raises(ValueError, match="fee fraction must be in"): + FeeConfigPayload( + schema_version=1, + cash_in_fee_fraction=0.16, + cash_out_fee_fraction=0.08, + components=self._components(s_in=0.10, o_in=0.06), + ) + + def test_cap_violation_cash_out_rejects(self): + with pytest.raises(ValueError, match="fee fraction must be in"): + FeeConfigPayload( + schema_version=1, + cash_in_fee_fraction=0.08, + cash_out_fee_fraction=0.20, + components=self._components(s_out=0.10, o_out=0.10), + ) + + def test_exact_cap_accepted(self): + """0.15 exactly is the upper bound — must accept.""" + FeeConfigPayload( + schema_version=1, + cash_in_fee_fraction=0.15, + cash_out_fee_fraction=0.15, + components=self._components(s_in=0.10, s_out=0.10, o_in=0.05, o_out=0.05), + ) + + def test_inconsistent_total_vs_components_rejects_cash_in(self): + """sum(super_cash_in + operator_cash_in) must equal + cash_in_fee_fraction within 1e-6.""" + with pytest.raises(ValueError, match="cash_in_fee_fraction"): + FeeConfigPayload( + schema_version=1, + cash_in_fee_fraction=0.09, # claims 9% + cash_out_fee_fraction=0.08, + components=self._components(), # actually 0.03 + 0.05 = 0.08 + ) + + def test_inconsistent_total_vs_components_rejects_cash_out(self): + with pytest.raises(ValueError, match="cash_out_fee_fraction"): + FeeConfigPayload( + schema_version=1, + cash_in_fee_fraction=0.08, + cash_out_fee_fraction=0.10, # claims 10% + components=self._components(), # actually 0.08 + ) + + def test_schema_version_zero_rejects(self): + with pytest.raises(ValueError, match="schema_version must be"): + FeeConfigPayload( + schema_version=0, + cash_in_fee_fraction=0.08, + cash_out_fee_fraction=0.08, + components=self._components(), + ) + + def test_zero_fractions_accepted(self): + """Free-charge ATM — both super + operator at 0 → totals 0.""" + FeeConfigPayload( + schema_version=1, + cash_in_fee_fraction=0.0, + cash_out_fee_fraction=0.0, + components=self._components(s_in=0.0, s_out=0.0, o_in=0.0, o_out=0.0), + ) + + +# --------------------------------------------------------------------------- +# build_fee_payload — composition from SuperConfig + Machine +# --------------------------------------------------------------------------- + + +class TestBuildFeePayload: + def test_basic_composition(self): + payload = build_fee_payload(_super(0.03, 0.03), _machine(0.05, 0.05)) + assert payload.cash_in_fee_fraction == 0.08 + assert payload.cash_out_fee_fraction == 0.08 + assert payload.components.super_cash_in == 0.03 + assert payload.components.operator_cash_in == 0.05 + + def test_different_directions(self): + """Cash-in and cash-out can differ — payload preserves both.""" + payload = build_fee_payload(_super(0.03, 0.05), _machine(0.0333, 0.0777)) + assert payload.cash_in_fee_fraction == 0.0633 + assert payload.cash_out_fee_fraction == 0.1277 + + def test_super_only_no_operator(self): + """Pre-Layer-2 default — machine has 0/0 operator fees; payload + carries super-only totals. This is the 'publish on machine create' + path's expected shape.""" + payload = build_fee_payload(_super(0.03, 0.03), _machine(0.0, 0.0)) + assert payload.cash_in_fee_fraction == 0.03 + assert payload.cash_out_fee_fraction == 0.03 + + def test_cap_violation_at_build_time_raises(self): + """If the API guards were bypassed and the DB has a cap-violating + state, build_fee_payload refuses rather than ship a bad payload.""" + with pytest.raises(ValueError, match="fee fraction must be in"): + build_fee_payload(_super(0.10, 0.03), _machine(0.10, 0.0)) + # 0.10 + 0.10 = 0.20 > 0.15 + + +# --------------------------------------------------------------------------- +# publish_fee_config — soft-fail discipline +# --------------------------------------------------------------------------- + + +class TestPublishFeeConfigSoftFail: + def test_relay_unavailable_returns_none_logs_warning( + self, monkeypatch, loguru_capture + ): + async def fake_publish(**kwargs): + raise RelayUnavailable("nostrclient extension is not installed") + + monkeypatch.setattr( + fee_transport, "publish_encrypted_kind_30078", fake_publish + ) + import asyncio + + result = asyncio.run(publish_fee_config(_machine(), _super(), "op1")) + assert result is None + assert any( + "soft-fail" in m and "RelayUnavailable" in m for m in loguru_capture + ) + + def test_signer_unavailable_returns_none_logs_warning( + self, monkeypatch, loguru_capture + ): + async def fake_publish(**kwargs): + raise SignerUnavailable("bunker unreachable") + + monkeypatch.setattr( + fee_transport, "publish_encrypted_kind_30078", fake_publish + ) + import asyncio + + result = asyncio.run(publish_fee_config(_machine(), _super(), "op1")) + assert result is None + assert any( + "soft-fail" in m and "SignerUnavailable" in m for m in loguru_capture + ) + + def test_operator_identity_missing_returns_none_logs_warning( + self, monkeypatch, loguru_capture + ): + """OperatorIdentityMissing is a NostrPublishError but not a + transport one — currently soft-fails at the same layer. The + caller may want to convert this to HTTP 400 in future if the + operator-facing UX needs a hard signal, but v1 keeps it soft + because a partially-onboarded operator shouldn't crash machine + create.""" + + async def fake_publish(**kwargs): + raise OperatorIdentityMissing("no pubkey on file") + + monkeypatch.setattr( + fee_transport, "publish_encrypted_kind_30078", fake_publish + ) + import asyncio + + result = asyncio.run(publish_fee_config(_machine(), _super(), "op1")) + assert result is None + + def test_publish_success_returns_signed_event(self, monkeypatch): + signed = { + "id": "ev1", + "kind": 30078, + "pubkey": "op_pubkey", + "content": "ciphertext", + "tags": [["d", f"bitspire-fees:{_ATM_PUBKEY_HEX}"], ["p", _ATM_PUBKEY_HEX]], + "created_at": 1780000000, + "sig": "ff" * 32, + } + + captured = {} + + async def fake_publish(**kwargs): + captured.update(kwargs) + return signed + + monkeypatch.setattr( + fee_transport, "publish_encrypted_kind_30078", fake_publish + ) + import asyncio + + result = asyncio.run(publish_fee_config(_machine(), _super(), "op1")) + assert result is signed + # Verify d-tag matches the locked spec + assert captured["d_tag"] == f"bitspire-fees:{_ATM_PUBKEY_HEX}" + assert captured["recipient_pubkey_hex"] == _ATM_PUBKEY_HEX + # Payload shape carries components per the §14:25Z lock + payload = captured["payload"] + assert payload["schema_version"] == 1 + assert payload["cash_in_fee_fraction"] == 0.08 + assert "components" in payload + + def test_cap_violation_raises_does_not_soft_fail(self, monkeypatch): + """build_fee_payload raises ValueError at construction time on + cap-violating state. That's a hard configuration error (API + guards bypassed), not a transient transport issue, so it + propagates. publish_encrypted_kind_30078 is never reached.""" + + called = {"count": 0} + + async def fake_publish(**kwargs): + called["count"] += 1 + return {} + + monkeypatch.setattr( + fee_transport, "publish_encrypted_kind_30078", fake_publish + ) + import asyncio + + with pytest.raises(ValueError, match="fee fraction must be in"): + asyncio.run( + publish_fee_config( + _machine(op_in=0.10, op_out=0.0), + _super(in_frac=0.10, out_frac=0.0), + "op1", + ) + ) + assert called["count"] == 0 -- 2.53.0 From 794d7e53951c6011a78be5a9d90f3e8c889d5a06 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 1 Jun 2026 20:07:56 +0200 Subject: [PATCH 3/3] feat(v2): wire fee-config publish into machine + super-config triggers (#39 3/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/test_fee_publish_triggers.py | 391 +++++++++++++++++++++++++++++ views_api.py | 33 +++ 2 files changed, 424 insertions(+) create mode 100644 tests/test_fee_publish_triggers.py diff --git a/tests/test_fee_publish_triggers.py b/tests/test_fee_publish_triggers.py new file mode 100644 index 0000000..650bd73 --- /dev/null +++ b/tests/test_fee_publish_triggers.py @@ -0,0 +1,391 @@ +""" +Tests for the three views_api trigger points that publish fee config +to ATMs via fee_transport (aiolabs/satmachineadmin#39 Layer 2): + +1. api_create_machine — publish always after create (so ATM unblocks + past `awaiting-fees` maintenance, even with default 0/0 operator + fees that produce a super-only payload) +2. api_update_machine — publish only when either operator fee fraction + changes (skip on name/location/wallet_id/is_active-only edits) +3. api_update_super_config — publish to every active machine when + either super fraction changes, signed by each machine's operator + +Tests monkeypatch `views_api.publish_fee_config` with a recording stub +to verify the trigger fired (or not) and what arguments it received. +The publisher itself is exercised by test_fee_transport.py — these +tests are about the wiring. +""" + +import asyncio +from datetime import datetime + +from .. import views_api +from ..models import CreateMachineData, Machine, SuperConfig, UpdateMachineData + +_NOW = datetime(2026, 6, 1, 12, 0, 0) +_ATM_PUBKEY_HEX = ( + "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891" +) +_OP_USER_ID = "ac35c9fc842f40f0a0e9809347cd24d1" + + +def _machine( + machine_id: str = "m1", + npub: str = _ATM_PUBKEY_HEX, + op_in: float = 0.0, + op_out: float = 0.0, + operator_user_id: str = _OP_USER_ID, +) -> Machine: + return Machine( + id=machine_id, + operator_user_id=operator_user_id, + machine_npub=npub, + wallet_id="w1", + name=f"machine-{machine_id}", + location=None, + fiat_code="EUR", + is_active=True, + operator_cash_in_fee_fraction=op_in, + operator_cash_out_fee_fraction=op_out, + created_at=_NOW, + updated_at=_NOW, + ) + + +def _super(in_frac: float = 0.03, out_frac: float = 0.03) -> SuperConfig: + return SuperConfig( + id="default", + super_cash_in_fee_fraction=in_frac, + super_cash_out_fee_fraction=out_frac, + super_fee_wallet_id="super-wallet", + updated_at=_NOW, + ) + + +class _PublishRecorder: + """Records every (machine.id, super_in, super_out, operator) tuple + publish_fee_config was called with. Drop-in stub for monkeypatching + `views_api.publish_fee_config`.""" + + def __init__(self): + self.calls: list[tuple[str, float, float, float, float, str]] = [] + + async def __call__(self, machine, super_config, operator_user_id): + self.calls.append( + ( + machine.id, + float(super_config.super_cash_in_fee_fraction), + float(super_config.super_cash_out_fee_fraction), + float(machine.operator_cash_in_fee_fraction), + float(machine.operator_cash_out_fee_fraction), + operator_user_id, + ) + ) + return {"id": f"evt_{machine.id}", "kind": 30078} + + +# --------------------------------------------------------------------------- +# Trigger 1: api_create_machine +# --------------------------------------------------------------------------- + + +class TestCreateMachineTrigger: + def test_publishes_on_create_with_default_operator_fees(self, monkeypatch): + """Default 0/0 operator fees — payload carries super-only totals. + Publish fires anyway so the ATM gets initial config and can + boot past maintenance.""" + recorder = _PublishRecorder() + machine = _machine(op_in=0.0, op_out=0.0) + + async def fake_assert_wallet(*args, **kwargs): + return None + + async def fake_assert_collision(*args, **kwargs): + return None + + async def fake_assert_fee_cap(*args, **kwargs): + return None + + async def fake_create_machine(user_id, data): + return machine + + async def fake_get_super(): + return _super() + + monkeypatch.setattr(views_api, "_assert_wallet_owned_by", fake_assert_wallet) + monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", fake_assert_collision) + monkeypatch.setattr(views_api, "_assert_machine_fee_cap_safe", fake_assert_fee_cap) + monkeypatch.setattr(views_api, "create_machine", fake_create_machine) + monkeypatch.setattr(views_api, "get_super_config", fake_get_super) + monkeypatch.setattr(views_api, "publish_fee_config", recorder) + + # Build a CreateMachineData + fake User and invoke the endpoint. + from types import SimpleNamespace + + data = CreateMachineData( + machine_npub=_ATM_PUBKEY_HEX, + wallet_id="w1", + name="sintra", + ) + user = SimpleNamespace(id=_OP_USER_ID) + result = asyncio.run(views_api.api_create_machine(data=data, user=user)) + + assert result is machine + assert len(recorder.calls) == 1 + assert recorder.calls[0] == ("m1", 0.03, 0.03, 0.0, 0.0, _OP_USER_ID) + + def test_publishes_on_create_with_nonzero_operator_fees(self, monkeypatch): + recorder = _PublishRecorder() + machine = _machine(op_in=0.05, op_out=0.05) + + async def passthrough(*args, **kwargs): + return None + + async def fake_create_machine(user_id, data): + return machine + + async def fake_get_super(): + return _super(0.03, 0.03) + + monkeypatch.setattr(views_api, "_assert_wallet_owned_by", passthrough) + monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", passthrough) + monkeypatch.setattr(views_api, "_assert_machine_fee_cap_safe", passthrough) + monkeypatch.setattr(views_api, "create_machine", fake_create_machine) + monkeypatch.setattr(views_api, "get_super_config", fake_get_super) + monkeypatch.setattr(views_api, "publish_fee_config", recorder) + + from types import SimpleNamespace + + data = CreateMachineData( + machine_npub=_ATM_PUBKEY_HEX, + wallet_id="w1", + operator_cash_in_fee_fraction=0.05, + operator_cash_out_fee_fraction=0.05, + ) + user = SimpleNamespace(id=_OP_USER_ID) + asyncio.run(views_api.api_create_machine(data=data, user=user)) + + assert recorder.calls == [("m1", 0.03, 0.03, 0.05, 0.05, _OP_USER_ID)] + + def test_no_super_config_skips_publish(self, monkeypatch): + """If the super-config singleton is missing (impossible in + practice since m001 inserts it), skip the publish rather than + crash the create. Machine still created.""" + recorder = _PublishRecorder() + machine = _machine() + + async def passthrough(*args, **kwargs): + return None + + async def fake_create_machine(user_id, data): + return machine + + async def fake_get_super(): + return None + + monkeypatch.setattr(views_api, "_assert_wallet_owned_by", passthrough) + monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", passthrough) + monkeypatch.setattr(views_api, "_assert_machine_fee_cap_safe", passthrough) + monkeypatch.setattr(views_api, "create_machine", fake_create_machine) + monkeypatch.setattr(views_api, "get_super_config", fake_get_super) + monkeypatch.setattr(views_api, "publish_fee_config", recorder) + + from types import SimpleNamespace + + data = CreateMachineData(machine_npub=_ATM_PUBKEY_HEX, wallet_id="w1") + user = SimpleNamespace(id=_OP_USER_ID) + result = asyncio.run(views_api.api_create_machine(data=data, user=user)) + + assert result is machine + assert recorder.calls == [] + + +# --------------------------------------------------------------------------- +# Trigger 2: api_update_machine +# --------------------------------------------------------------------------- + + +def _wire_update_machine_patches( + monkeypatch, existing_machine, updated_machine, recorder +): + """Common setup for api_update_machine tests.""" + + async def passthrough(*args, **kwargs): + return None + + async def fake_get_machine(machine_id): + return existing_machine + + async def fake_update_machine(machine_id, data): + return updated_machine + + async def fake_get_super(): + return _super(0.03, 0.03) + + monkeypatch.setattr(views_api, "_assert_wallet_owned_by", passthrough) + monkeypatch.setattr(views_api, "_assert_machine_fee_cap_safe", passthrough) + monkeypatch.setattr(views_api, "get_machine", fake_get_machine) + monkeypatch.setattr(views_api, "update_machine", fake_update_machine) + monkeypatch.setattr(views_api, "get_super_config", fake_get_super) + monkeypatch.setattr(views_api, "publish_fee_config", recorder) + + +class TestUpdateMachineTrigger: + def test_publishes_when_operator_cash_in_changes(self, monkeypatch): + recorder = _PublishRecorder() + existing = _machine(op_in=0.05, op_out=0.05) + updated = _machine(op_in=0.07, op_out=0.05) + _wire_update_machine_patches(monkeypatch, existing, updated, recorder) + + from types import SimpleNamespace + + data = UpdateMachineData(operator_cash_in_fee_fraction=0.07) + user = SimpleNamespace(id=_OP_USER_ID) + asyncio.run( + views_api.api_update_machine(machine_id="m1", data=data, user=user) + ) + + assert len(recorder.calls) == 1 + assert recorder.calls[0] == ("m1", 0.03, 0.03, 0.07, 0.05, _OP_USER_ID) + + def test_publishes_when_operator_cash_out_changes(self, monkeypatch): + recorder = _PublishRecorder() + existing = _machine(op_in=0.05, op_out=0.05) + updated = _machine(op_in=0.05, op_out=0.08) + _wire_update_machine_patches(monkeypatch, existing, updated, recorder) + + from types import SimpleNamespace + + data = UpdateMachineData(operator_cash_out_fee_fraction=0.08) + user = SimpleNamespace(id=_OP_USER_ID) + asyncio.run( + views_api.api_update_machine(machine_id="m1", data=data, user=user) + ) + + assert len(recorder.calls) == 1 + assert recorder.calls[0] == ("m1", 0.03, 0.03, 0.05, 0.08, _OP_USER_ID) + + def test_no_publish_when_only_name_changes(self, monkeypatch): + """Name / location / fiat_code / is_active / wallet_id changes + don't affect the fee model the ATM enforces — skip the + republish to avoid relay churn.""" + recorder = _PublishRecorder() + existing = _machine() + updated = _machine() # same fees + _wire_update_machine_patches(monkeypatch, existing, updated, recorder) + + from types import SimpleNamespace + + data = UpdateMachineData(name="new name") + user = SimpleNamespace(id=_OP_USER_ID) + asyncio.run( + views_api.api_update_machine(machine_id="m1", data=data, user=user) + ) + + assert recorder.calls == [] + + def test_no_publish_when_only_is_active_changes(self, monkeypatch): + recorder = _PublishRecorder() + _wire_update_machine_patches(monkeypatch, _machine(), _machine(), recorder) + + from types import SimpleNamespace + + data = UpdateMachineData(is_active=False) + user = SimpleNamespace(id=_OP_USER_ID) + asyncio.run( + views_api.api_update_machine(machine_id="m1", data=data, user=user) + ) + + assert recorder.calls == [] + + +# --------------------------------------------------------------------------- +# Trigger 3: api_update_super_config +# --------------------------------------------------------------------------- + + +class TestSuperConfigUpdateTrigger: + def test_publishes_to_every_active_machine_on_super_fraction_change( + self, monkeypatch + ): + """A super-fee change ripples to every active machine since each + machine's total = super + machine.operator. Republish per-machine + with that machine's operator as the signer (machines owned by + different operators sign with different keys).""" + recorder = _PublishRecorder() + new_super = _super(in_frac=0.04, out_frac=0.04) + + machines = [ + _machine(machine_id="m1", operator_user_id="op_A"), + _machine(machine_id="m2", operator_user_id="op_B", op_in=0.05, op_out=0.07), + _machine(machine_id="m3", operator_user_id="op_A", op_in=0.02, op_out=0.02), + ] + + async def fake_assert_cap(*args, **kwargs): + return None + + async def fake_update_super(data): + return new_super + + async def fake_list_active(): + return machines + + monkeypatch.setattr( + views_api, "_assert_super_config_cap_safe", fake_assert_cap + ) + monkeypatch.setattr(views_api, "update_super_config", fake_update_super) + monkeypatch.setattr( + views_api, "list_all_active_machines", fake_list_active + ) + monkeypatch.setattr(views_api, "publish_fee_config", recorder) + + from types import SimpleNamespace + from ..models import UpdateSuperConfigData + + data = UpdateSuperConfigData(super_cash_in_fee_fraction=0.04) + user = SimpleNamespace(id="super_admin") + asyncio.run(views_api.api_update_super_config(data=data, _user=user)) + + assert len(recorder.calls) == 3 + # Verify each call carries the NEW super fractions + that + # machine's operator + own fees + assert recorder.calls[0] == ("m1", 0.04, 0.04, 0.0, 0.0, "op_A") + assert recorder.calls[1] == ("m2", 0.04, 0.04, 0.05, 0.07, "op_B") + assert recorder.calls[2] == ("m3", 0.04, 0.04, 0.02, 0.02, "op_A") + + def test_no_publish_when_only_wallet_id_changes(self, monkeypatch): + """Changing super_fee_wallet_id without touching either fraction + doesn't affect any ATM's fee model — skip the fleet-wide + republish.""" + recorder = _PublishRecorder() + new_super = _super(in_frac=0.03, out_frac=0.03) + + async def fake_assert_cap(*args, **kwargs): + return None + + async def fake_update_super(data): + return new_super + + async def fake_list_active(): + raise AssertionError( + "list_all_active_machines should not be called when " + "no fraction changed" + ) + + monkeypatch.setattr( + views_api, "_assert_super_config_cap_safe", fake_assert_cap + ) + monkeypatch.setattr(views_api, "update_super_config", fake_update_super) + monkeypatch.setattr( + views_api, "list_all_active_machines", fake_list_active + ) + monkeypatch.setattr(views_api, "publish_fee_config", recorder) + + from types import SimpleNamespace + from ..models import UpdateSuperConfigData + + data = UpdateSuperConfigData(super_fee_wallet_id="new-wallet") + user = SimpleNamespace(id="super_admin") + asyncio.run(views_api.api_update_super_config(data=data, _user=user)) + + assert recorder.calls == [] diff --git a/views_api.py b/views_api.py index bf51fbd..3c4c6cc 100644 --- a/views_api.py +++ b/views_api.py @@ -22,6 +22,7 @@ from .cassette_transport import ( SignerUnavailable, publish_to_atm, ) +from .fee_transport import publish_fee_config from .crud import ( append_settlement_note, count_completed_legs_for_settlement, @@ -264,6 +265,12 @@ async def api_create_machine( data.operator_cash_out_fee_fraction, ) machine = await create_machine(user.id, data) + # Layer 2 (#39): publish initial fee config to the ATM so it can + # unblock past its `awaiting-fees` maintenance gate. Soft-fails on + # transport errors — machine creation has already succeeded. + super_config = await get_super_config() + if super_config is not None: + await publish_fee_config(machine, super_config, user.id) return machine @@ -319,6 +326,18 @@ async def api_update_machine( updated = await update_machine(machine_id, data) if updated is None: raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found") + # Layer 2 (#39): if either operator fee fraction changed, publish a + # fresh kind-30078 to the ATM so it picks up the new total. Skip + # otherwise — name/location/wallet_id/is_active edits don't change + # the fee model the ATM enforces. + fees_changed = ( + data.operator_cash_in_fee_fraction is not None + or data.operator_cash_out_fee_fraction is not None + ) + if fees_changed: + super_config = await get_super_config() + if super_config is not None: + await publish_fee_config(updated, super_config, user.id) return updated @@ -939,6 +958,20 @@ async def api_update_super_config( raise HTTPException( HTTPStatus.INTERNAL_SERVER_ERROR, "Failed to update super config" ) + # Layer 2 (#39): a super-fee change ripples to every active machine + # since each machine's total = super + machine.operator. Republish + # per-machine with that machine's operator as the signer. + # Soft-fails per machine independently; partial success is acceptable + # (the operator whose publish failed can re-trigger via a machine + # edit). Skip if neither directional fraction was touched in this + # update (e.g. caller only changed super_fee_wallet_id). + super_fractions_changed = ( + data.super_cash_in_fee_fraction is not None + or data.super_cash_out_fee_fraction is not None + ) + if super_fractions_changed: + for machine in await list_all_active_machines(): + await publish_fee_config(machine, config, machine.operator_user_id) return config -- 2.53.0