feat(v2): bootstrap consumer task — auto-populate cassette_configs (#29 v1)
Some checks failed
ci.yml / feat(v2): bootstrap consumer task — auto-populate cassette_configs (#29 v1) (pull_request) Failing after 0s

Long-running task wired into satmachineadmin_start that subscribes to
kind-30078 bitspire-cassettes-state:<atm_pubkey_hex> events from every
active machine's ATM and upserts cassette_configs via apply_bootstrap_state
on receipt. Pairs with bitspire's one-shot bootstrap publish in
aiolabs/lamassu-next#56 — operator's first config publish then validates
against a non-empty denomination set.

Pattern mirrors wait_for_paid_invoices (try/except per event, never lets
the loop die). Uses the same nostr_client.relay_manager singleton that
cassette_transport.publish_to_atm uses, just on the subscribe side.

Implementation: poll the singleton NostrRouter.received_subscription_events
dict keyed by our subscription_id (satmachineadmin-cassette-bootstrap).
This is the same drain pattern nostrclient's per-WebSocket NostrRouter
uses; since we use a distinct sub_id, no cross-contamination with
WebSocket-connected clients of nostrclient.

Filter is re-derived from active machines each tick — newly-added
machines start receiving bootstrap events without an LNbits restart.

Soft-fail surfaces (none crash the listener):
  - nostrclient extension not installed → log + 30s backoff
  - inbound event sig-verify fails → log + skip
  - sender pubkey not in dca_machines → log + skip (relay noise)
  - operator privkey not on file → log + skip
  - NIP-44 v2 decrypt / payload validation fails → log + skip
  - apply_bootstrap_state error → log + skip

Per-event handler routes to the right operator's privkey by looking up
the machine via get_machine_by_atm_pubkey_hex (O(N) over active
machines — fine for small fleets; if fleets grow, normalize machine_npub
at write + add an index).

CRUD additions:
  - list_all_active_machines: cross-operator query for the subscription
    filter
  - get_machine_by_atm_pubkey_hex: route inbound events to the right
    machine row + operator account; accepts hex or bech32 storage

14 tests in test_cassette_state_consumer.py covering:
  - decrypt_and_parse_state_event happy path + 6 negative paths (tamper,
    wrong privkey, malformed pubkey, missing fields, garbage JSON,
    wrong-shape payload)
  - d-tag construction regression guard (REGRESSION GUARD: d-tag uses
    ATM hex pubkey not internal UUID — pins the load-bearing detail
    from coord-log 11:50Z)
  - build_state_d_tags_for_machines + bech32 → hex canonicalisation

Full handler dispatch (verify_event → get_machine_by_atm_pubkey_hex →
apply_bootstrap_state) needs a live LNbits DB; smoke-tested manually
per the existing project convention.

Total: 146 passed, 1 skipped (cross-test fixture pending), 1 pre-existing
async-plugin failure unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-30 18:19:15 +02:00
commit e57a73083e
4 changed files with 535 additions and 1 deletions

View file

@ -0,0 +1,263 @@
"""
Tests for the cassette bootstrap consumer (`tasks._handle_cassette_state_event`
and `cassette_transport.decrypt_and_parse_state_event`).
Covers the consumer-side validation path end-to-end without standing up
the full nostrclient relay subscription:
- happy path: signed event from a known ATM decrypt parse returns
a PublishCassettesPayload
- sig-verify failure path (covered at the transport-decrypt level + the
handler-level rejection test)
- tampered ciphertext CassetteEventDecodeError
- unknown sender pubkey CassetteEventDecodeError (well-formed but
decrypt fails because conversation key is wrong)
- malformed pubkey CassetteEventDecodeError
Full handler tests (the dispatch through verify_event get_machine_by_atm_
pubkey_hex apply_bootstrap_state) need a live LNbits DB; they're
smoke-tested manually via the dev container per the project's existing
convention (see test_deposit_currency.py).
"""
import json
import coincurve
import pytest
from ..cassette_transport import (
CassetteEventDecodeError,
_atm_hex_pubkey,
_config_d_tag,
_state_d_tag,
build_state_d_tags_for_machines,
decrypt_and_parse_state_event,
)
from ..models import Machine, PublishCassettesPayload
from ..nip44 import encrypt_with_conversation_key, get_conversation_key
# Canonical keys (integer 1 + integer 2, the paulmillr/nip44 reference pair).
_OP_SEC = "00" * 31 + "01"
_ATM_SEC = "00" * 31 + "02"
def _pub_hex(sec_hex: str) -> str:
return (
coincurve.PrivateKey(bytes.fromhex(sec_hex))
.public_key.format(compressed=True)[1:]
.hex()
)
_OP_PUB = _pub_hex(_OP_SEC)
_ATM_PUB = _pub_hex(_ATM_SEC)
def _make_state_event(
payload: PublishCassettesPayload,
*,
atm_sec: str = _ATM_SEC,
op_pub: str = _OP_PUB,
atm_pub: str = _ATM_PUB,
event_id: str = "fake-event-id",
created_at: int = 1234567890,
) -> dict:
"""Build a state event the way bitspire's ATM publisher would.
Skips the actual sig-verify step (the handler-level test does
that against verify_event); the transport-level decrypt path
doesn't care about sig validity, only about the conversation key."""
plaintext = json.dumps(payload.to_wire_dict(), separators=(",", ":"))
ck = get_conversation_key(atm_sec, op_pub)
content = encrypt_with_conversation_key(plaintext, ck)
return {
"kind": 30078,
"pubkey": atm_pub,
"content": content,
"tags": [
["d", f"bitspire-cassettes-state:{atm_pub}"],
["p", op_pub],
],
"created_at": created_at,
"id": event_id,
}
# =============================================================================
# decrypt_and_parse_state_event — transport-decrypt path
# =============================================================================
class TestDecryptAndParseStateEvent:
"""The function the consumer task calls per inbound event. Verifies
NIP-44 v2 decrypt + JSON-parse + PublishCassettesPayload validation.
Sig verification is the caller's responsibility (the handler does it
before reaching here)."""
def test_happy_path(self):
payload = PublishCassettesPayload(
denominations={
"20": {"position": 1, "count": 49},
"50": {"position": 2, "count": 100},
}
)
event = _make_state_event(payload)
recovered = decrypt_and_parse_state_event(event, _OP_SEC)
assert sorted(recovered.denominations.keys()) == [20, 50]
assert recovered.denominations[20].position == 1
assert recovered.denominations[20].count == 49
assert recovered.denominations[50].count == 100
def test_tampered_content_rejected(self):
payload = PublishCassettesPayload(
denominations={"20": {"position": 1, "count": 49}}
)
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"
with pytest.raises(CassetteEventDecodeError):
decrypt_and_parse_state_event(event, _OP_SEC)
def test_wrong_operator_privkey_rejected(self):
"""The conversation key derives from operator-privkey + sender-pubkey.
A wrong privkey gives a different conversation key, which yields a
different hmac_key, so MAC verification inside NIP-44 v2 decrypt
fails surfaced as CassetteEventDecodeError."""
payload = PublishCassettesPayload(
denominations={"20": {"position": 1, "count": 49}}
)
event = _make_state_event(payload)
wrong_sec = "00" * 31 + "03"
with pytest.raises(CassetteEventDecodeError):
decrypt_and_parse_state_event(event, wrong_sec)
def test_malformed_sender_pubkey_rejected(self):
payload = PublishCassettesPayload(
denominations={"20": {"position": 1, "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):
event = _make_state_event(
PublishCassettesPayload(denominations={"20": {"position": 1, "count": 49}})
)
del event["content"]
with pytest.raises(CassetteEventDecodeError):
decrypt_and_parse_state_event(event, _OP_SEC)
def test_missing_pubkey_rejected(self):
event = _make_state_event(
PublishCassettesPayload(denominations={"20": {"position": 1, "count": 49}})
)
del event["pubkey"]
with pytest.raises(CassetteEventDecodeError):
decrypt_and_parse_state_event(event, _OP_SEC)
def test_decrypted_garbage_json_rejected(self):
"""If the plaintext decrypts but isn't JSON, we surface an error
rather than crashing the consumer loop."""
# Encrypt something that isn't JSON
ck = get_conversation_key(_ATM_SEC, _OP_PUB)
bad_plaintext_event = {
"kind": 30078,
"pubkey": _ATM_PUB,
"content": encrypt_with_conversation_key(
"definitely not json", ck
),
"tags": [],
"created_at": 0,
"id": "x",
}
with pytest.raises(CassetteEventDecodeError) as exc:
decrypt_and_parse_state_event(bad_plaintext_event, _OP_SEC)
assert "JSON" in str(exc.value) or "didn't validate" in str(exc.value)
def test_decrypted_json_with_wrong_shape_rejected(self):
"""Well-formed JSON but missing the 'denominations' field is
a payload-shape failure, not a decrypt failure."""
ck = get_conversation_key(_ATM_SEC, _OP_PUB)
bad_shape_event = {
"kind": 30078,
"pubkey": _ATM_PUB,
"content": encrypt_with_conversation_key(
'{"wrong_field": 42}', ck
),
"tags": [],
"created_at": 0,
"id": "x",
}
with pytest.raises(CassetteEventDecodeError) as exc:
decrypt_and_parse_state_event(bad_shape_event, _OP_SEC)
assert "didn't validate" in str(exc.value)
# =============================================================================
# d-tag construction — _atm_hex_pubkey, _config_d_tag, _state_d_tag, helper
# =============================================================================
class TestDTagConstruction:
"""The `<m>` placeholder in d-tags = ATM hex pubkey (load-bearing per
coord-log 11:50Z). These tests pin the canonical substitution so a
refactor can't silently break wire compatibility."""
def _machine(self, npub: str, id_: str = "m1") -> Machine:
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
return Machine(
id=id_,
operator_user_id="op1",
machine_npub=npub,
wallet_id="w1",
name=None,
location=None,
fiat_code="EUR",
is_active=True,
created_at=now,
updated_at=now,
)
def test_atm_hex_pubkey_from_hex_storage(self):
assert _atm_hex_pubkey(self._machine(_ATM_PUB)) == _ATM_PUB
def test_atm_hex_pubkey_lowercases_uppercase_hex(self):
assert _atm_hex_pubkey(self._machine(_ATM_PUB.upper())) == _ATM_PUB
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
npub_bech32 = hex_to_npub(_ATM_PUB)
assert _atm_hex_pubkey(self._machine(npub_bech32)) == _ATM_PUB
def test_config_d_tag_uses_hex_pubkey_not_id(self):
"""REGRESSION GUARD: d-tag must contain the ATM hex pubkey, NOT
the internal machine UUID. If this test fails, bitspire's ATM
won't see our publishes."""
m = self._machine(_ATM_PUB, id_="some-uuid-not-the-pubkey")
d_tag = _config_d_tag(_atm_hex_pubkey(m))
assert d_tag == f"bitspire-cassettes:{_ATM_PUB}"
assert "some-uuid" not in d_tag
def test_state_d_tag_uses_hex_pubkey_not_id(self):
m = self._machine(_ATM_PUB, id_="some-uuid-not-the-pubkey")
d_tag = _state_d_tag(_atm_hex_pubkey(m))
assert d_tag == f"bitspire-cassettes-state:{_ATM_PUB}"
assert "some-uuid" not in d_tag
def test_build_state_d_tags_for_machines(self):
atm2_pub = _pub_hex("00" * 31 + "03")
machines = [
self._machine(_ATM_PUB, id_="m1"),
self._machine(atm2_pub, id_="m2"),
]
tags = build_state_d_tags_for_machines(machines)
assert tags == [
f"bitspire-cassettes-state:{_ATM_PUB}",
f"bitspire-cassettes-state:{atm2_pub}",
]