refactor(v2): cassette transport — signer.nip44_* migration (#29 v1.1 / closes #21 partial)

Migrates the cassette transport's encrypt/decrypt paths off direct
`account.prvkey` reads to `signer.nip44_encrypt` / `signer.nip44_decrypt`
on the NostrSigner ABC landed by aiolabs/lnbits PR #38 (phase 2.4). Closes
the operator-side regression flagged at coord-log 2026-05-31T06:50Z:
Greg's RemoteBunkerSigner-migrated account had `accounts.prvkey IS NULL`
post-bunker, which the old code couldn't handle — consumer was logging
WARN every poll cycle and skipping every inbound state event.

## What changed

### cassette_transport.py

- New imports: `resolve_signer`, `SignerError`, `SignerUnavailableError`,
  `NsecBunkerTimeoutError`, `NsecBunkerRpcError` from the post-#38 lnbits
  surface. (The `try: from lnbits.core.signers import SignerError` block
  in the old code was permanently failing because `SignerError` actually
  lives in `lnbits.core.signers.base`, not the package root — fixed.)
- New `_resolve_operator_signer(operator_user_id)`: single source of
  truth for "give me the operator's account + NostrSigner, or raise an
  operator-facing error." Used by both the publish path and the consumer
  task.
- New `_nip44_encrypt_via_signer(account, signer, plaintext, peer)`
  and `_nip44_decrypt_via_signer(...)`: route through `signer.nip44_*`
  first; on `SignerUnavailableError` from a LocalSigner stub (the
  post-#38 ABC has LocalSigner raise on nip44_* explicitly — bunker
  migration required for NIP-44 v2), fall back to the hand-rolled impl
  against `account.prvkey`. Transitional until every operator on the
  instance is bunker-backed (S7).
- `_sign_as_operator` simplified: now `await signer.sign_event(event)`
  (the ABC is async; the old code passed `signer.sign_event` to the
  caller without await, returning a coroutine — also broken but never
  hit because the ImportError fallback fired first).
- `publish_to_atm` flow: `_resolve_operator_signer` → `_nip44_encrypt_
  via_signer` → `_sign_as_operator` → publish. Each step maps bunker /
  signer errors to `OperatorIdentityMissing` (400) / `SignerUnavailable`
  (503) / `CassetteTransportError` (500) for the API handler.
- `decrypt_and_parse_state_event` now `async` and takes `(event, account,
  signer)` instead of `(event, operator_privkey_hex)`. Maps
  `NsecBunkerTimeoutError` → `CassetteEventTransientError` (caller
  should retry on next poll, NOT advance `state_event_id`).
  `NsecBunkerRpcError` / `SignerUnavailableError` / `Nip44Error` / etc.
  → `CassetteEventDecodeError` (terminal — caller logs + skips).
- New `CassetteEventTransientError` class for the bunker-timeout case.
  Distinct from `CassetteEventDecodeError` so the consumer can log at
  INFO + retry vs WARNING + advance.
- Deleted `_get_operator_privkey_hex` (no longer needed).

### tasks.py — _handle_cassette_state_event

- Resolves the signer via `_resolve_operator_signer(machine.operator_
  user_id)`. On `CassetteTransportError` (OperatorIdentityMissing /
  SignerUnavailable), logs + skips.
- Awaits `decrypt_and_parse_state_event(event_obj, account, signer)`.
  On `CassetteEventTransientError`, logs at INFO + returns (state_event_
  id NOT advanced → consumer retries on next poll cycle).
  On `CassetteEventDecodeError`, logs at WARNING + returns (still
  state_event_id NOT advanced for v1; the WARN log surfaces the
  underlying issue for operator triage).

### tests/test_cassette_state_consumer.py — rewritten

- Three test doubles: `_FakeBunkerSigner` (working nip44_decrypt via
  hand-rolled impl), `_FakeLocalSignerStub` (raises like the post-#38
  LocalSigner stub), `_FakeRaisingSigner` (configurable exception).
- `_fake_account` helper using SimpleNamespace — the code under test
  only reads `.signer_type` + `.prvkey`.
- Five test classes covering: bunker-signer happy path (incl. multi-
  same-denom round-trip), LocalSigner transitional fallback,
  bunker-error mapping (timeout → transient, rpc reject → decode),
  payload validation (tamper / wrong-key / missing-fields / garbage
  JSON / wrong shape), d-tag construction (unchanged, kept as
  regression guard).
- Async coroutines driven via `asyncio.run` — matches the existing
  project pattern (no pytest-asyncio plugin in CI; see test_init.py
  failure mode).

### nip44.py — docstring update

Added a "Runtime status (post lnbits PR #38, 2026-05-31)" section
documenting that runtime usage moved to `signer.nip44_*` and this
module's role narrowed to (a) the LocalSigner transitional fallback
called from `cassette_transport`, and (b) test-only fixtures in
test_nip44_v2.py for spec-vector + bitspire cross-test validation.
"Don't add new runtime call sites here. The signer abstraction is
the path."

## Verification

- 155 passed, 1 pre-existing async-plugin failure unchanged. The 19
  consumer tests cover bunker happy path + LocalSigner fallback +
  bunker error mapping + payload validation + d-tag construction.
- Live smoke against Greg's RemoteBunkerSigner-migrated account
  on the regtest container: consumer correctly resolves the bunker
  signer, fires `NIP-46 rpc -> method=nip44_decrypt`, catches the
  resulting `NsecBunkerTimeoutError` (the local nsecbunkerd is not
  responding within 15s — separate operational concern), maps to
  `CassetteEventTransientError`, logs at INFO with "will retry next
  poll", and crucially does NOT advance `state_event_id` on the
  cassette_configs rows. Retry semantics preserved.

## Outstanding

- The bunker timeout itself is an operational issue (nsecbunkerd
  config / policy / process state for kind-less nip44_decrypt RPC) —
  not a satmachineadmin code concern; surface to the nsecbunkerd /
  lnbits sessions if it persists.
- Once every operator on the instance is on RemoteBunkerSigner (S7
  fully landed), the `_nip44_*_via_signer` helpers collapse to a
  direct `await signer.nip44_*` call, the LocalSigner fallback can
  be deleted, and `nip44.py`'s runtime exports retire (test-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-31 09:21:43 +02:00
commit dcb7de0c27
4 changed files with 573 additions and 199 deletions

View file

@ -44,18 +44,24 @@ import json
import time import time
from typing import Optional from typing import Optional
import coincurve
from lnbits.core.crud.users import get_account from lnbits.core.crud.users import get_account
from lnbits.utils.nostr import normalize_public_key, sign_event 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 loguru import logger
from .models import Machine, PublishCassettesPayload from .models import Machine, PublishCassettesPayload
from .nip44 import ( from .nip44 import Nip44Error
Nip44Error, from .nip44 import decrypt_from as _nip44_local_decrypt
decrypt_with_conversation_key, from .nip44 import encrypt_for as _nip44_local_encrypt
encrypt_with_conversation_key,
get_conversation_key,
)
_KIND_NIP78 = 30078 _KIND_NIP78 = 30078
_D_TAG_CONFIG_PREFIX = "bitspire-cassettes:" # operator → ATM _D_TAG_CONFIG_PREFIX = "bitspire-cassettes:" # operator → ATM
@ -91,7 +97,19 @@ class RelayUnavailable(CassetteTransportError):
class CassetteEventDecodeError(CassetteTransportError): class CassetteEventDecodeError(CassetteTransportError):
"""Inbound state event failed validation: bad signature, NIP-44 v2 """Inbound state event failed validation: bad signature, NIP-44 v2
decrypt failure, or payload didn't conform to PublishCassettesPayload.""" 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."""
# ============================================================================= # =============================================================================
@ -129,28 +147,19 @@ def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]:
# ============================================================================= # =============================================================================
async def _sign_as_operator( async def _resolve_operator_signer(operator_user_id: str):
operator_user_id: str, event: dict """Fetch the operator's account + resolve to a NostrSigner.
) -> Optional[dict]:
"""Sign `event` using the operator's stored Nostr identity.
Mutates `event` to add `created_at` (now), `pubkey`, `id`, and `sig`. Single source of truth for "give me the signer for this operator,
Returns the signed event, or raises a typed CassetteTransportError or raise an operator-facing error if we can't." Returns
on a hard failure the caller should surface to the operator. `(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.
Routing: post-`aiolabs/lnbits#17` (signer abstraction) we go through Raises:
`lnbits.core.signers.resolve_signer`, which transparently handles - OperatorIdentityMissing no account, or no pubkey on file
LocalSigner (envelope-encrypted nsec at rest, decrypted on demand) - SignerUnavailable signer resolve failed, or signer can't sign
and ClientSideOnlySigner (raises SignerUnavailableError). On pre-#17 server-side (ClientSideOnly)
lnbits versions the import fails and we fall back to a direct
`account.prvkey` read. Both paths produce identical signed events.
Pattern preserved from the removed nostr_publish.py at commit
e13178d / 131ff92 recovered here for the cassette transport.
Unlike the prior fleet-publish path (which soft-failed on missing
operator identity since the publish was a CRUD side-effect), the
cassette publish is operator-initiated so missing identity is a hard
error surfaced as HTTP 400 by the API caller.
""" """
account = await get_account(operator_user_id) account = await get_account(operator_user_id)
if account is None or not account.pubkey: if account is None or not account.pubkey:
@ -159,30 +168,6 @@ async def _sign_as_operator(
"Onboard via the LNbits Nostr-login flow to publish cassette " "Onboard via the LNbits Nostr-login flow to publish cassette "
"config to your ATMs." "config to your ATMs."
) )
# created_at is part of the BIP-340 event-id hash; must be set before
# signing so both code paths below see the same value.
event["created_at"] = int(time.time())
try:
from lnbits.core.signers import ( # type: ignore[import-not-found]
SignerError,
SignerUnavailableError,
resolve_signer,
)
except ImportError:
# Pre-#17 lnbits — direct prvkey read. Removed once the #17
# cascade lands on every host that runs this extension.
if not account.prvkey:
raise OperatorIdentityMissing(
f"operator {operator_user_id[:8]}... has no signing key "
"on file (pre-lnbits#17 path). Onboard via Nostr-login or "
"wait for aiolabs/lnbits#18 bunker integration."
)
private_key = coincurve.PrivateKey(bytes.fromhex(account.prvkey))
return sign_event(event, account.pubkey, private_key)
# Post-#17 lnbits — route through the signer abstraction.
try: try:
signer = resolve_signer(account) signer = resolve_signer(account)
except SignerError as exc: except SignerError as exc:
@ -190,16 +175,32 @@ async def _sign_as_operator(
f"signer resolve failed for operator {operator_user_id[:8]}...: " f"signer resolve failed for operator {operator_user_id[:8]}...: "
f"{exc}" f"{exc}"
) from exc ) from exc
if not signer.can_sign(): if not signer.can_sign():
raise SignerUnavailable( raise SignerUnavailable(
f"operator {operator_user_id[:8]}... has a client-side-only " f"operator {operator_user_id[:8]}... has a client-side-only "
"signer; server can't publish on their behalf. Wait for bunker " "signer; server can't sign or NIP-44-encrypt on their behalf. "
"integration (lnbits#18) or operator-driven publishing." "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
) -> Optional[dict]:
"""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: try:
return signer.sign_event(event) return await signer.sign_event(event)
except SignerUnavailableError as exc: except SignerUnavailableError as exc:
raise SignerUnavailable( raise SignerUnavailable(
f"signer unavailable for operator {operator_user_id[:8]}...: " f"signer unavailable for operator {operator_user_id[:8]}...: "
@ -207,27 +208,57 @@ async def _sign_as_operator(
) from exc ) from exc
async def _get_operator_privkey_hex(operator_user_id: str) -> str: async def _nip44_encrypt_via_signer(
"""Fetch the operator's signing key hex for NIP-44 v2 encryption. 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.
NIP-44 v2 ECDH needs the raw private scalar, which the signer The bunker (RemoteBunkerSigner) implements `nip44_encrypt` natively
abstraction's high-level `sign_event` doesn't expose. For v1 we the operator's nsec never leaves the bunker process. LocalSigner's
read `account.prvkey` directly same surface that the pre-#17 `nip44_encrypt` stub explicitly raises SignerUnavailableError
fallback in `_sign_as_operator` uses. Post-bunker (lnbits#18) ("LocalSigner does not implement nip44_encrypt") per the
this becomes a NIP-44-over-bunker call routed through the bunker post-PR-#38 ABC — the spec is "migrate to bunker." For the
client (the operator's nsec never leaves the bunker process), but transitional window where some operators are still on LocalSigner
that path is v2 follow-up. + 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.
Raises OperatorIdentityMissing on missing keys. 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)`.
""" """
account = await get_account(operator_user_id) try:
if account is None or not account.prvkey: return await signer.nip44_encrypt(plaintext, peer_pubkey_hex)
raise OperatorIdentityMissing( except SignerUnavailableError:
f"operator {operator_user_id[:8]}... has no signing key on " if (
"file; can't NIP-44 v2 encrypt the cassette payload to the " account.signer_type == "LocalSigner"
"ATM. Onboard via the LNbits Nostr-login flow." and account.prvkey
) ):
return 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
# ============================================================================= # =============================================================================
@ -269,18 +300,39 @@ async def publish_to_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 CassetteTransportError subclasses on hard failures:
- OperatorIdentityMissing 400: operator hasn't onboarded - OperatorIdentityMissing 400: operator hasn't onboarded
- SignerUnavailable 503: signer offline / client-side-only - SignerUnavailable 503: signer offline / client-side-only / bunker
timeout at the encrypt or sign step
- RelayUnavailable 503: nostrclient not installed - RelayUnavailable 503: nostrclient not installed
- CassetteTransportError 500: anything else - CassetteTransportError 500: anything else
""" """
atm_pubkey_hex = _atm_hex_pubkey(machine) atm_pubkey_hex = _atm_hex_pubkey(machine)
# Build the NIP-44 v2 encrypted content using the operator's privkey # Single fetch + resolve — same signer is used for both encrypt and sign.
# as sender and the ATM pubkey as recipient. account, signer = await _resolve_operator_signer(operator_user_id)
operator_privkey_hex = await _get_operator_privkey_hex(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=(",", ":")) plaintext = json.dumps(payload.to_wire_dict(), separators=(",", ":"))
conversation_key = get_conversation_key(operator_privkey_hex, atm_pubkey_hex) try:
content = encrypt_with_conversation_key(plaintext, conversation_key) 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 = { event: dict = {
"kind": _KIND_NIP78, "kind": _KIND_NIP78,
@ -289,11 +341,9 @@ async def publish_to_atm(
["p", atm_pubkey_hex], ["p", atm_pubkey_hex],
], ],
"content": content, "content": content,
# created_at is set inside _sign_as_operator before signing.
} }
signed = await _sign_as_operator(operator_user_id, event) signed = await _sign_as_operator(operator_user_id, event)
# _sign_as_operator raises on hard failure; a None return would mean
# an unexpected soft-path slipped through — treat as hard error here.
if signed is None: if signed is None:
raise CassetteTransportError( raise CassetteTransportError(
"sign_as_operator returned None unexpectedly — soft-fail path " "sign_as_operator returned None unexpectedly — soft-fail path "
@ -314,24 +364,32 @@ async def publish_to_atm(
# ============================================================================= # =============================================================================
def decrypt_and_parse_state_event( async def decrypt_and_parse_state_event(
event: dict, operator_privkey_hex: str event: dict, account, signer: NostrSigner
) -> PublishCassettesPayload: ) -> PublishCassettesPayload:
"""Decrypt + parse an inbound `bitspire-cassettes-state:<atm_pubkey_hex>` """Decrypt + parse an inbound `bitspire-cassettes-state:<atm_pubkey_hex>`
event the ATM published toward the operator. Caller is responsible event the ATM published toward the operator.
for:
Caller is responsible for:
- filtering on `kind=30078` and the expected `#d` tag list - filtering on `kind=30078` and the expected `#d` tag list
- verifying the event signature (lnbits.utils.nostr.verify_event) - verifying the event signature (lnbits.utils.nostr.verify_event)
- confirming `event["pubkey"]` matches a known ATM in the operator's - confirming `event["pubkey"]` matches a known ATM (= machine.machine_npub
machines table (the d-tag suffix == event pubkey == machine.machine_npub canonicalised) the consumer task does this before calling here
canonicalised) - resolving the operator's account + signer via
`_resolve_operator_signer(...)` and passing them in
This function does: This function does:
- NIP-44 v2 decrypt of event["content"] using the sender's pubkey - NIP-44 v2 decrypt of event["content"] via `signer.nip44_decrypt`
from event["pubkey"] and the operator's privkey (bunker round-trip on RemoteBunkerSigner; direct prvkey on the
transitional LocalSigner path)
- JSON parse + PublishCassettesPayload validation - JSON parse + PublishCassettesPayload validation
Raises CassetteEventDecodeError on any decode/validate failure. 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") sender_pubkey = event.get("pubkey")
content = event.get("content") content = event.get("content")
@ -341,16 +399,31 @@ def decrypt_and_parse_state_event(
) )
try: try:
conversation_key = get_conversation_key( plaintext = await _nip44_decrypt_via_signer(
operator_privkey_hex, sender_pubkey account, signer, content, sender_pubkey
) )
plaintext = decrypt_with_conversation_key(content, conversation_key) except NsecBunkerTimeoutError as exc:
except Nip44Error as exc: raise CassetteEventTransientError(
f"bunker unreachable while decrypting cassette state event: {exc}"
) from exc
except NsecBunkerRpcError as exc:
raise CassetteEventDecodeError( raise CassetteEventDecodeError(
f"NIP-44 v2 decrypt failed: {exc}" 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 ) from exc
except ValueError as exc: except ValueError as exc:
# coincurve raises ValueError on a malformed pubkey hex. # coincurve raises ValueError on a malformed pubkey hex (only
# reachable via the LocalSigner fallback path; the bunker handles
# pubkey validation server-side).
raise CassetteEventDecodeError( raise CassetteEventDecodeError(
f"sender pubkey is malformed: {exc}" f"sender pubkey is malformed: {exc}"
) from exc ) from exc

View file

@ -1,17 +1,37 @@
""" """
NIP-44 v2 versioned encrypted payloads (https://github.com/nostr-protocol/nips/blob/master/44.md). NIP-44 v2 versioned encrypted payloads (https://github.com/nostr-protocol/nips/blob/master/44.md).
Hand-rolled because lnbits ships only NIP-04 (AES-CBC) in `lnbits.utils.nostr.encrypt_content`, Hand-rolled because lnbits historically shipped only NIP-04 (AES-CBC) in
and the locked design at aiolabs/satmachineadmin#29 (paired with lamassu-next#56) wires `lnbits.utils.nostr.encrypt_content`, and the locked design at
cassette config over kind-30078 with NIP-44 v2 encrypted content. Adding a Python NIP-44 aiolabs/satmachineadmin#29 (paired with lamassu-next#56) wires cassette config
v2 lib dep was an option per the plan; chose the hand-roll path to stay dep-light and over kind-30078 with NIP-44 v2 encrypted content.
keep the impl auditable inline.
Two safety nets keep this honest: ## Runtime status (post lnbits PR #38, 2026-05-31)
**Runtime usage has migrated to the signer abstraction** via
`signer.nip44_encrypt` / `signer.nip44_decrypt` on `lnbits.core.signers.base.
NostrSigner`. For RemoteBunkerSigner-backed accounts the bunker performs the
crypto and the operator's nsec never leaves the bunker process; for the
transitional LocalSigner path `cassette_transport._nip44_*_via_signer` falls
back to the helpers in this module against the stored `account.prvkey`.
This module's runtime export footprint is therefore:
- `encrypt_for` / `decrypt_from` called by the LocalSigner fallback in
`cassette_transport` until every operator on the instance is bunker-backed
(S7 / aiolabs/satmachineadmin#21). Then those calls disappear too.
- Everything else (encrypt_with_conversation_key, decrypt_with_conversation_key,
get_conversation_key, padding helpers, error classes) is **test-only**:
referenced by `tests/test_nip44_v2.py` to validate the wire format against
the canonical paulmillr/nip44 reference vectors and the bitspire cross-test
fixture posted to the coordination log.
Don't add new runtime call sites here. The signer abstraction is the path.
Two safety nets keep the impl honest:
1. tests/test_nip44_v2.py runs reference vectors + round-trip + tamper-detection. 1. tests/test_nip44_v2.py runs reference vectors + round-trip + tamper-detection.
2. bitspire posts a sample event encrypted on their nostr-tools side to the coord log; 2. bitspire posts a sample event encrypted on their nostr-tools side to the
test_decrypts_bitspire_sample_event_from_coord_log cross-checks our impl against coord log; test_decrypts_bitspire_sample_event cross-checks our impl
theirs by decrypting that event with a known privkey. against theirs by decrypting that event with a known privkey.
Wire format (per spec): Wire format (per spec):
payload = base64( 0x02 || nonce (32B) || ciphertext (var) || mac (32B) ) payload = base64( 0x02 || nonce (32B) || ciphertext (var) || mac (32B) )

View file

@ -386,18 +386,37 @@ async def _handle_cassette_state_event(
get_machine_by_atm_pubkey_hex, get_machine_by_atm_pubkey_hex,
apply_bootstrap_state, apply_bootstrap_state,
) -> None: ) -> None:
"""Verify signature, route to the right operator's privkey, decrypt, """Verify signature, resolve the operator's signer, decrypt via the
parse, upsert. Each step that fails is logged at WARNING (not ERROR) signer abstraction (bunker round-trip for RemoteBunkerSigner; direct
so a noisy attacker can't fill the logs — this is data on a public prvkey on the LocalSigner transitional fallback inside the transport
relay, garbage is expected.""" helper), parse, upsert.
Each step logs at WARNING (not ERROR) so a noisy attacker can't fill
the logs this is data on a public relay, garbage is expected.
Two skip outcomes:
- Terminal (CassetteEventDecodeError / SignerUnavailable /
OperatorIdentityMissing / etc.): log + return. `apply_bootstrap_
state` is never called `state_event_id` is not advanced
same event would re-process on next poll cycle but the consumer's
WARN log surfaces the underlying issue immediately.
- Transient (CassetteEventTransientError): log at INFO (less noisy)
+ return. Same retry-via-no-advance semantics, just less
alarming in the operator log feed.
"""
import json as _json import json as _json
from datetime import datetime as _datetime from datetime import datetime as _datetime
from datetime import timezone as _timezone from datetime import timezone as _timezone
from lnbits.core.crud.users import get_account
from lnbits.utils.nostr import verify_event from lnbits.utils.nostr import verify_event
from .cassette_transport import decrypt_and_parse_state_event from .cassette_transport import (
CassetteEventDecodeError,
CassetteEventTransientError,
CassetteTransportError,
_resolve_operator_signer,
decrypt_and_parse_state_event,
)
event_raw = event_message.event event_raw = event_message.event
if isinstance(event_raw, str): if isinstance(event_raw, str):
@ -430,16 +449,36 @@ async def _handle_cassette_state_event(
) )
return return
account = await get_account(machine.operator_user_id) try:
if account is None or not account.prvkey: account, signer = await _resolve_operator_signer(
machine.operator_user_id
)
except CassetteTransportError as exc:
# OperatorIdentityMissing / SignerUnavailable — log + skip.
logger.warning( logger.warning(
f"satmachineadmin: operator {machine.operator_user_id[:8]}... " f"satmachineadmin: can't resolve signer for operator "
"has no privkey on file; can't decrypt cassette state event for " f"{machine.operator_user_id[:8]}... (machine {machine.id}): "
f"machine {machine.id}. Onboard via Nostr-login." f"{exc}"
) )
return return
payload = decrypt_and_parse_state_event(event_obj, account.prvkey) try:
payload = await decrypt_and_parse_state_event(
event_obj, account, signer
)
except CassetteEventTransientError as exc:
logger.info(
f"satmachineadmin: cassette state event for machine {machine.id} "
f"hit a transient signer error (will retry next poll): {exc}"
)
return
except CassetteEventDecodeError as exc:
logger.warning(
f"satmachineadmin: cassette state event decode failed for "
f"machine {machine.id} (id={event_obj.get('id', '?')[:12]}...): "
f"{exc}"
)
return
event_id = event_obj.get("id", "") event_id = event_obj.get("id", "")
created_at_unix = event_obj.get("created_at", 0) created_at_unix = event_obj.get("created_at", 0)

View file

@ -1,32 +1,49 @@
""" """
Tests for the cassette bootstrap consumer (`tasks._handle_cassette_state_event` Tests for the cassette bootstrap consumer's transport-decrypt path
and `cassette_transport.decrypt_and_parse_state_event`). (`cassette_transport.decrypt_and_parse_state_event`) and d-tag construction.
Covers the consumer-side validation path end-to-end without standing up Post-PR-#38 migration (2026-05-31): the function takes an Account +
the full nostrclient relay subscription: NostrSigner instead of a raw privkey, and is async. Tests use:
- happy path: signed event from a known ATM decrypt parse returns - `_FakeBunkerSigner` implements async `nip44_decrypt/encrypt` against
a position-keyed PublishCassettesPayload the hand-rolled `nip44` impl so tests don't need a live bunker.
- multiple same-denom cassettes (v1.1 operational case) round-trips Exercises the "happy" RemoteBunkerSigner path.
- tampered ciphertext CassetteEventDecodeError - `_FakeLocalSignerStub` raises `SignerUnavailableError` from
- wrong operator privkey CassetteEventDecodeError (well-formed but `nip44_decrypt`, mimicking the post-#38 `LocalSigner` stub. Combined
decrypt fails because conversation key is wrong) with an Account that has `signer_type="LocalSigner"` + `prvkey`,
- malformed pubkey CassetteEventDecodeError exercises the transitional fallback path in
- missing fields CassetteEventDecodeError `_nip44_decrypt_via_signer`.
- decrypted garbage / wrong-shape JSON CassetteEventDecodeError - `_FakeRaisingSigner` raises an arbitrary exception, used to
exercise the `NsecBunkerTimeoutError` `CassetteEventTransientError`
and `NsecBunkerRpcError` `CassetteEventDecodeError` mappings.
Full handler tests (the dispatch through verify_event get_machine_by_atm_ Coroutines are driven via `asyncio.run` so no pytest-asyncio config is
pubkey_hex apply_bootstrap_state) need a live LNbits DB; they're required. Matches the existing project test pattern (test_init.py
smoke-tested manually via the dev container per the project's existing demonstrates the project lacks an asyncio plugin in CI; using asyncio.run
convention (see test_deposit_currency.py). inside the test body sidesteps that without changing project config).
Full handler tests (the dispatch through verify_event
get_machine_by_atm_pubkey_hex apply_bootstrap_state) need a live LNbits
DB; smoke-tested manually via the dev container per the project
convention (see test_deposit_currency.py rationale).
""" """
import asyncio
import json import json
from types import SimpleNamespace
from typing import Optional
import coincurve import coincurve
import pytest import pytest
from lnbits.core.services.nip46_bunker_client import (
NsecBunkerRpcError,
NsecBunkerTimeoutError,
)
from lnbits.core.signers.base import SignerUnavailableError
from ..cassette_transport import ( from ..cassette_transport import (
CassetteEventDecodeError, CassetteEventDecodeError,
CassetteEventTransientError,
_atm_hex_pubkey, _atm_hex_pubkey,
_config_d_tag, _config_d_tag,
_state_d_tag, _state_d_tag,
@ -34,7 +51,13 @@ from ..cassette_transport import (
decrypt_and_parse_state_event, decrypt_and_parse_state_event,
) )
from ..models import Machine, PublishCassettesPayload from ..models import Machine, PublishCassettesPayload
from ..nip44 import encrypt_with_conversation_key, get_conversation_key from ..nip44 import (
decrypt_from as _nip44_decrypt,
)
from ..nip44 import (
encrypt_with_conversation_key,
get_conversation_key,
)
# Canonical keys (integer 1 + integer 2, the paulmillr/nip44 reference pair). # Canonical keys (integer 1 + integer 2, the paulmillr/nip44 reference pair).
@ -54,6 +77,91 @@ _OP_PUB = _pub_hex(_OP_SEC)
_ATM_PUB = _pub_hex(_ATM_SEC) _ATM_PUB = _pub_hex(_ATM_SEC)
# =============================================================================
# Fake signers + account-shaped helper
# =============================================================================
class _FakeBunkerSigner:
"""Test double for RemoteBunkerSigner — implements async nip44_*
against the hand-rolled `nip44` impl. Used to exercise the
"signer.nip44_decrypt returns successfully" path without standing up
a live bunker process."""
def __init__(self, privkey_hex: str):
self._privkey_hex = privkey_hex
@property
def pubkey(self) -> str:
return _pub_hex(self._privkey_hex)
def can_sign(self) -> bool:
return True
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
ck = get_conversation_key(self._privkey_hex, peer_pubkey_hex)
return encrypt_with_conversation_key(plaintext, ck)
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
return _nip44_decrypt(ciphertext, self._privkey_hex, peer_pubkey_hex)
class _FakeLocalSignerStub:
"""Test double for the post-#38 LocalSigner stub — its nip44_* always
raises SignerUnavailableError. Combined with an Account that has
`signer_type='LocalSigner'` + `prvkey` populated, exercises the
transitional fallback in `_nip44_decrypt_via_signer` (which catches
the SignerUnavailableError and falls back to direct-prvkey via the
hand-rolled impl)."""
def can_sign(self) -> bool:
return True
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
raise SignerUnavailableError(
"LocalSigner does not implement nip44_encrypt"
)
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
raise SignerUnavailableError(
"LocalSigner does not implement nip44_decrypt"
)
class _FakeRaisingSigner:
"""Test double that raises a configurable exception on nip44_decrypt.
Used to validate the bunker-error-mapping branches in
decrypt_and_parse_state_event."""
def __init__(self, exc):
self._exc = exc
def can_sign(self) -> bool:
return True
async def nip44_encrypt(self, plaintext: str, peer_pubkey_hex: str) -> str:
raise self._exc
async def nip44_decrypt(self, ciphertext: str, peer_pubkey_hex: str) -> str:
raise self._exc
def _fake_account(
signer_type: str = "RemoteBunkerSigner",
prvkey: Optional[str] = None,
):
"""Account-shaped duck-typed object. decrypt_and_parse_state_event +
_nip44_decrypt_via_signer only read `.signer_type` and `.prvkey`; the
rest is irrelevant."""
return SimpleNamespace(
id="test-operator",
pubkey=_OP_PUB,
prvkey=prvkey,
signer_type=signer_type,
signer_config=None,
)
def _make_state_event( def _make_state_event(
payload: PublishCassettesPayload, payload: PublishCassettesPayload,
*, *,
@ -63,10 +171,9 @@ def _make_state_event(
event_id: str = "fake-event-id", event_id: str = "fake-event-id",
created_at: int = 1234567890, created_at: int = 1234567890,
) -> dict: ) -> dict:
"""Build a state event the way bitspire's ATM publisher would. """Build a state event the way bitspire's ATM publisher would. Skips
Skips the actual sig-verify step (the handler-level test does the sig-verify step (handler-level concern); the transport-decrypt
that against verify_event); the transport-level decrypt path path doesn't depend on sig validity, only on conversation-key match."""
doesn't care about sig validity, only about the conversation key."""
plaintext = json.dumps(payload.to_wire_dict(), separators=(",", ":")) plaintext = json.dumps(payload.to_wire_dict(), separators=(",", ":"))
ck = get_conversation_key(atm_sec, op_pub) ck = get_conversation_key(atm_sec, op_pub)
content = encrypt_with_conversation_key(plaintext, ck) content = encrypt_with_conversation_key(plaintext, ck)
@ -84,17 +191,16 @@ def _make_state_event(
# ============================================================================= # =============================================================================
# decrypt_and_parse_state_event — transport-decrypt path # decrypt_and_parse_state_event — RemoteBunkerSigner happy path
# ============================================================================= # =============================================================================
class TestDecryptAndParseStateEvent: class TestDecryptViaBunkerSigner:
"""The function the consumer task calls per inbound event. Verifies """The expected production path post-#38: operator account is bunker-
NIP-44 v2 decrypt + JSON-parse + PublishCassettesPayload validation. backed, signer.nip44_decrypt routes through the bunker (mocked here
Sig verification is the caller's responsibility (the handler does it via _FakeBunkerSigner), and the wire payload round-trips cleanly."""
before reaching here)."""
def test_happy_path(self): def test_happy_path_recovers_positions_keyed_payload(self):
payload = PublishCassettesPayload( payload = PublishCassettesPayload(
positions={ positions={
"1": {"denomination": 20, "count": 49}, "1": {"denomination": 20, "count": 49},
@ -102,7 +208,12 @@ class TestDecryptAndParseStateEvent:
} }
) )
event = _make_state_event(payload) event = _make_state_event(payload)
recovered = decrypt_and_parse_state_event(event, _OP_SEC) account = _fake_account(signer_type="RemoteBunkerSigner")
signer = _FakeBunkerSigner(_OP_SEC)
recovered = asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
assert sorted(recovered.positions.keys()) == [1, 2] assert sorted(recovered.positions.keys()) == [1, 2]
assert recovered.positions[1].denomination == 20 assert recovered.positions[1].denomination == 20
assert recovered.positions[1].count == 49 assert recovered.positions[1].count == 49
@ -110,8 +221,8 @@ class TestDecryptAndParseStateEvent:
assert recovered.positions[2].count == 100 assert recovered.positions[2].count == 100
def test_round_trips_multiple_same_denomination(self): def test_round_trips_multiple_same_denomination(self):
"""v1.1 operational case from coord-log 18:45Z: real machines """v1.1 operational case (coord-log 2026-05-30T18:45Z) — multiple
load multiple cassettes with the same denomination.""" bays carrying the same denomination."""
payload = PublishCassettesPayload( payload = PublishCassettesPayload(
positions={ positions={
"1": {"denomination": 20, "count": 100}, "1": {"denomination": 20, "count": 100},
@ -121,44 +232,166 @@ class TestDecryptAndParseStateEvent:
} }
) )
event = _make_state_event(payload) event = _make_state_event(payload)
recovered = decrypt_and_parse_state_event(event, _OP_SEC) account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
recovered = asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
assert len(recovered.positions) == 4 assert len(recovered.positions) == 4
for pos in (1, 2, 3, 4): for pos in (1, 2, 3, 4):
assert recovered.positions[pos].denomination == 20 assert recovered.positions[pos].denomination == 20
assert recovered.positions[pos].count == 100 assert recovered.positions[pos].count == 100
# =============================================================================
# decrypt_and_parse_state_event — LocalSigner transitional fallback
# =============================================================================
class TestDecryptViaLocalSignerFallback:
"""When the operator account is still on LocalSigner (pre-bunker
migration), the LocalSigner stub raises SignerUnavailableError from
nip44_decrypt. `_nip44_decrypt_via_signer` catches that and falls
back to the hand-rolled impl using `account.prvkey`. Same wire
output; transitional until S7 retires LocalSigner accounts entirely."""
def test_localsigner_with_prvkey_decrypts_via_fallback(self):
payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
event = _make_state_event(payload)
account = _fake_account(signer_type="LocalSigner", prvkey=_OP_SEC)
signer = _FakeLocalSignerStub()
recovered = asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
assert recovered.positions[1].denomination == 20
assert recovered.positions[1].count == 49
def test_localsigner_without_prvkey_raises_decode_error(self):
"""A LocalSigner account whose prvkey field is None (impossible
in practice LocalSigner requires prvkey at provision time, but
defensive in case the row is corrupt) should surface as a
decode error, not silently succeed."""
payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
event = _make_state_event(payload)
account = _fake_account(signer_type="LocalSigner", prvkey=None)
signer = _FakeLocalSignerStub()
with pytest.raises(CassetteEventDecodeError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
def test_clientonlysigner_raises_decode_error(self):
"""ClientSideOnlySigner has no server-side decrypt path at all;
falling back to direct-prvkey is also impossible (no prvkey).
Surface as a decode error so the consumer logs + skips."""
payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
event = _make_state_event(payload)
account = _fake_account(
signer_type="ClientSideOnlySigner", prvkey=None
)
signer = _FakeLocalSignerStub() # behaves the same way: raises
with pytest.raises(CassetteEventDecodeError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
# =============================================================================
# decrypt_and_parse_state_event — bunker error mapping
# =============================================================================
class TestBunkerErrorMapping:
"""The post-#38 error hierarchy splits transient (bunker partitioned)
from terminal (bunker policy reject, MAC failure). Consumer behaves
differently transient retries, terminal logs + skips. Validate the
mapping from NsecBunker* exceptions to our CassetteEvent* types."""
def test_timeout_maps_to_transient_error(self):
"""Bunker unreachable → NsecBunkerTimeoutError → caller-visible
CassetteEventTransientError. Consumer treats this as retry-
eligible (don't advance state_event_id)."""
payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
event = _make_state_event(payload)
account = _fake_account()
signer = _FakeRaisingSigner(
NsecBunkerTimeoutError("bunker unreachable")
)
with pytest.raises(CassetteEventTransientError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
def test_rpc_reject_maps_to_decode_error(self):
"""Bunker rejected the RPC (policy / MAC / config) →
NsecBunkerRpcError caller-visible CassetteEventDecodeError.
Terminal retrying won't help."""
payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
event = _make_state_event(payload)
account = _fake_account()
signer = _FakeRaisingSigner(
NsecBunkerRpcError("bunker policy reject: kind 30078 not authorised")
)
with pytest.raises(CassetteEventDecodeError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
# =============================================================================
# decrypt_and_parse_state_event — payload + envelope validation
# =============================================================================
class TestPayloadValidation:
"""Errors that originate at the parse layer (post-decrypt), not the
signer. Same set as pre-migration covered through the bunker-signer
path since LocalSigner is going away."""
def test_tampered_content_rejected(self): def test_tampered_content_rejected(self):
payload = PublishCassettesPayload( payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}} positions={"1": {"denomination": 20, "count": 49}}
) )
event = _make_state_event(payload) event = _make_state_event(payload)
# Flip a base64 character — corrupts the ciphertext or MAC
# depending on where the flip lands.
event["content"] = event["content"][:-2] + "AA" event["content"] = event["content"][:-2] + "AA"
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
decrypt_and_parse_state_event(event, _OP_SEC) asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
def test_wrong_operator_privkey_rejected(self): def test_wrong_signer_privkey_rejected(self):
"""The conversation key derives from operator-privkey + sender-pubkey. """Wrong privkey on the signer → wrong conversation key → MAC
A wrong privkey gives a different conversation key, which yields a verification fails inside nip44_decrypt surfaces as decode
different hmac_key, so MAC verification inside NIP-44 v2 decrypt error (via the hand-rolled Nip44Error since this is the fake
fails surfaced as CassetteEventDecodeError.""" bunker signer; in production the bunker would raise
NsecBunkerRpcError which also maps to CassetteEventDecodeError)."""
payload = PublishCassettesPayload( payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}} positions={"1": {"denomination": 20, "count": 49}}
) )
event = _make_state_event(payload) event = _make_state_event(payload)
account = _fake_account()
wrong_sec = "00" * 31 + "03" wrong_sec = "00" * 31 + "03"
signer = _FakeBunkerSigner(wrong_sec)
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
decrypt_and_parse_state_event(event, wrong_sec) asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
def test_malformed_sender_pubkey_rejected(self): )
payload = PublishCassettesPayload(
positions={"1": {"denomination": 20, "count": 49}}
)
event = _make_state_event(payload)
event["pubkey"] = "not-a-real-pubkey"
with pytest.raises(CassetteEventDecodeError):
decrypt_and_parse_state_event(event, _OP_SEC)
def test_missing_content_rejected(self): def test_missing_content_rejected(self):
event = _make_state_event( event = _make_state_event(
@ -167,8 +400,12 @@ class TestDecryptAndParseStateEvent:
) )
) )
del event["content"] del event["content"]
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
decrypt_and_parse_state_event(event, _OP_SEC) asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
def test_missing_pubkey_rejected(self): def test_missing_pubkey_rejected(self):
event = _make_state_event( event = _make_state_event(
@ -177,14 +414,18 @@ class TestDecryptAndParseStateEvent:
) )
) )
del event["pubkey"] del event["pubkey"]
account = _fake_account()
signer = _FakeBunkerSigner(_OP_SEC)
with pytest.raises(CassetteEventDecodeError): with pytest.raises(CassetteEventDecodeError):
decrypt_and_parse_state_event(event, _OP_SEC) asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
def test_decrypted_garbage_json_rejected(self): def test_decrypted_garbage_json_rejected(self):
"""If the plaintext decrypts but isn't JSON, we surface an error """If plaintext decrypts cleanly but isn't valid JSON, surface
rather than crashing the consumer loop.""" as decode error (not crash the consumer loop)."""
ck = get_conversation_key(_ATM_SEC, _OP_PUB) ck = get_conversation_key(_ATM_SEC, _OP_PUB)
bad_plaintext_event = { event = {
"kind": 30078, "kind": 30078,
"pubkey": _ATM_PUB, "pubkey": _ATM_PUB,
"content": encrypt_with_conversation_key( "content": encrypt_with_conversation_key(
@ -194,38 +435,42 @@ class TestDecryptAndParseStateEvent:
"created_at": 0, "created_at": 0,
"id": "x", "id": "x",
} }
with pytest.raises(CassetteEventDecodeError) as exc: account = _fake_account()
decrypt_and_parse_state_event(bad_plaintext_event, _OP_SEC) signer = _FakeBunkerSigner(_OP_SEC)
assert "JSON" in str(exc.value) or "didn't validate" in str(exc.value) with pytest.raises(CassetteEventDecodeError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
def test_decrypted_json_with_wrong_shape_rejected(self): def test_decrypted_wrong_shape_rejected(self):
"""Well-formed JSON but missing the 'positions' field is """Well-formed JSON but missing 'positions' → payload-shape
a payload-shape failure, not a decrypt failure.""" validation failure."""
ck = get_conversation_key(_ATM_SEC, _OP_PUB) ck = get_conversation_key(_ATM_SEC, _OP_PUB)
bad_shape_event = { event = {
"kind": 30078, "kind": 30078,
"pubkey": _ATM_PUB, "pubkey": _ATM_PUB,
"content": encrypt_with_conversation_key( "content": encrypt_with_conversation_key('{"wrong_field": 42}', ck),
'{"wrong_field": 42}', ck
),
"tags": [], "tags": [],
"created_at": 0, "created_at": 0,
"id": "x", "id": "x",
} }
with pytest.raises(CassetteEventDecodeError) as exc: account = _fake_account()
decrypt_and_parse_state_event(bad_shape_event, _OP_SEC) signer = _FakeBunkerSigner(_OP_SEC)
assert "didn't validate" in str(exc.value) with pytest.raises(CassetteEventDecodeError):
asyncio.run(
decrypt_and_parse_state_event(event, account, signer)
)
# ============================================================================= # =============================================================================
# d-tag construction — _atm_hex_pubkey, _config_d_tag, _state_d_tag, helper # d-tag construction — unchanged by the migration, kept as regression guard
# ============================================================================= # =============================================================================
class TestDTagConstruction: class TestDTagConstruction:
"""The `<m>` placeholder in d-tags = ATM hex pubkey (load-bearing per """The `<m>` placeholder in d-tags = ATM hex pubkey (load-bearing per
coord-log 11:50Z). These tests pin the canonical substitution so a coord-log 2026-05-30T11:50Z). These tests pin the canonical
refactor can't silently break wire compatibility.""" substitution so a refactor can't silently break wire compatibility."""
def _machine(self, npub: str, id_: str = "m1") -> Machine: def _machine(self, npub: str, id_: str = "m1") -> Machine:
from datetime import datetime, timezone from datetime import datetime, timezone
@ -251,8 +496,6 @@ class TestDTagConstruction:
assert _atm_hex_pubkey(self._machine(_ATM_PUB.upper())) == _ATM_PUB assert _atm_hex_pubkey(self._machine(_ATM_PUB.upper())) == _ATM_PUB
def test_atm_hex_pubkey_canonicalises_bech32_to_hex(self): def test_atm_hex_pubkey_canonicalises_bech32_to_hex(self):
"""Operator may have entered npub1... in the UI; canonical d-tag
substitution is always the hex form."""
from lnbits.utils.nostr import hex_to_npub from lnbits.utils.nostr import hex_to_npub
npub_bech32 = hex_to_npub(_ATM_PUB) npub_bech32 = hex_to_npub(_ATM_PUB)
@ -260,8 +503,7 @@ class TestDTagConstruction:
def test_config_d_tag_uses_hex_pubkey_not_id(self): def test_config_d_tag_uses_hex_pubkey_not_id(self):
"""REGRESSION GUARD: d-tag must contain the ATM hex pubkey, NOT """REGRESSION GUARD: d-tag must contain the ATM hex pubkey, NOT
the internal machine UUID. If this test fails, bitspire's ATM the internal machine UUID."""
won't see our publishes."""
m = self._machine(_ATM_PUB, id_="some-uuid-not-the-pubkey") m = self._machine(_ATM_PUB, id_="some-uuid-not-the-pubkey")
d_tag = _config_d_tag(_atm_hex_pubkey(m)) d_tag = _config_d_tag(_atm_hex_pubkey(m))
assert d_tag == f"bitspire-cassettes:{_ATM_PUB}" assert d_tag == f"bitspire-cassettes:{_ATM_PUB}"