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 .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_api import satmachineadmin_api_router
@ -42,6 +42,14 @@ def satmachineadmin_start():
"ext_satmachineadmin", wait_for_paid_invoices
)
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__ = [