fix: guard every machine_npub deref against unpaired machines (None)
Some checks failed
ci.yml / fix: guard every machine_npub deref against unpaired machines (None) (pull_request) Failing after 0s

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.
This commit is contained in:
Padreug 2026-06-22 16:45:29 +02:00
commit d52a3bfafe
4 changed files with 31 additions and 2 deletions

View file

@ -126,6 +126,14 @@ def assert_nostr_attribution(machine: Machine, extra: dict) -> None:
"missing nostr_sender_pubkey on Payment.extra — invoice was not " "missing nostr_sender_pubkey on Payment.extra — invoice was not "
"issued through the nostr-transport path" "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 from lnbits.utils.nostr import normalize_public_key
try: try:

View file

@ -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]: def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]:
"""Bootstrap-consumer subscription filter helper: returns the full """Bootstrap-consumer subscription filter helper: returns the full
`#d=[...]` list for all known ATMs an operator subscribes to.""" `#d=[...]` list for all known PAIRED ATMs an operator subscribes to.
return [_state_d_tag(_atm_hex_pubkey(m)) for m in machines] 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]
# ============================================================================= # =============================================================================

View file

@ -180,6 +180,11 @@ async def get_machine_by_atm_pubkey_hex(atm_pubkey_hex: str) -> Machine | None:
target = atm_pubkey_hex.lower() target = atm_pubkey_hex.lower()
machines = await list_all_active_machines() machines = await list_all_active_machines()
for m in 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: try:
if normalize_public_key(m.machine_npub).lower() == target: if normalize_public_key(m.machine_npub).lower() == target:
return m return m

View file

@ -1081,6 +1081,12 @@ async def api_update_super_config(
) )
if super_fractions_changed: if super_fractions_changed:
for machine in await list_all_active_machines(): 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) await publish_fee_config(machine, config, machine.operator_user_id)
return config return config
@ -1147,6 +1153,14 @@ async def api_publish_machine_cassettes(
500 anything else from the publish path 500 anything else from the publish path
""" """
machine = await _machine_owned_by(machine_id, user.id) 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 = await list_cassette_configs_for_machine(machine_id)
existing_positions = {row.position for row in existing} existing_positions = {row.position for row in existing}