feat(v2): operator fee-config Nostr publisher (closes #39) #43
3 changed files with 342 additions and 214 deletions
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>
commit
aeaee1f568
|
|
@ -41,57 +41,61 @@ centralises the canonicalisation via lnbits.utils.nostr.normalize_public_key.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
|
|
||||||
from lnbits.core.crud.users import get_account
|
|
||||||
from lnbits.core.services.nip46_bunker_client import (
|
from lnbits.core.services.nip46_bunker_client import (
|
||||||
NsecBunkerRpcError,
|
NsecBunkerRpcError,
|
||||||
NsecBunkerTimeoutError,
|
NsecBunkerTimeoutError,
|
||||||
)
|
)
|
||||||
from lnbits.core.signers import resolve_signer
|
|
||||||
from lnbits.core.signers.base import (
|
from lnbits.core.signers.base import (
|
||||||
NostrSigner,
|
NostrSigner,
|
||||||
SignerError,
|
|
||||||
SignerUnavailableError,
|
SignerUnavailableError,
|
||||||
)
|
)
|
||||||
from lnbits.utils.nostr import normalize_public_key
|
from lnbits.utils.nostr import normalize_public_key
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from .models import Machine, PublishCassettesPayload
|
from .models import Machine, PublishCassettesPayload
|
||||||
from .nip44 import Nip44Error
|
from .nip44 import Nip44Error
|
||||||
from .nip44 import decrypt_from as _nip44_local_decrypt
|
from .nostr_publish import (
|
||||||
from .nip44 import encrypt_for as _nip44_local_encrypt
|
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_CONFIG_PREFIX = "bitspire-cassettes:" # operator → ATM
|
||||||
_D_TAG_STATE_PREFIX = "bitspire-cassettes-state:" # ATM → operator
|
_D_TAG_STATE_PREFIX = "bitspire-cassettes-state:" # ATM → operator
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Errors
|
# Errors — cassette-specific subclasses of the generic NostrPublishError
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class CassetteTransportError(Exception):
|
class CassetteTransportError(NostrPublishError):
|
||||||
"""Generic transport-layer error. Subclasses distinguish failure modes
|
"""Generic cassette-transport error. Subclasses distinguish failure
|
||||||
so the API can surface meaningful HTTP statuses + the consumer task
|
modes so the API can surface meaningful HTTP statuses + the consumer
|
||||||
can log + skip without crashing."""
|
task can log + skip without crashing.
|
||||||
|
|
||||||
|
Bridges back-compat with pre-extraction callers that catch this
|
||||||
class OperatorIdentityMissing(CassetteTransportError):
|
class — now equivalent to NostrPublishError plus the two consumer-
|
||||||
"""Operator account has no Nostr pubkey on file, or no signer is
|
side decode/transient distinctions below.
|
||||||
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."""
|
|
||||||
|
|
||||||
|
|
||||||
class CassetteEventDecodeError(CassetteTransportError):
|
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]
|
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)
|
# 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(
|
async def publish_to_atm(
|
||||||
machine: Machine,
|
machine: Machine,
|
||||||
payload: PublishCassettesPayload,
|
payload: PublishCassettesPayload,
|
||||||
|
|
@ -283,63 +159,20 @@ async def publish_to_atm(
|
||||||
from the operator to the target ATM.
|
from the operator to the target ATM.
|
||||||
|
|
||||||
Returns the signed event dict on success (caller may log event.id for
|
Returns the signed event dict on success (caller may log event.id for
|
||||||
audit). Raises CassetteTransportError subclasses on hard failures:
|
audit). Raises NostrPublishError subclasses (re-exported here as
|
||||||
- OperatorIdentityMissing → 400: operator hasn't onboarded
|
CassetteTransportError, OperatorIdentityMissing, SignerUnavailable,
|
||||||
- SignerUnavailable → 503: signer offline / client-side-only / bunker
|
RelayUnavailable) on hard failures.
|
||||||
timeout at the encrypt or sign step
|
|
||||||
- RelayUnavailable → 503: nostrclient not installed
|
|
||||||
- CassetteTransportError → 500: anything else
|
|
||||||
"""
|
"""
|
||||||
atm_pubkey_hex = _atm_hex_pubkey(machine)
|
atm_pubkey_hex = _atm_hex_pubkey(machine)
|
||||||
|
signed = await publish_encrypted_kind_30078(
|
||||||
# Single fetch + resolve — same signer is used for both encrypt and sign.
|
operator_user_id=operator_user_id,
|
||||||
account, signer = await _resolve_operator_signer(operator_user_id)
|
recipient_pubkey_hex=atm_pubkey_hex,
|
||||||
|
d_tag=_config_d_tag(atm_pubkey_hex),
|
||||||
# NIP-44 v2 encrypt the wire payload. Bunker round-trip on
|
payload=payload.to_wire_dict(),
|
||||||
# RemoteBunkerSigner; direct prvkey on LocalSigner (transitional).
|
log_context=(
|
||||||
plaintext = json.dumps(payload.to_wire_dict(), separators=(",", ":"))
|
f"cassette config (machine={machine.id}, "
|
||||||
try:
|
f"positions={sorted(payload.positions.keys())})"
|
||||||
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())})"
|
|
||||||
)
|
)
|
||||||
return signed
|
return signed
|
||||||
|
|
||||||
|
|
@ -384,7 +217,7 @@ async def decrypt_and_parse_state_event(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
plaintext = await _nip44_decrypt_via_signer(
|
plaintext = await nip44_decrypt_via_signer(
|
||||||
account, signer, content, sender_pubkey
|
account, signer, content, sender_pubkey
|
||||||
)
|
)
|
||||||
except NsecBunkerTimeoutError as exc:
|
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,
|
CassetteEventDecodeError,
|
||||||
CassetteEventTransientError,
|
CassetteEventTransientError,
|
||||||
CassetteTransportError,
|
CassetteTransportError,
|
||||||
_resolve_operator_signer,
|
|
||||||
decrypt_and_parse_state_event,
|
decrypt_and_parse_state_event,
|
||||||
)
|
)
|
||||||
|
from .nostr_publish import resolve_operator_signer
|
||||||
|
|
||||||
event_raw = event_message.event
|
event_raw = event_message.event
|
||||||
if isinstance(event_raw, str):
|
if isinstance(event_raw, str):
|
||||||
|
|
@ -444,7 +444,7 @@ async def _handle_cassette_state_event(
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
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:
|
except CassetteTransportError as exc:
|
||||||
# OperatorIdentityMissing / SignerUnavailable — log + skip.
|
# OperatorIdentityMissing / SignerUnavailable — log + skip.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue