feat(v2): bootstrap consumer task — auto-populate cassette_configs (#29 v1)

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

@ -5,7 +5,7 @@ from lnbits.tasks import create_permanent_unique_task
from loguru import logger from loguru import logger
from .crud import db from .crud import db
from .tasks import wait_for_paid_invoices from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices
from .views import satmachineadmin_generic_router from .views import satmachineadmin_generic_router
from .views_api import satmachineadmin_api_router from .views_api import satmachineadmin_api_router
@ -42,6 +42,14 @@ def satmachineadmin_start():
"ext_satmachineadmin", wait_for_paid_invoices "ext_satmachineadmin", wait_for_paid_invoices
) )
scheduled_tasks.append(invoice_task) scheduled_tasks.append(invoice_task)
# Cassette bootstrap consumer (#29 v1) — subscribes to
# bitspire-cassettes-state events from each active ATM and upserts
# cassette_configs on receipt. Soft-fails if nostrclient isn't
# installed (logs + backs off, never crashes).
cassette_task = create_permanent_unique_task(
"ext_satmachineadmin_cassette_bootstrap", wait_for_cassette_state_events
)
scheduled_tasks.append(cassette_task)
__all__ = [ __all__ = [

39
crud.py
View file

@ -144,6 +144,45 @@ async def get_machines_for_operator(operator_user_id: str) -> List[Machine]:
) )
async def list_all_active_machines() -> List[Machine]:
"""Used by the cassette bootstrap consumer task to build a single
cross-operator subscription filter. Each event's pubkey routes to
the right operator via get_machine_by_atm_pubkey_hex + the machine's
operator_user_id.
"""
return await db.fetchall(
"""
SELECT * FROM satoshimachine.dca_machines
WHERE is_active = true
ORDER BY created_at DESC
""",
{},
Machine,
)
async def get_machine_by_atm_pubkey_hex(atm_pubkey_hex: str) -> Optional[Machine]:
"""Look up an active machine by its ATM pubkey, accepting hex or bech32
in machine_npub. Used by the cassette bootstrap consumer to route an
incoming state event to the right machine row (and therefore operator
privkey for decryption).
O(N) over active machines fine for small fleets. If fleet sizes
grow, normalise machine_npub-at-write to hex and add an index.
"""
from lnbits.utils.nostr import normalize_public_key
target = atm_pubkey_hex.lower()
machines = await list_all_active_machines()
for m in machines:
try:
if normalize_public_key(m.machine_npub).lower() == target:
return m
except (ValueError, AssertionError):
continue
return None
async def update_machine(machine_id: str, data: UpdateMachineData) -> Optional[Machine]: async def update_machine(machine_id: str, data: UpdateMachineData) -> Optional[Machine]:
update_data = {k: v for k, v in data.dict().items() if v is not None} update_data = {k: v for k, v in data.dict().items() if v is not None}
if not update_data: if not update_data:

224
tasks.py
View file

@ -25,6 +25,7 @@
# sat-amount invariants (range/sum). # sat-amount invariants (range/sum).
import asyncio import asyncio
from typing import Optional
from lnbits.core.models import Payment from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener from lnbits.tasks import register_invoice_listener
@ -237,3 +238,226 @@ async def _record_rejected(
f"(machine={machine.machine_npub[:12]}..., " f"(machine={machine.machine_npub[:12]}..., "
f"payment_hash={payment.payment_hash[:12]}...): {exc}" f"payment_hash={payment.payment_hash[:12]}...): {exc}"
) )
# =============================================================================
# Cassette bootstrap consumer (#29 v1)
# =============================================================================
# Subscribes to kind-30078 bitspire-cassettes-state:<atm_pubkey_hex> events
# published by each active machine's ATM on first boot (lamassu-next#56's
# bootstrap publish path). Decrypts the NIP-44 v2 content with the operator's
# privkey + ATM sender pubkey, validates as PublishCassettesPayload, and
# upserts cassette_configs via apply_bootstrap_state.
#
# v1 = one-shot per machine (ATM's meta.bootstrapPublishedAt makes the
# publish idempotent on ATM-side restart; satmachineadmin's apply_bootstrap_
# state dedups on state_event_id for relay re-delivery).
#
# v2 (separate issue) = continuous reverse-channel consumer with a
# last_state_created_at watermark for reconciliation UI.
#
# Implementation: polls nostrclient.router.NostrRouter.received_subscription_
# events keyed by our subscription_id. nostrclient's NostrRouter design is
# per-WebSocket-client; the singleton dict it drains into is the only
# server-side hook to consume events without standing up an in-process
# websocket. The relay manager is the same singleton publish_to_atm uses,
# so add_subscription registers a filter against the same relay pool.
CASSETTE_BOOTSTRAP_SUB_ID = "satmachineadmin-cassette-bootstrap"
_CASSETTE_POLL_INTERVAL_S = 2.0
_CASSETTE_BACKOFF_S = 30.0 # when nostrclient isn't installed yet
async def wait_for_cassette_state_events() -> None:
"""Long-running task: subscribe to bitspire-cassettes-state events from
every active machine's ATM and upsert cassette_configs on receipt.
Pattern mirrors wait_for_paid_invoices (try/except wraps each event,
never lets the loop die). Re-derives the subscription filter on each
tick from the current active-machines list newly-added machines
start receiving bootstrap events without an LNbits restart.
Soft-fail surfaces:
- nostrclient not installed log + sleep _CASSETTE_BACKOFF_S
between retries (operator may install it later)
- inbound event fails sig-verify / decrypt / parse log + skip
the event, continue the loop
- apply_bootstrap_state errors log + skip
"""
logger.info(
"satmachineadmin v2: cassette bootstrap consumer starting "
f"(sub_id={CASSETTE_BOOTSTRAP_SUB_ID})"
)
current_filter_key: Optional[str] = None
while True:
try:
current_filter_key = await _cassette_consumer_tick(current_filter_key)
await asyncio.sleep(_CASSETTE_POLL_INTERVAL_S)
except _NostrclientUnavailable:
logger.warning(
"satmachineadmin: nostrclient extension not installed; "
f"cassette bootstrap consumer sleeping {_CASSETTE_BACKOFF_S}s "
"before retry. Install + activate nostrclient on this "
"LNbits instance."
)
current_filter_key = None
await asyncio.sleep(_CASSETTE_BACKOFF_S)
except Exception as exc: # listener must never die
logger.error(
f"satmachineadmin: cassette consumer loop error (continuing): "
f"{exc}"
)
await asyncio.sleep(_CASSETTE_POLL_INTERVAL_S)
class _NostrclientUnavailable(Exception):
"""Internal sentinel — nostrclient extension import failed. Caller
sleeps a backoff then retries; the operator may install nostrclient
at any time."""
async def _cassette_consumer_tick(current_filter_key: Optional[str]) -> str:
"""Single iteration of the bootstrap-consumer loop. Returns the filter
key used this tick so the caller can detect filter-set changes.
Raises _NostrclientUnavailable if nostrclient can't be imported (the
outer loop backs off + retries).
"""
try:
from nostrclient.router import ( # type: ignore[import-not-found]
NostrRouter,
nostr_client,
)
except ImportError as exc:
raise _NostrclientUnavailable() from exc
from .cassette_transport import build_state_d_tags_for_machines
from .crud import (
apply_bootstrap_state,
get_machine_by_atm_pubkey_hex,
list_all_active_machines,
)
machines = await list_all_active_machines()
d_tags = build_state_d_tags_for_machines(machines)
filter_key = ",".join(sorted(d_tags))
if filter_key != current_filter_key:
if d_tags:
filters = [{"kinds": [30078], "#d": d_tags}]
nostr_client.relay_manager.add_subscription(
CASSETTE_BOOTSTRAP_SUB_ID, filters
)
logger.info(
"satmachineadmin: (re)registered cassette bootstrap "
f"subscription with {len(d_tags)} d-tag(s)"
)
else:
nostr_client.relay_manager.close_subscription(
CASSETTE_BOOTSTRAP_SUB_ID
)
logger.info(
"satmachineadmin: no active machines; closed cassette "
"bootstrap subscription"
)
inbound = NostrRouter.received_subscription_events.get(
CASSETTE_BOOTSTRAP_SUB_ID
)
if inbound:
while inbound:
event_message = inbound.pop(0)
try:
await _handle_cassette_state_event(
event_message, get_machine_by_atm_pubkey_hex,
apply_bootstrap_state,
)
except Exception as exc: # noqa: BLE001 — log + skip
logger.warning(
f"satmachineadmin: cassette state event handler "
f"failed (skipping): {exc}"
)
return filter_key
async def _handle_cassette_state_event(
event_message,
get_machine_by_atm_pubkey_hex,
apply_bootstrap_state,
) -> None:
"""Verify signature, route to the right operator's privkey, decrypt,
parse, upsert. Each step that fails is logged at WARNING (not ERROR)
so a noisy attacker can't fill the logs — this is data on a public
relay, garbage is expected."""
import json as _json
from datetime import datetime as _datetime
from datetime import timezone as _timezone
from lnbits.core.crud.users import get_account
from lnbits.utils.nostr import verify_event
from .cassette_transport import decrypt_and_parse_state_event
event_raw = event_message.event
if isinstance(event_raw, str):
event_obj = _json.loads(event_raw)
elif isinstance(event_raw, dict):
event_obj = event_raw
else:
logger.warning(
f"satmachineadmin: cassette event of unexpected type "
f"{type(event_raw).__name__}; skipping"
)
return
if not verify_event(event_obj):
logger.warning(
f"satmachineadmin: cassette state event sig verify failed "
f"(id={event_obj.get('id', '?')[:12]}...)"
)
return
sender_pubkey = event_obj.get("pubkey", "")
machine = await get_machine_by_atm_pubkey_hex(sender_pubkey)
if machine is None:
# Unknown sender — could be relay noise or an attacker. Don't
# treat as our problem.
logger.warning(
f"satmachineadmin: cassette state event from unknown ATM "
f"pubkey {sender_pubkey[:12]}... (not in dca_machines); "
"skipping"
)
return
account = await get_account(machine.operator_user_id)
if account is None or not account.prvkey:
logger.warning(
f"satmachineadmin: operator {machine.operator_user_id[:8]}... "
"has no privkey on file; can't decrypt cassette state event for "
f"machine {machine.id}. Onboard via Nostr-login."
)
return
payload = decrypt_and_parse_state_event(event_obj, account.prvkey)
event_id = event_obj.get("id", "")
created_at_unix = event_obj.get("created_at", 0)
event_created_at = _datetime.fromtimestamp(
int(created_at_unix), tz=_timezone.utc
)
applied = await apply_bootstrap_state(
machine.id, event_id, event_created_at, payload
)
if applied:
logger.info(
f"satmachineadmin: applied bootstrap state event {event_id[:12]}... "
f"to machine {machine.id} ({len(payload.denominations)} cassettes)"
)
else:
# Replay: event_id already on file. Normal on relay reconnect.
logger.debug(
f"satmachineadmin: cassette state event {event_id[:12]}... "
f"already applied to machine {machine.id} (replay no-op)"
)

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}",
]