From d52a3bfafeb5624a0bd35153ad202ae9a717ab88 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 22 Jun 2026 16:45:29 +0200 Subject: [PATCH] fix: guard every machine_npub deref against unpaired machines (None) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit machine_npub became nullable in #29/m011 (register-unpaired flow), but several consumers still assumed it's non-None and crashed `normalize_public_key(None)` with `AttributeError: 'NoneType' object has no attribute 'startswith'`. On the demo (which had an unpaired machine) this broke the platform-fee update (500) and spammed the cassette consumer with errors every 2s. The #29 create/pair paths were guarded; these were missed: - views_api `api_update_super_config`: the "republish fee to every active machine" loop → skip unpaired (they get their config at pairing). - cassette_transport `build_state_d_tags_for_machines`: skip unpaired (no state-beacon d-tag yet) — the cassette-consumer loop crash. - crud `get_machine_by_atm_pubkey_hex`: its `except (ValueError, AssertionError)` didn't catch the AttributeError; skip unpaired before normalize — the cassette event-handler crash. - bitspire `assert_nostr_attribution`: reject (SettlementAttributionError) an unpaired machine instead of crashing the payment listener. - views_api cassettes/publish endpoint: 400 (not paired) instead of crashing publish_to_atm. Verified on the dev stack: with an unpaired active machine present, the cassette consumer registers (skipping it) and runs clean — no AttributeError. --- bitspire.py | 8 ++++++++ cassette_transport.py | 6 ++++-- crud.py | 5 +++++ views_api.py | 14 ++++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/bitspire.py b/bitspire.py index 9e0c70c..1609052 100644 --- a/bitspire.py +++ b/bitspire.py @@ -126,6 +126,14 @@ def assert_nostr_attribution(machine: Machine, extra: dict) -> None: "missing nostr_sender_pubkey on Payment.extra — invoice was not " "issued through the nostr-transport path" ) + if not machine.machine_npub: + # Unpaired machine (machine_npub None — nullable since #29/m011). It has + # no identity to attribute a settlement to; reject cleanly rather than + # let normalize_public_key(None) raise an uncaught AttributeError. + raise SettlementAttributionError( + f"machine {machine.id} is unpaired (no machine_npub); " + "a settlement cannot be attributed to it" + ) from lnbits.utils.nostr import normalize_public_key try: diff --git a/cassette_transport.py b/cassette_transport.py index 9f60d9a..e6517a1 100644 --- a/cassette_transport.py +++ b/cassette_transport.py @@ -141,8 +141,10 @@ def _state_d_tag(atm_pubkey_hex: str) -> str: def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]: """Bootstrap-consumer subscription filter helper: returns the full - `#d=[...]` list for all known ATMs an operator subscribes to.""" - return [_state_d_tag(_atm_hex_pubkey(m)) for m in machines] + `#d=[...]` list for all known PAIRED ATMs an operator subscribes to. + Unpaired machines (machine_npub is None — nullable since #29/m011) have no + state-beacon d-tag yet, so skip them rather than crash `_atm_hex_pubkey`.""" + return [_state_d_tag(_atm_hex_pubkey(m)) for m in machines if m.machine_npub] # ============================================================================= diff --git a/crud.py b/crud.py index 6c4ef53..6eda100 100644 --- a/crud.py +++ b/crud.py @@ -180,6 +180,11 @@ async def get_machine_by_atm_pubkey_hex(atm_pubkey_hex: str) -> Machine | None: target = atm_pubkey_hex.lower() machines = await list_all_active_machines() for m in machines: + # Unpaired machines (machine_npub is None — nullable since #29/m011) + # have no identity to match and would raise AttributeError in + # normalize_public_key (not caught below); skip them. + if not m.machine_npub: + continue try: if normalize_public_key(m.machine_npub).lower() == target: return m diff --git a/views_api.py b/views_api.py index 1a5bc62..35d35b2 100644 --- a/views_api.py +++ b/views_api.py @@ -1081,6 +1081,12 @@ async def api_update_super_config( ) if super_fractions_changed: for machine in await list_all_active_machines(): + # Unpaired machines (machine_npub is None — nullable since #29/m011) + # have no Nostr identity to publish a fee-config beacon to. Skip + # them; they pick up the current fee config when they pair + # (api_pair_machine publishes on success). + if not machine.machine_npub: + continue await publish_fee_config(machine, config, machine.operator_user_id) return config @@ -1147,6 +1153,14 @@ async def api_publish_machine_cassettes( 500 — anything else from the publish path """ machine = await _machine_owned_by(machine_id, user.id) + if not machine.machine_npub: + # Unpaired machine (machine_npub None — nullable since #29/m011) has no + # ATM identity to publish a cassette config to. Fail fast with a clean + # 400 instead of crashing publish_to_atm's normalize_public_key(None). + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "machine is not paired — pair it before publishing cassette config", + ) existing = await list_cassette_configs_for_machine(machine_id) existing_positions = {row.position for row in existing}