refactor(v2): extract kind-30078 publish primitives to nostr_publish.py (#39 1/3)

Layer 2 prep: a second consumer (fee_transport.py for #39) is about to
land that uses the same operator-signer + NIP-44 v2 + nostrclient publish
flow as cassette_transport.py. Extracting shared primitives now rather
than duplicating ~100 lines.

New `nostr_publish.py` module:
- Error hierarchy: NostrPublishError base + OperatorIdentityMissing,
  SignerUnavailable, RelayUnavailable subclasses (all transport-layer
  failures, domain-agnostic).
- `resolve_operator_signer(operator_user_id)` — fetch account + resolve
  to NostrSigner, with the can-sign + has-pubkey checks.
- `sign_as_operator(operator_user_id, event)` — wrap signer.sign_event,
  set created_at before signing.
- `nip44_encrypt_via_signer` + `nip44_decrypt_via_signer` — transitional
  LocalSigner → RemoteBunkerSigner cascade (bunker handles natively;
  LocalSigner falls back to hand-rolled NIP-44 v2 against the stored
  prvkey).
- `publish_signed_event(signed)` — nostrclient relay-manager publish
  with lazy import + RelayUnavailable on missing extension.
- High-level `publish_encrypted_kind_30078(operator_user_id,
  recipient_pubkey_hex, d_tag, payload)` — builds event, encrypts via
  signer, signs, publishes. The whole flow in one call; callers
  (cassette_transport, soon fee_transport) just specify domain.

`cassette_transport.py`:
- Imports from nostr_publish; CassetteTransportError becomes a subclass
  of NostrPublishError so existing catches still work.
- `publish_to_atm` reduces to a thin wrapper that builds the
  cassette-specific payload + d-tag and delegates to
  `publish_encrypted_kind_30078`.
- Consumer path (`decrypt_and_parse_state_event`) still owns
  cassette-specific decode/transient distinctions; uses imported
  `nip44_decrypt_via_signer`.
- Re-exports OperatorIdentityMissing / SignerUnavailable /
  RelayUnavailable so views_api can keep importing from
  cassette_transport without change.

`tasks.py` — cassette bootstrap consumer imports `resolve_operator_signer`
from nostr_publish directly instead of the cassette_transport
underscore-prefixed name.

164/164 tests green; behavior unchanged.

Refs: aiolabs/satmachineadmin#37 (parent), #39 (Layer 2, this commit
is prep).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-01 19:54:08 +02:00
commit aeaee1f568
3 changed files with 342 additions and 214 deletions

View file

@ -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: