Compare commits
No commits in common. "main" and "v0.1.1" have entirely different histories.
8 changed files with 25 additions and 124 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
5
crud.py
5
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
|
||||
|
|
|
|||
32
pairing.py
32
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
|
||||
|
|
|
|||
5
tasks.py
5
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}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
14
views_api.py
14
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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue