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>
257 lines
9.8 KiB
Python
257 lines
9.8 KiB
Python
"""
|
|
Cassette-config Nostr transport — operator ↔ ATM kind-30078 publish + consume.
|
|
|
|
Per the locked design at aiolabs/satmachineadmin#29 (paired with
|
|
lamassu-next#56) and the dcd0874 privacy-by-default pivot, the operator
|
|
publishes position-keyed cassette config to a target ATM via:
|
|
|
|
kind = 30078 (NIP-78, replaceable)
|
|
tags = [
|
|
["d", "bitspire-cassettes:<atm_pubkey_hex>"],
|
|
["p", "<atm_pubkey_hex>"]
|
|
]
|
|
content = NIP-44 v2 encrypted JSON of PublishCassettesPayload.to_wire_dict()
|
|
pubkey = operator pubkey
|
|
sig = operator signature
|
|
|
|
The ATM-side consumer (lamassu-next#56) subscribes by the d-tag + its own
|
|
npub, decrypts, validates, applies, hot-reloads HAL.
|
|
|
|
Reverse direction (ATM → operator, v1 = one-shot bootstrap on first boot,
|
|
v2 = continuous reverse channel for reconciliation):
|
|
|
|
kind = 30078
|
|
tags = [
|
|
["d", "bitspire-cassettes-state:<atm_pubkey_hex>"],
|
|
["p", "<operator_pubkey_hex>"]
|
|
]
|
|
content = NIP-44 v2 encrypted JSON, same PublishCassettesPayload shape
|
|
pubkey = ATM pubkey
|
|
|
|
This module owns the wire-format side of both directions. The consumer
|
|
task (tasks.py) calls `decrypt_and_parse_state_event` per incoming event;
|
|
the API endpoint (views_api.py) calls `publish_to_atm` per operator submit.
|
|
|
|
The `<m>` placeholder semantics (load-bearing per the 2026-05-30T11:50Z
|
|
coord-log entry): always the ATM's hex pubkey, NEVER satmachineadmin's
|
|
internal dca_machines.id UUID. Helper `_atm_hex_pubkey(machine)`
|
|
centralises the canonicalisation via lnbits.utils.nostr.normalize_public_key.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from lnbits.core.services.nip46_bunker_client import (
|
|
NsecBunkerRpcError,
|
|
NsecBunkerTimeoutError,
|
|
)
|
|
from lnbits.core.signers.base import (
|
|
NostrSigner,
|
|
SignerUnavailableError,
|
|
)
|
|
from lnbits.utils.nostr import normalize_public_key
|
|
|
|
from .models import Machine, PublishCassettesPayload
|
|
from .nip44 import Nip44Error
|
|
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",
|
|
]
|
|
|
|
_D_TAG_CONFIG_PREFIX = "bitspire-cassettes:" # operator → ATM
|
|
_D_TAG_STATE_PREFIX = "bitspire-cassettes-state:" # ATM → operator
|
|
|
|
|
|
# =============================================================================
|
|
# Errors — cassette-specific subclasses of the generic NostrPublishError
|
|
# =============================================================================
|
|
|
|
|
|
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.
|
|
|
|
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):
|
|
"""Inbound state event failed validation: bad signature, NIP-44 v2
|
|
decrypt failure, or payload didn't conform to PublishCassettesPayload.
|
|
Terminal — caller should log + skip, advancing past the event."""
|
|
|
|
|
|
class CassetteEventTransientError(CassetteTransportError):
|
|
"""Inbound state event couldn't be decrypted because the signer
|
|
component (typically the bunker) is transiently unavailable. Caller
|
|
should NOT advance past the event; retry on next tick.
|
|
|
|
Distinct from CassetteEventDecodeError so the consumer task can
|
|
differentiate "MAC failed, give up" from "bunker is partitioned, try
|
|
again in a few seconds" — surfaced by lnbits at coord-log
|
|
2026-05-31T07:10Z as the load-bearing distinction post-PR-#38."""
|
|
|
|
|
|
# =============================================================================
|
|
# Helpers — canonical pubkey + d-tag construction
|
|
# =============================================================================
|
|
|
|
|
|
def _atm_hex_pubkey(machine: Machine) -> str:
|
|
"""Canonicalise machine.machine_npub (hex OR npub bech32 — operator
|
|
enters either in the UI) to lowercase hex. ALL d-tag substitutions
|
|
use this value; using the internal machine.id UUID would silently
|
|
no-op the wire-level filter (per coord-log 11:50Z load-bearing nudge).
|
|
"""
|
|
return normalize_public_key(machine.machine_npub).lower()
|
|
|
|
|
|
def _config_d_tag(atm_pubkey_hex: str) -> str:
|
|
"""d-tag for operator → ATM publish. ATM subscribes by this tag."""
|
|
return f"{_D_TAG_CONFIG_PREFIX}{atm_pubkey_hex}"
|
|
|
|
|
|
def _state_d_tag(atm_pubkey_hex: str) -> str:
|
|
"""d-tag for ATM → operator publish (bootstrap in v1, continuous v2)."""
|
|
return f"{_D_TAG_STATE_PREFIX}{atm_pubkey_hex}"
|
|
|
|
|
|
def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]:
|
|
"""Bootstrap-consumer subscription filter helper: returns the full
|
|
`#d=[...]` list for all known ATMs an operator subscribes to."""
|
|
return [_state_d_tag(_atm_hex_pubkey(m)) for m in machines]
|
|
|
|
|
|
# =============================================================================
|
|
# Publish — operator → ATM (the satmachineadmin API path)
|
|
# =============================================================================
|
|
|
|
|
|
async def publish_to_atm(
|
|
machine: Machine,
|
|
payload: PublishCassettesPayload,
|
|
operator_user_id: str,
|
|
) -> dict:
|
|
"""Build, encrypt, sign, and publish a kind-30078 cassette config event
|
|
from the operator to the target ATM.
|
|
|
|
Returns the signed event dict on success (caller may log event.id for
|
|
audit). Raises NostrPublishError subclasses (re-exported here as
|
|
CassetteTransportError, OperatorIdentityMissing, SignerUnavailable,
|
|
RelayUnavailable) on hard failures.
|
|
"""
|
|
atm_pubkey_hex = _atm_hex_pubkey(machine)
|
|
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
|
|
|
|
|
|
# =============================================================================
|
|
# Consume — ATM → operator (the bootstrap consumer task)
|
|
# =============================================================================
|
|
|
|
|
|
async def decrypt_and_parse_state_event(
|
|
event: dict, account, signer: NostrSigner
|
|
) -> PublishCassettesPayload:
|
|
"""Decrypt + parse an inbound `bitspire-cassettes-state:<atm_pubkey_hex>`
|
|
event the ATM published toward the operator.
|
|
|
|
Caller is responsible for:
|
|
- filtering on `kind=30078` and the expected `#d` tag list
|
|
- verifying the event signature (lnbits.utils.nostr.verify_event)
|
|
- confirming `event["pubkey"]` matches a known ATM (= machine.machine_npub
|
|
canonicalised) — the consumer task does this before calling here
|
|
- resolving the operator's account + signer via
|
|
`_resolve_operator_signer(...)` and passing them in
|
|
|
|
This function does:
|
|
- NIP-44 v2 decrypt of event["content"] via `signer.nip44_decrypt`
|
|
(bunker round-trip on RemoteBunkerSigner; direct prvkey on the
|
|
transitional LocalSigner path)
|
|
- JSON parse + PublishCassettesPayload validation
|
|
|
|
Error mapping:
|
|
- CassetteEventTransientError on NsecBunkerTimeoutError → caller
|
|
should NOT advance state_event_id; retry on next consumer tick
|
|
- CassetteEventDecodeError on anything else (bunker RPC reject,
|
|
signer unavailable, MAC failure, JSON parse, payload shape) →
|
|
terminal; caller logs + skips
|
|
"""
|
|
sender_pubkey = event.get("pubkey")
|
|
content = event.get("content")
|
|
if not isinstance(sender_pubkey, str) or not isinstance(content, str):
|
|
raise CassetteEventDecodeError(
|
|
"event missing required pubkey or content fields"
|
|
)
|
|
|
|
try:
|
|
plaintext = await nip44_decrypt_via_signer(
|
|
account, signer, content, sender_pubkey
|
|
)
|
|
except NsecBunkerTimeoutError as exc:
|
|
raise CassetteEventTransientError(
|
|
f"bunker unreachable while decrypting cassette state event: {exc}"
|
|
) from exc
|
|
except NsecBunkerRpcError as exc:
|
|
raise CassetteEventDecodeError(
|
|
f"bunker rejected nip44_decrypt (policy / MAC / config): {exc}"
|
|
) from exc
|
|
except SignerUnavailableError as exc:
|
|
raise CassetteEventDecodeError(f"signer cannot nip44-decrypt: {exc}") from exc
|
|
except Nip44Error as exc:
|
|
# Hand-rolled LocalSigner fallback path (transitional) — MAC fail
|
|
# / version mismatch / length issue.
|
|
raise CassetteEventDecodeError(
|
|
f"NIP-44 v2 decrypt failed (LocalSigner fallback path): {exc}"
|
|
) from exc
|
|
except ValueError as exc:
|
|
# coincurve raises ValueError on a malformed pubkey hex (only
|
|
# reachable via the LocalSigner fallback path; the bunker handles
|
|
# pubkey validation server-side).
|
|
raise CassetteEventDecodeError(f"sender pubkey is malformed: {exc}") from exc
|
|
|
|
try:
|
|
raw = json.loads(plaintext)
|
|
except json.JSONDecodeError as exc:
|
|
raise CassetteEventDecodeError(
|
|
f"decrypted content isn't valid JSON: {exc}"
|
|
) from exc
|
|
|
|
try:
|
|
return PublishCassettesPayload(**raw)
|
|
except Exception as exc:
|
|
raise CassetteEventDecodeError(
|
|
f"payload didn't validate as PublishCassettesPayload: {exc}"
|
|
) from exc
|