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(