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:
parent
52911af7b1
commit
aeaee1f568
3 changed files with 342 additions and 214 deletions
|
|
@ -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:
|
||||
|
|
|
|||
295
nostr_publish.py
Normal file
295
nostr_publish.py
Normal file
|
|
@ -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
|
||||
4
tasks.py
4
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue