Fork of satmachineadmin's v2-bitspire line into its own repo. Renames
both identifiers so this extension is fully independent of the original
satmachineadmin install (which remains in service):
- extension id satmachineadmin -> spirekeeper
(router prefix, static path/static_url_for, module symbols, task
names, templates dir, config/manifest paths)
- database name satoshimachine -> spirekeeper
(Database(ext_spirekeeper), all schema-qualified table refs)
Also resets versioning to 0.1.0, sets the display name + manifest to
spirekeeper/aiolabs, and fixes the placeholder pyproject description.
Historical aiolabs/satmachineadmin#N issue references in comments are
left pointing at the original repo where those issues live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""
|
|
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"spirekeeper: {prefix}published kind-30078 to ATM "
|
|
f"{recipient_pubkey_hex[:12]}... d-tag={d_tag} "
|
|
f"event_id={signed['id'][:12]}..."
|
|
)
|
|
return signed
|