diff --git a/bitspire.py b/bitspire.py index 1609052..9e0c70c 100644 --- a/bitspire.py +++ b/bitspire.py @@ -126,14 +126,6 @@ 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 e6517a1..9f60d9a 100644 --- a/cassette_transport.py +++ b/cassette_transport.py @@ -141,10 +141,8 @@ 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 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] + `#d=[...]` list for all known ATMs an operator subscribes to.""" + return [_state_d_tag(_atm_hex_pubkey(m)) for m in machines] # ============================================================================= diff --git a/crud.py b/crud.py index 6eda100..6c4ef53 100644 --- a/crud.py +++ b/crud.py @@ -180,11 +180,6 @@ 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/pairing.py b/pairing.py index c2d923f..be7a6a4 100644 --- a/pairing.py +++ b/pairing.py @@ -179,20 +179,26 @@ async def pair_spire( connection lifecycle out of the orchestration so this is unit-testable with a fake client. - `relays` are the relays the spire uses for its *own* events - (kind-21000/30078) — typically the operator's public nostrrelay; supplied by - the API layer. `bunker_relay` (the relay baked into `bunker_url`, where the - spire reaches the bunker) defaults to `relays[0]`; `keystore_passphrase` - defaults to the lnbits bunker setting. Both injectable for tests. + `bunker_relay` / `keystore_passphrase` default to the lnbits bunker + settings; injectable for tests. `relays` are the relays the spire will + use for its *own* events (kind-21000/30078) — typically the operator's + nostrrelay; supplied by the API layer. Raises PairingError on any bunker failure; no state is persisted here (the API layer persists on success). """ + relay = ( + bunker_relay if bunker_relay is not None else settings.lnbits_nsec_bunker_url + ) passphrase = ( keystore_passphrase if keystore_passphrase is not None else settings.lnbits_nsec_bunker_keystore_passphrase ) + if not relay: + raise PairingError( + "LNBITS_NSEC_BUNKER_URL is not set — cannot build a spire bunker connection" + ) if not passphrase: raise PairingError( "LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — " @@ -200,14 +206,6 @@ async def pair_spire( ) if not relays: raise PairingError("at least one relay is required for the seed URL") - # The relay baked into `bunker_url` is where the *spire* (the remote ATM) - # reaches the bunker, so it must be a machine-reachable public URL — NOT - # `settings.lnbits_nsec_bunker_url`, which is how the co-located lnbits - # reaches the bunker (typically ws://127.0.0.1, unreachable from the ATM — - # the localhost-relay /pair gotcha bitspire flagged). Default to the spire's - # own event relay (the bunker lives on the same operator relay the spire - # publishes to); an explicit `bunker_relay` overrides for split-relay deploys. - relay = bunker_relay if bunker_relay else relays[0] key_name = spire_key_name(machine.id) client_name = f"spire-client-{machine.id}" @@ -252,7 +250,9 @@ async def pair_spire( ) -async def revoke_spire(machine: Machine, *, admin_client: NsecBunkerAdminClient) -> int: +async def revoke_spire( + machine: Machine, *, admin_client: NsecBunkerAdminClient +) -> int: """Revoke a spire's bunker access (the "Revoke spire access" UX, aiolabs/spirekeeper#9/#12). @@ -274,4 +274,6 @@ async def revoke_spire(machine: Machine, *, admin_client: NsecBunkerAdminClient) except NsecBunkerNotConfiguredError as exc: raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc except NsecBunkerError as exc: - raise PairingError(f"bunker admin RPC failed during revoke: {exc}") from exc + raise PairingError( + f"bunker admin RPC failed during revoke: {exc}" + ) from exc diff --git a/tasks.py b/tasks.py index e3865d2..eb874de 100644 --- a/tasks.py +++ b/tasks.py @@ -235,10 +235,7 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) - return logger.error( f"spirekeeper: rejected settlement {rejected.id} " - # An unpaired machine (machine_npub None) reaches here now that - # assert_nostr_attribution rejects it — fall back to the id so the - # log line doesn't crash on None[:12]. - f"(machine={(machine.machine_npub or machine.id)[:12]}..., " + f"(machine={machine.machine_npub[:12]}..., " f"payment_hash={payment.payment_hash[:12]}...): {exc}" ) diff --git a/tests/test_pairing.py b/tests/test_pairing.py index e3d2999..080f5f9 100644 --- a/tests/test_pairing.py +++ b/tests/test_pairing.py @@ -218,29 +218,17 @@ def test_malformed_token_raises(): _pair(bunker) -def test_bunker_relay_defaults_to_spire_event_relay(): - """No explicit bunker_relay -> the relay baked into bunker_url is the spire's - own public event relay (relays[0]), NOT lnbits's internal bunker URL. This - is the localhost-relay /pair gotcha: a UI-minted seed (the form has no - bunker_relay field) must embed a machine-reachable relay, not ws://127.0.0.1. - An empty bunker_relay falls back to the same default.""" - from urllib.parse import quote - - for empty in (None, ""): - result = asyncio.run( +def test_missing_relay_or_passphrase_raises(): + with pytest.raises(PairingError, match="LNBITS_NSEC_BUNKER_URL"): + asyncio.run( pair_spire( _machine(), relays=_RELAYS, - admin_client=FakeBunker(token_secret="s"), # pragma: allowlist secret - bunker_relay=empty, + admin_client=FakeBunker(), + bunker_relay="", keystore_passphrase=_PASSPHRASE, ) ) - assert f"relay={quote(_RELAYS[0], safe='')}" in result.bunker_url - assert "127.0.0.1" not in result.bunker_url - - -def test_missing_relay_or_passphrase_raises(): with pytest.raises(PairingError, match="PASSPHRASE"): asyncio.run( pair_spire( diff --git a/tests/test_unpaired_machine_guards.py b/tests/test_unpaired_machine_guards.py deleted file mode 100644 index 78ae196..0000000 --- a/tests/test_unpaired_machine_guards.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -Regression: `machine_npub` is nullable (#29/m011 register-unpaired flow), so -every consumer that derives a Nostr identity from it must handle `None` rather -than crash `normalize_public_key(None)` (AttributeError: 'NoneType' has no -'startswith') or `machine_npub[:12]` (TypeError). See PR #33 — an unpaired -machine on the demo broke the platform-fee update (500) and the cassette -consumer. - -These cover the pure-function guards; the DB-backed loops -(get_machine_by_atm_pubkey_hex, the super-config republish loop) are exercised -on the dev stack with an unpaired active machine. -""" - -from datetime import datetime, timezone - -import pytest - -from ..bitspire import SettlementAttributionError, assert_nostr_attribution -from ..cassette_transport import build_state_d_tags_for_machines -from ..models import Machine - -_PAIRED_HEX = "82341f882b6eabcbd6b1c2da5cd14df14b8e91dd0e6da41a72b78ad8f3a7d3b9" - - -def _machine(npub: str | None) -> Machine: - now = datetime.now(timezone.utc) - return Machine( - id="unpaired1", - operator_user_id="op1", - machine_npub=npub, - wallet_id="w1", - name="unpaired", - location=None, - fiat_code="EUR", - is_active=True, - created_at=now, - updated_at=now, - ) - - -def test_attribution_rejects_unpaired_machine_cleanly(): - """An unpaired machine must raise the domain SettlementAttributionError - (which the listener records as 'rejected'), not an uncaught AttributeError - from normalize_public_key(None).""" - with pytest.raises(SettlementAttributionError): - assert_nostr_attribution( - _machine(None), - {"source": "bitspire", "nostr_sender_pubkey": _PAIRED_HEX}, - ) - - -def test_cassette_d_tags_skip_unpaired_machine(): - """build_state_d_tags_for_machines must skip unpaired machines rather than - crash _atm_hex_pubkey on a None npub — the cassette-consumer loop crash.""" - tags = build_state_d_tags_for_machines([_machine(_PAIRED_HEX), _machine(None)]) - assert len(tags) == 1 # only the paired machine contributes a d-tag - assert all("None" not in t for t in tags) diff --git a/views_api.py b/views_api.py index 35d35b2..1a5bc62 100644 --- a/views_api.py +++ b/views_api.py @@ -1081,12 +1081,6 @@ 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 @@ -1153,14 +1147,6 @@ 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}