diff --git a/__init__.py b/__init__.py index d40a411..39de3d8 100644 --- a/__init__.py +++ b/__init__.py @@ -4,7 +4,6 @@ from fastapi import APIRouter from lnbits.tasks import create_permanent_unique_task from loguru import logger -from .cashin_transport import register_create_withdraw_rpc from .crud import db from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices @@ -14,7 +13,9 @@ from .views_api import spirekeeper_api_router logger.info("spirekeeper v2 loaded") -spirekeeper_ext: APIRouter = APIRouter(prefix="/spirekeeper", tags=["DCA Admin"]) +spirekeeper_ext: APIRouter = APIRouter( + prefix="/spirekeeper", tags=["DCA Admin"] +) spirekeeper_ext.include_router(spirekeeper_generic_router) spirekeeper_ext.include_router(spirekeeper_api_router) @@ -56,12 +57,6 @@ def spirekeeper_start(): # wallet, not an auto-created machine wallet. Soft-fails on lnbits # versions that don't yet expose `register_roster_resolver`. register_roster_with_lnbits() - # Secure cash-in (#31): register the `create_withdraw` nostr-transport RPC - # so the ATM requests a server-priced, server-stamped cash-in withdraw link - # over the bunker-signed transport — amount/fee/attribution derived - # server-side, never client-supplied. Soft-fails if `register_rpc` isn't - # exposed by this lnbits. - register_create_withdraw_rpc() __all__ = [ 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/cashin_transport.py b/cashin_transport.py deleted file mode 100644 index c041f48..0000000 --- a/cashin_transport.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -Secure cash-in: a `create_withdraw` nostr-transport RPC (aiolabs/spirekeeper#31). - -Mirrors withdraw's `lnurlw_create_link`, but cash-in-semantic. The ATM sends the -gross `principal_sats` (the hardware-attested fiat value) over the bunker-signed -kind-21000 transport; the operator side derives the fee, the NET withdraw -amount, and the attribution **server-side** — none of it client-supplied. The -customer claims the NET link; the payout carries the stamped `extra` -(aiolabs/withdraw#3) so `_handle_payment` records the `cash_in` settlement with -cryptographic attribution (the verified transport sender), exactly like the -cash-out path. - -Why this exists: `lnurlw_create_link` takes `min/max_withdrawable` and `extra` -straight from the client body, so an authenticated-but-malicious/buggy ATM could -set the gross amount (no fee), forge `nostr_sender_pubkey`, or request an -arbitrary amount. This RPC closes that by computing everything server-side. -""" - -from __future__ import annotations - -from loguru import logger - -from .crud import get_machine_by_atm_pubkey_hex, get_super_config - -_RPC_NAME = "create_withdraw" - - -async def handle_create_withdraw(auth, request) -> dict: - """nostr-transport RPC handler. `auth` is a WalletTypeInfo (the operator - wallet, roster-resolved from the verified sender); `request` is a - NostrRpcRequest with `body`, `sender_pubkey` (verified), and `event_id`.""" - # Import withdraw lazily so registration never hard-depends on the withdraw - # extension being importable at startup; a missing dep fails the request, - # not the daemon. - try: - from withdraw.crud import create_withdraw_link - from withdraw.helpers import create_lnurl_from_baseurl - from withdraw.models import CreateWithdrawData - except ImportError as exc: - raise ValueError( - "withdraw extension unavailable; cannot mint a cash-in link" - ) from exc - - body = request.body or {} - - principal_sats = body.get("principal_sats") - if not isinstance(principal_sats, int) or principal_sats <= 0: - raise ValueError("principal_sats must be a positive integer") - - # Attribution is the VERIFIED transport sender — never read it from the body. - sender = (request.sender_pubkey or "").lower() - if not sender: - raise ValueError("missing verified sender_pubkey") - - machine = await get_machine_by_atm_pubkey_hex(sender) - if machine is None: - raise ValueError("no active machine for this signer") - # Defence in depth: the roster already resolved `auth.wallet` from `sender`; - # confirm it's the machine's own wallet before minting a payout link on it. - if machine.wallet_id != auth.wallet.id: - raise ValueError("machine wallet does not match the authenticated wallet") - - super_config = await get_super_config() - if super_config is None: - raise ValueError("super_config not initialised") - - # Per-tx cap (server-side; the bunker ACL / usage caps cannot see sats). - cap = getattr(super_config, "max_cash_in_sats", None) - if cap is not None and principal_sats > cap: - raise ValueError( - f"principal_sats {principal_sats} exceeds max_cash_in_sats {cap}" - ) - - # Round each leg independently so the split matches parse_settlement exactly - # (platform_fee + operator_fee == fee_sats → fee_mismatch_sats = 0). - platform_fee = round( - principal_sats * float(super_config.super_cash_in_fee_fraction) - ) - operator_fee = round(principal_sats * float(machine.operator_cash_in_fee_fraction)) - fee_sats = platform_fee + operator_fee - net_sats = principal_sats - fee_sats - if net_sats <= 0: - raise ValueError("fee >= principal; nothing to withdraw") - - data = CreateWithdrawData( - title=body.get("title") or f"bitSpire Cash-In {machine.id}", - min_withdrawable=net_sats, - max_withdrawable=net_sats, - uses=1, - wait_time=int(body.get("wait_time") or 1), - is_unique=False, - extra={ - "source": "bitspire", - "type": "cash_in", - "principal_sats": principal_sats, - "fee_sats": fee_sats, - "nostr_sender_pubkey": sender, - "nostr_event_id": body.get("client_ref") or request.event_id, - }, - ) - link = await create_withdraw_link(data, auth.wallet.id) - lnurl = create_lnurl_from_baseurl(link) - logger.info( - f"spirekeeper: create_withdraw machine={machine.id} " - f"principal={principal_sats} fee={fee_sats} (super={platform_fee} " - f"op={operator_fee}) net={net_sats} link={link.id}" - ) - return { - "link_id": link.id, - # `Lnurl.__str__` is the raw URL — wallets need the bech32 LNURL1… - # (lud01). Mirror withdraw's `_populate_lnurl` field convention. - "lnurl": str(lnurl.bech32), - "lnurl_url": str(lnurl.url), - "net_sats": net_sats, - "principal_sats": principal_sats, - "fee_sats": fee_sats, - } - - -def register_create_withdraw_rpc() -> None: - """Register `create_withdraw` with the lnbits nostr transport. Soft-fails if - the transport doesn't expose `register_rpc` (older lnbits).""" - try: - from lnbits.core.services.nostr_transport.dispatcher import ( # type: ignore - AUTH_WALLET, - register_rpc, - ) - except ImportError: - logger.warning( - "spirekeeper: nostr-transport register_rpc unavailable; " - "'create_withdraw' not registered (secure cash-in over RPC disabled)" - ) - return - register_rpc(_RPC_NAME, handle_create_withdraw, AUTH_WALLET) - logger.info("spirekeeper: registered nostr-transport RPC 'create_withdraw'") 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..9646f8d 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 @@ -207,55 +202,6 @@ async def update_machine(machine_id: str, data: UpdateMachineData) -> Machine | return await get_machine(machine_id) -async def set_machine_pairing( - machine_id: str, - *, - machine_npub: str, - bunker_spire_key_name: str, - paired_at: datetime, -) -> Machine | None: - """Persist the result of a (re-)pair: the bunker-minted spire identity - becomes the machine's npub (so lnbits' path-B roster routes it), and we - record the bunker key name + pair time. Stored as lowercase hex — the - roster + collision guard normalise either form, hex is canonical.""" - await db.execute( - """ - UPDATE spirekeeper.dca_machines - SET machine_npub = :npub, - bunker_spire_key_name = :key_name, - paired_at = :paired_at, - updated_at = :updated_at - WHERE id = :id - """, - { - "npub": machine_npub.lower(), - "key_name": bunker_spire_key_name, - "paired_at": paired_at, - "updated_at": datetime.now(), - "id": machine_id, - }, - ) - return await get_machine(machine_id) - - -async def set_machine_unpaired(machine_id: str) -> Machine | None: - """Mark a machine unpaired after revoking its spire's bunker access - (POST /revoke). Clears `paired_at`; keeps `machine_npub` + - `bunker_spire_key_name` for audit / re-pair. The bunker-side - `KeyUser.revokedAt` (set by `revoke_spire`) is what actually stops the - spire signing — this just records the operator-visible state.""" - await db.execute( - """ - UPDATE spirekeeper.dca_machines - SET paired_at = NULL, - updated_at = :updated_at - WHERE id = :id - """, - {"updated_at": datetime.now(), "id": machine_id}, - ) - return await get_machine(machine_id) - - async def delete_machine(machine_id: str) -> None: await db.execute( "DELETE FROM spirekeeper.dca_machines WHERE id = :id", diff --git a/migrations.py b/migrations.py index 6ede0eb..b8e6ec0 100644 --- a/migrations.py +++ b/migrations.py @@ -82,7 +82,7 @@ async def m001_satmachine_v2_initial(db): CREATE TABLE IF NOT EXISTS spirekeeper.dca_machines ( id TEXT PRIMARY KEY, operator_user_id TEXT NOT NULL, - machine_npub TEXT UNIQUE, + machine_npub TEXT NOT NULL UNIQUE, wallet_id TEXT NOT NULL, name TEXT, location TEXT, @@ -735,128 +735,3 @@ async def m009_split_fee_fractions_by_direction(db): await db.execute( "ALTER TABLE spirekeeper.super_config DROP COLUMN super_fee_fraction" ) - - -async def m010_add_machine_bunker_pairing(db): - """Add NIP-46 bunker-pairing columns to dca_machines for seed-URL - pairing (S0 / aiolabs/spirekeeper#9; spire-side aiolabs/bitspire#52). - - Under the chosen model (A1, decided 2026-06-16), the spire's signing - key lives inside the operator's nsecbunkerd rather than on the spire's - disk. `pair_machine` mints a per-spire key in the bunker, issues a - scoped NIP-46 connect token, and hands the spire a one-shot seed URL - embedding a `bunker://` connection. The spire then self-signs all its - events (kind-21000 RPC, kind-30078 beacon/cassette-state) as its own - bunker-held key; lnbits' path-B roster routes that npub to the - operator's wallet. - - ("spire" = a bitSpire machine; the legacy Lamassu term was "ATM".) - - - bunker_spire_key_name — the spire's key name inside the bunker - (`spire-`). Used to re-issue a connect token on - re-pair and (once the admin client grows a revoke RPC) to revoke - spire access. - - paired_at — timestamp of the last successful pair. NULL = the - machine row exists but no bunker key has been minted yet. - - Both nullable: machines created before this migration, and registered - -but-never-paired machines, carry NULL until first pair. Idempotent - column-probe pattern (same shape as m009). - """ - additions = [ - ("dca_machines", "bunker_spire_key_name", "TEXT"), - ("dca_machines", "paired_at", "TIMESTAMP"), - ] - for table, col, coltype in additions: - try: - await db.fetchone(f"SELECT {col} FROM spirekeeper.{table} LIMIT 1") - continue - except Exception: - pass - await db.execute( - f"ALTER TABLE spirekeeper.{table} ADD COLUMN {col} {coltype}" - ) - - -async def m011_machine_npub_nullable(db): - """Make dca_machines.machine_npub nullable so an operator can register a - machine *unpaired* (no npub) and have its identity minted by the bunker - at pairing time (model A1, aiolabs/spirekeeper#9). The npub is only - supplied up front on the development self-key path (a machine that holds - its own signing key). UNIQUE stays — NULLs don't collide, so any number - of unpaired machines coexist. - - Pre-public-launch: no back-compat shim. Existing rows are preserved by - the rebuild; the column simply loses NOT NULL. - """ - if db.type != "SQLITE": - # Postgres / Cockroach can drop the constraint in place. - await db.execute( - "ALTER TABLE spirekeeper.dca_machines " - "ALTER COLUMN machine_npub DROP NOT NULL" - ) - return - - # SQLite can't drop NOT NULL in place — rebuild the table (same pattern - # as m008/m009), preserving every row + the indexes. - await db.execute( - f""" - CREATE TABLE spirekeeper.dca_machines_new ( - id TEXT PRIMARY KEY, - operator_user_id TEXT NOT NULL, - machine_npub TEXT UNIQUE, - wallet_id TEXT NOT NULL, - name TEXT, - location TEXT, - fiat_code TEXT NOT NULL DEFAULT 'GTQ', - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, - updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, - operator_cash_in_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000, - operator_cash_out_fee_fraction DECIMAL(10,4) NOT NULL DEFAULT 0.0000, - bunker_spire_key_name TEXT, - paired_at TIMESTAMP - ) - """ - ) - await db.execute( - """ - INSERT INTO spirekeeper.dca_machines_new - (id, operator_user_id, machine_npub, wallet_id, name, location, - fiat_code, is_active, created_at, updated_at, - operator_cash_in_fee_fraction, operator_cash_out_fee_fraction, - bunker_spire_key_name, paired_at) - SELECT id, operator_user_id, machine_npub, wallet_id, name, location, - fiat_code, is_active, created_at, updated_at, - operator_cash_in_fee_fraction, operator_cash_out_fee_fraction, - bunker_spire_key_name, paired_at - FROM spirekeeper.dca_machines - """ - ) - await db.execute("DROP TABLE spirekeeper.dca_machines") - await db.execute( - "ALTER TABLE spirekeeper.dca_machines_new RENAME TO dca_machines" - ) - await db.execute( - "CREATE INDEX IF NOT EXISTS dca_machines_operator_idx " - "ON dca_machines (operator_user_id)" - ) - await db.execute( - "CREATE UNIQUE INDEX IF NOT EXISTS dca_machines_wallet_id_uq " - "ON dca_machines (wallet_id)" - ) - - -async def m012_add_max_cash_in_sats(db): - """Server-side per-transaction cash-in ceiling (aiolabs/spirekeeper#31). - - The secure `create_withdraw` RPC derives fee/net/attribution server-side, - but `principal_sats` is necessarily ATM-attested (only the hardware knows - how much cash went in). The bunker ACL / usage caps gate call *rate*, not - *sats*, so a single in-rate call could request an arbitrarily large payout. - `max_cash_in_sats` bounds that: the handler rejects a cash-in whose - principal exceeds it. NULL = no cap. - """ - await db.execute( - "ALTER TABLE spirekeeper.super_config ADD COLUMN max_cash_in_sats INTEGER" - ) diff --git a/models.py b/models.py index 99ee552..c158fba 100644 --- a/models.py +++ b/models.py @@ -28,11 +28,7 @@ class CreateMachineData(BaseModel): not against any fee total. See aiolabs/satmachineadmin#37 / #38. """ - # Optional: blank = register the machine UNPAIRED — the bunker mints its - # identity at pairing (model A1, the normal path). Supplying an npub here - # is the development self-key path (a machine that holds its own signing - # key); see views_api.api_create_machine. - machine_npub: str | None = None + machine_npub: str wallet_id: str name: str | None = None location: str | None = None @@ -52,7 +48,7 @@ class CreateMachineData(BaseModel): class Machine(BaseModel): id: str operator_user_id: str - machine_npub: str | None # NULL until paired (or supplied on the dev self-key path) + machine_npub: str wallet_id: str name: str | None location: str | None @@ -60,9 +56,6 @@ class Machine(BaseModel): is_active: bool operator_cash_in_fee_fraction: float = 0.0 operator_cash_out_fee_fraction: float = 0.0 - # NIP-46 bunker pairing (S0 / #9). NULL until the spire is first paired. - bunker_spire_key_name: str | None = None - paired_at: datetime | None = None created_at: datetime updated_at: datetime @@ -85,29 +78,6 @@ class UpdateMachineData(BaseModel): return round(float(v), 4) -class PairMachineData(BaseModel): - """Body for POST /machines/{id}/pair (S0 / #9). `relays` are the relays - the spire will use for its own events (kind-21000/30078) — typically the - operator's nostrrelay. `bunker_relay` overrides the relay embedded in the - seed's `bunker://` URL (the relay the spire uses to *reach* the bunker); - when omitted it defaults to `settings.lnbits_nsec_bunker_url`. Set it when - the relay lnbits uses to reach the bunker differs from the one the spire - must reach — e.g. an internal docker hostname (`ws://lnbits:5001/…`) vs a - LAN/public URL (`ws://192.168.0.32:5001/…`), or any split-relay deploy. - `duration_hours` optionally time-bounds the spire's connect token - (None = non-expiring).""" - - relays: list[str] - bunker_relay: str | None = None - duration_hours: int | None = None - - @validator("duration_hours") - def _positive_duration(cls, v): - if v is not None and v <= 0: - raise ValueError("duration_hours must be positive when set") - return v - - # ============================================================================= # DCA Clients — LP registrations, scoped per (machine, user). # ============================================================================= @@ -482,10 +452,6 @@ class SuperConfig(BaseModel): super_cash_in_fee_fraction: float = 0.0 super_cash_out_fee_fraction: float = 0.0 super_fee_wallet_id: str | None - # Per-transaction cash-in ceiling in sats (#31). The bunker ACL gates call - # rate, not sats, so this bounds a single ATM-attested principal. NULL = no - # cap. - max_cash_in_sats: int | None = None updated_at: datetime @@ -493,7 +459,6 @@ class UpdateSuperConfigData(BaseModel): super_cash_in_fee_fraction: float | None = None super_cash_out_fee_fraction: float | None = None super_fee_wallet_id: str | None = None - max_cash_in_sats: int | None = None @validator( "super_cash_in_fee_fraction", @@ -506,14 +471,6 @@ class UpdateSuperConfigData(BaseModel): raise ValueError("super fee fraction must be between 0 and 1") return round(float(v), 4) - @validator("max_cash_in_sats") - def _cap_non_negative(cls, v): - if v is None: - return v - if v < 0: - raise ValueError("max_cash_in_sats must be >= 0") - return int(v) - # ============================================================================= # Operator UX action carriers — partial-tx and balance-settlement features. diff --git a/pairing.py b/pairing.py deleted file mode 100644 index c2d923f..0000000 --- a/pairing.py +++ /dev/null @@ -1,277 +0,0 @@ -"""Seed-URL pairing for bitSpire machines (S0 / aiolabs/spirekeeper#9), model A1. - -Mints a per-spire signing key *inside the operator's nsecbunkerd*, issues a -scoped NIP-46 connect token, and builds the one-shot **seed URL** the spire -redeems at first boot. The spire then self-signs all of its own events -(kind-21000 cash RPC, kind-30078 beacon + cassette-state, CLINK 21001-21003) -as that bunker-held key; lnbits' path-B roster (`nostr_transport/roster.py`) -maps the spire npub to the operator's wallet. No nsec ever lands on the -spire's disk. - -Division of labour (vs. lnbits' `RemoteBunkerSigner.provision`, which is the -reference for the admin chain): - - spirekeeper (here) spire, at first boot (bitspire#52) - ────────────────── ────────────────────────────────── - 1. create_new_key 5. NIP-46 connect — redeem the token with a - 2. ensure_policy freshly-generated *client* keypair; bunker - 3. create_new_token binds (client_pubkey → spire key). The - 4. get_key_tokens ─ seed ─► client_nsec stays on the spire; the - (package token in URL) signing key never leaves the bunker. - -We deliberately do NOT run the connect/eager-bind step here: the spire is the -NIP-46 client, so the binding must happen spire-side with the spire's own -client keypair. spirekeeper only mints + packages. - -Seed URL wire format (contract shared with bitspire#52): - - spire-seed:v1: json = { - "v": 1, - "spire_npub": "npub1…", # the bunker-minted spire identity - "spire_pubkey": "<64-hex>", # same key, hex (consumer convenience) - "bunker_url": "bunker://?relay=&secret=", - "relays": ["wss://…"], # relays for the spire's own events - } -""" - -from __future__ import annotations - -import base64 -import json -from urllib.parse import quote - -from lnbits.core.services.nsec_bunker import ( - NsecBunkerAdminClient, - NsecBunkerError, - NsecBunkerNotConfiguredError, - npub_to_hex, -) -from lnbits.core.signers.remote_bunker import ensure_policy -from lnbits.settings import settings -from pydantic import BaseModel - -from .models import Machine - -SEED_URL_SCHEME = "spire-seed:v1:" - -# Policy granted to every spire's connect token. Scoped to exactly what a -# bitSpire signs as itself: -# - 21000 nostr-transport cash RPC envelope to lnbits -# - 22242 NIP-42 relay AUTH — the spire authenticates to its relays -# (must be bunker-signed: AUTH proves control of spire_pubkey, -# which only the bunker holds; can't be done with client_nsec) -# - 21001-21003 CLINK Offer / Debit / Manage (dormant on dev; kept) -# - 30078 NIP-78 beacon + bitspire-cassettes-state hello-event -# Kind-scoped rules go in create_new_policy; kind-less methods (nip44, for -# encrypting cassette-state to the operator) are added via add_policy_rule -# because nsecbunkerd's create_new_policy chokes on null `kind` -# (rule.kind.toString()). Mirrors lnbits' DEFAULT_POLICY_* split. nip04 is -# deliberately absent — the v1/nip04 path is dead code (bitspire#52). -# -# Kind set confirmed against the spire's signing sites in bitspire#52 -# (2026-06-18): live = 21000 + 30078 + 22242; CLINK 21001-21003 dormant but -# kept; nip04 unused. Under-granting = silent bunker reject, so err toward -# inclusion (low blast radius — only widens what a spire signs as its OWN key). -SPIRE_POLICY_NAME = "spirekeeper-spire" -SPIRE_POLICY_RULES = [ - {"method": "sign_event", "kind": 21000}, - {"method": "sign_event", "kind": 22242}, # NIP-42 relay AUTH (bitspire#52) - {"method": "sign_event", "kind": 21001}, - {"method": "sign_event", "kind": 21002}, - {"method": "sign_event", "kind": 21003}, - {"method": "sign_event", "kind": 30078}, -] -SPIRE_POLICY_METHODS_NO_KIND = ["nip44_encrypt", "nip44_decrypt"] - - -class PairingError(Exception): - """Pairing could not be completed (bunker unreachable, misconfigured, - or returned an unusable response). The caller maps this to a 4xx/5xx; - no machine state is mutated on failure.""" - - -class PairResult(BaseModel): - """Output of a successful pair. The API layer persists - `bunker_spire_key_name` + `spire_npub` (→ machine_npub) + `paired_at`, - and returns `seed_url` to the operator (QR + copy).""" - - spire_npub: str - spire_pubkey_hex: str - bunker_key_name: str - bunker_url: str - seed_url: str - - -class RevokeResult(BaseModel): - """Output of revoke. `revoked_count` >= 1 = the spire's signing access - is cut (KeyUser.revokedAt set); 0 = nothing was bound (token minted but - the spire never connected).""" - - revoked_count: int - - -def spire_key_name(machine_id: str) -> str: - """The spire's key name in the bunker keystore. Stable across re-pairs - so re-issuing a token reuses the same underlying key (create_new_key - is replace-by-name on the bunker side).""" - return f"spire-{machine_id}" - - -def _recover_token(tokens: list[dict], client_name: str) -> str: - """Pull the freshly-issued `#` token out of the bunker's - `get_key_tokens` response. Match by client name when the bunker - serializes it; otherwise fall back to the most-recent entry (same - defensiveness as lnbits' provision()).""" - matching = [ - t - for t in tokens - if t.get("clientName") == client_name or t.get("client_name") == client_name - ] or tokens - if not matching: - raise PairingError("bunker returned no tokens after create_new_token") - token = matching[-1].get("token") - if not isinstance(token, str) or "#" not in token: - raise PairingError(f"bunker returned a malformed token: {token!r}") - return token - - -def build_seed_url( - *, spire_npub: str, spire_pubkey_hex: str, bunker_url: str, relays: list[str] -) -> str: - payload = { - "v": 1, - "spire_npub": spire_npub, - "spire_pubkey": spire_pubkey_hex, - "bunker_url": bunker_url, - "relays": relays, - } - blob = ( - base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()) - .decode() - .rstrip("=") - ) - return SEED_URL_SCHEME + blob - - -async def pair_spire( - machine: Machine, - *, - relays: list[str], - admin_client: NsecBunkerAdminClient, - bunker_relay: str | None = None, - keystore_passphrase: str | None = None, - duration_hours: int | None = None, -) -> PairResult: - """Mint a bunker-held key + scoped connect token for `machine` and - return the seed URL the spire redeems at first boot. - - `duration_hours` (optional, aiolabs/lnbits#54 item 2) stamps `expiresAt` - on the spire's connect token, bounding the established binding's lifetime. - Since aiolabs/nsecbunkerd#27 (deployed 2026-06-19) the sign-time ACL - evaluates token lifecycle on EVERY request (`checkIfPubkeyAllowed` step 4 - joins through a `liveWhere` filter; `applyToken` no longer photocopies - grants), so an expired token stops signing post-bind, not just at connect. - The spire must re-pair to keep signing once the token lapses. None = - non-expiring (the only invalidation path is then `revoke_spire`). - - `admin_client` must already be connected (the caller owns the - `async with NsecBunkerAdminClient.from_settings()` context) — keeps - 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. - - Raises PairingError on any bunker failure; no state is persisted here - (the API layer persists on success). - """ - passphrase = ( - keystore_passphrase - if keystore_passphrase is not None - else settings.lnbits_nsec_bunker_keystore_passphrase - ) - if not passphrase: - raise PairingError( - "LNBITS_NSEC_BUNKER_KEYSTORE_PASSPHRASE is not set — " - "cannot mint a spire key" - ) - 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}" - - try: - spire_npub = await admin_client.create_new_key(key_name, passphrase) - spire_pubkey_hex = npub_to_hex(spire_npub) - policy_id = await ensure_policy( - admin_client, - name=SPIRE_POLICY_NAME, - rules=SPIRE_POLICY_RULES, - methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND, - ) - await admin_client.create_new_token( - key_name, client_name, policy_id, duration_hours=duration_hours - ) - tokens = await admin_client.get_key_tokens(key_name) - 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 pairing: {exc}") from exc - - token = _recover_token(tokens, client_name) - _, _, secret = token.partition("#") - - bunker_url = ( - f"bunker://{spire_pubkey_hex}?relay={quote(relay, safe='')}" - f"&secret={quote(secret, safe='')}" - ) - seed_url = build_seed_url( - spire_npub=spire_npub, - spire_pubkey_hex=spire_pubkey_hex, - bunker_url=bunker_url, - relays=relays, - ) - return PairResult( - spire_npub=spire_npub, - spire_pubkey_hex=spire_pubkey_hex, - bunker_key_name=key_name, - bunker_url=bunker_url, - seed_url=seed_url, - ) - - -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). - - Calls `revoke_key_user` (sets `KeyUser.revokedAt`) — the subject-level - sticky ban that's checked at step 2 of `checkIfPubkeyAllowed`, beating - every grant. This cuts the WHOLE binding regardless of how many tokens - were issued to the spire, which is the right semantics for "revoke this - spire." (Since aiolabs/nsecbunkerd#27 token-revoke also works post-bind — - the sign-time ACL now evaluates `Token.revokedAt`/`expiresAt` live every - request, closing the #22 no-op — but per-token revoke only cuts one - token's grant, so `revoke_key_user` remains the correct full-deauth call.) - - Returns the number of KeyUsers revoked: >= 1 means the spire's signing - access is now cut; 0 means nothing was bound (token minted but the - spire never connected). Raises PairingError on any bunker failure. - """ - try: - return await admin_client.revoke_key_user(spire_key_name(machine.id)) - 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 diff --git a/static/image/aio.png b/static/image/aio.png index 63dc24b..db8823b 100644 Binary files a/static/image/aio.png and b/static/image/aio.png differ diff --git a/static/js/index.js b/static/js/index.js index 053133d..f510e9c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -103,8 +103,7 @@ window.app = Vue.createApp({ data: { super_cash_in_fee_fraction: 0, super_cash_out_fee_fraction: 0, - super_fee_wallet_id: '', - max_cash_in_sats: null + super_fee_wallet_id: '' } }, @@ -192,14 +191,6 @@ window.app = Vue.createApp({ saving: false, data: {} }, - pairDialog: { - show: false, - saving: false, - machine: null, - relays: '', - durationHours: null, - result: null - }, machineDetail: { show: false, loading: false, @@ -577,50 +568,13 @@ window.app = Vue.createApp({ this.superConfig?.super_cash_in_fee_fraction ?? 0, super_cash_out_fee_fraction: this.superConfig?.super_cash_out_fee_fraction ?? 0, - super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '', - max_cash_in_sats: this.superConfig?.max_cash_in_sats ?? null + super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '' } this.superFeeDialog.show = true }, - // Guard the decimal-vs-percent trap shared by the super + operator fee - // forms: fees are decimal fractions (3% = 0.03), capped at 0.15. A value - // > 0.15 almost always means a percent was typed (3 instead of 0.03). - // Returns false + shows a clear toast so the operator never sees a raw 400. - _assertFeesDecimal(...fracs) { - if (fracs.some((v) => !Number.isFinite(v) || v < 0 || v > 0.15)) { - Quasar.Notify.create({ - type: 'negative', - message: 'Enter each fee as a decimal fraction (e.g. 3% = 0.03)', - caption: - 'Range 0–0.15. A value above 0.15 usually means a percent was typed (3 instead of 0.03).' - }) - return false - } - return true - }, - async submitSuperFee() { const d = this.superFeeDialog.data - if (!this._assertFeesDecimal( - Number(d.super_cash_in_fee_fraction), - Number(d.super_cash_out_fee_fraction) - )) return - // Blank cap field -> null (the PUT skips null, so the existing value is - // preserved rather than cleared — set 0 to reject every cash-in). - const cap = - d.max_cash_in_sats === '' || - d.max_cash_in_sats === null || - d.max_cash_in_sats === undefined - ? null - : Number(d.max_cash_in_sats) - if (cap !== null && (!Number.isInteger(cap) || cap < 0)) { - Quasar.Notify.create({ - type: 'negative', - message: 'Max cash-in must be a non-negative whole number of sats' - }) - return - } this.superFeeDialog.saving = true try { const {data} = await LNbits.api.request( @@ -628,8 +582,7 @@ window.app = Vue.createApp({ { super_cash_in_fee_fraction: Number(d.super_cash_in_fee_fraction), super_cash_out_fee_fraction: Number(d.super_cash_out_fee_fraction), - super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null, - max_cash_in_sats: cap + super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null } ) this.superConfig = data @@ -738,17 +691,13 @@ window.app = Vue.createApp({ async submitAddMachine() { const body = this._cleanMachineForm(this.addMachineDialog.data) - if (!body.wallet_id) { + if (!body.machine_npub || !body.wallet_id) { Quasar.Notify.create({ type: 'negative', - message: 'A wallet is required' + message: 'machine_npub and wallet_id are required' }) return } - if (!this._assertFeesDecimal( - Number(body.operator_cash_in_fee_fraction) || 0, - Number(body.operator_cash_out_fee_fraction) || 0 - )) return this.addMachineDialog.saving = true try { const {data} = await LNbits.api.request('POST', MACHINES_PATH, null, body) @@ -756,7 +705,7 @@ window.app = Vue.createApp({ this.addMachineDialog.show = false Quasar.Notify.create({ type: 'positive', - message: `Machine ${data.name || (data.machine_npub || 'unpaired').slice(0, 12)} added` + message: `Machine ${data.name || data.machine_npub.slice(0, 12)} added` }) } catch (e) { this._notifyError(e, 'Failed to add machine') @@ -784,10 +733,6 @@ window.app = Vue.createApp({ async submitEditMachine() { const d = this.editMachineDialog.data - if (!this._assertFeesDecimal( - Number(d.operator_cash_in_fee_fraction) || 0, - Number(d.operator_cash_out_fee_fraction) || 0 - )) return this.editMachineDialog.saving = true try { const {data} = await LNbits.api.request( @@ -819,7 +764,7 @@ window.app = Vue.createApp({ Quasar.Dialog.create({ title: 'Delete machine?', message: - `This removes ${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}` + + `This removes ${machine.name || machine.machine_npub.slice(0, 12)}` + ' from your fleet. Existing settlements and payment history are preserved' + ' — only the machine row itself is removed. Continue?', html: true, @@ -836,93 +781,6 @@ window.app = Vue.createApp({ }) }, - // ----------------------------------------------------------------- - // Pair / revoke spire (S0 / #9, #12) - // ----------------------------------------------------------------- - openPairDialog(machine) { - this.pairDialog.machine = machine - this.pairDialog.relays = '' - this.pairDialog.durationHours = null - this.pairDialog.result = null - this.pairDialog.show = true - }, - - async submitPair() { - const relays = (this.pairDialog.relays || '') - .split(/[\s,]+/) - .map(s => s.trim()) - .filter(Boolean) - if (!relays.length) { - Quasar.Notify.create({ - type: 'negative', - message: 'At least one relay is required' - }) - return - } - const body = {relays} - if (this.pairDialog.durationHours) { - body.duration_hours = Number(this.pairDialog.durationHours) - } - this.pairDialog.saving = true - try { - const {data} = await LNbits.api.request( - 'POST', - `${MACHINES_PATH}/${this.pairDialog.machine.id}/pair`, - null, - body - ) - this.pairDialog.result = data - // The bunker-minted key becomes the machine identity; reflect it + - // the paired state in the row immediately. - const m = this.machines.find(x => x.id === this.pairDialog.machine.id) - if (m) { - m.machine_npub = data.spire_pubkey_hex - m.bunker_spire_key_name = data.bunker_key_name - m.paired_at = new Date().toISOString() - } - Quasar.Notify.create({ - type: 'positive', - message: 'Spire paired — hand the seed URL to the device' - }) - } catch (e) { - this._notifyError(e, 'Pairing failed') - } finally { - this.pairDialog.saving = false - } - }, - - confirmRevokeMachine(machine) { - Quasar.Dialog.create({ - title: 'Revoke spire access?', - message: - `This cuts ${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}'s` + - ' signing access at the bunker — the spire can no longer submit' + - ' cash-outs until you re-pair it. Continue?', - html: true, - cancel: true, - persistent: true - }).onOk(async () => { - try { - const {data} = await LNbits.api.request( - 'POST', - `${MACHINES_PATH}/${machine.id}/revoke`, - null - ) - const m = this.machines.find(x => x.id === machine.id) - if (m) m.paired_at = null - Quasar.Notify.create({ - type: data.revoked_count >= 1 ? 'positive' : 'warning', - message: - data.revoked_count >= 1 - ? 'Spire access revoked' - : 'Nothing was bound (the spire never connected)' - }) - } catch (e) { - this._notifyError(e, 'Revoke failed') - } - }) - }, - // ----------------------------------------------------------------- // Machine detail dialog (P9b) // ----------------------------------------------------------------- @@ -1562,7 +1420,7 @@ window.app = Vue.createApp({ // Helpers // ----------------------------------------------------------------- shortNpub(npub) { - if (!npub) return 'unpaired' + if (!npub) return '' if (npub.length <= 16) return npub return npub.slice(0, 8) + '…' + npub.slice(-6) }, @@ -1648,7 +1506,7 @@ window.app = Vue.createApp({ _cleanMachineForm(d) { return { - machine_npub: (d.machine_npub || '').trim() || null, + machine_npub: (d.machine_npub || '').trim(), wallet_id: d.wallet_id, name: (d.name || '').trim() || null, location: (d.location || '').trim() || null, diff --git a/tasks.py b/tasks.py index e3865d2..4685c84 100644 --- a/tasks.py +++ b/tasks.py @@ -130,11 +130,7 @@ async def _handle_payment(payment: Payment) -> None: data = parse_settlement( machine=machine, payment_hash=payment.payment_hash, - # `payment.sat` is signed by protocol direction (negative for an - # outbound cash-in payout, positive for an inbound cash-out - # receipt). The settlement's `wire_sats` is a magnitude — direction - # is carried separately by `tx_type` — so pass the absolute value. - wire_sats=abs(payment.sat), + wire_sats=payment.sat, extra=extra, super_config=super_config, ) @@ -209,8 +205,7 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) - data = CreateDcaSettlementData( machine_id=machine.id, payment_hash=payment.payment_hash, - # Magnitude, not the signed `payment.sat` (negative for outbound). - wire_sats=abs(payment.sat), + wire_sats=payment.sat, fiat_amount=0.0, fiat_code=machine.fiat_code, exchange_rate=0.0, @@ -218,11 +213,11 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) - fee_sats=0, platform_fee_sats=0, operator_fee_sats=0, - # The parsed tx_type is unavailable on the rejection path, but the - # authenticated protocol direction is: an outbound payment is a - # cash-in, an inbound one a cash-out. Use that so a rejected row shows - # the right direction instead of always reading "cash-out". - tx_type="cash_in" if not payment.is_in else "cash_out", + # tx_type is unknown for rejection paths; default to cash_out + # (the only direction currently wired). When S8 lands the + # listener will branch on tx_type from extra, and this default + # gets revisited. + tx_type="cash_out", ) rejected = await create_settlement_idempotent( data, initial_status="rejected", error_message=str(exc) @@ -235,10 +230,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/templates/spirekeeper/index.html b/templates/spirekeeper/index.html index 01bb973..b58714e 100644 --- a/templates/spirekeeper/index.html +++ b/templates/spirekeeper/index.html @@ -131,17 +131,6 @@
- - paired - Bunker key minted; paired ${ new Date(props.row.paired_at).toLocaleString() } - - - not paired - Edit - - ${ props.row.paired_at ? 'Re-pair (new seed URL)' : 'Pair (seed URL)' } - - - Revoke spire access - @@ -792,13 +770,13 @@ - - - - - - -
Pair spire
- - -
- - - - - - -
-
- @@ -1368,12 +1251,12 @@ typically a wallet you (the super) own.

@@ -1381,11 +1264,6 @@ label="Super fee destination wallet_id" hint="LNbits wallet that collects the platform fee" class="q-mb-md" dense outlined> - @@ -1632,12 +1510,12 @@ diff --git a/tests/test_pair_endpoint.py b/tests/test_pair_endpoint.py deleted file mode 100644 index 0d50d95..0000000 --- a/tests/test_pair_endpoint.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Wiring tests for POST /machines/{id}/pair (S0 / #9). - -The pairing *service* is covered in test_pairing.py with a fake bunker; -here we only exercise the endpoint glue — ownership, the empty-relays -guard, the post-mint collision guard, persistence of the bunker-minted -hex npub, and error mapping — by monkeypatching the module-level deps. -""" - -import asyncio -from datetime import datetime, timezone -from types import SimpleNamespace - -import pytest -from fastapi import HTTPException -from lnbits.utils.nostr import hex_to_npub - -from .. import views_api -from ..models import Machine, PairMachineData -from ..pairing import PairingError, PairResult - -_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc) -_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891" -_SPIRE_NPUB = hex_to_npub(_SPIRE_HEX) - - -def _machine(npub: str = "placeholder") -> Machine: - return Machine( - id="m1", - operator_user_id="op1", - machine_npub=npub, - wallet_id="w1", - name="sintra", - location=None, - fiat_code="EUR", - is_active=True, - created_at=_NOW, - updated_at=_NOW, - ) - - -class _FakeAdmin: - @classmethod - def from_settings(cls): - return cls() - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - -def _result() -> PairResult: - return PairResult( - spire_npub=_SPIRE_NPUB, - spire_pubkey_hex=_SPIRE_HEX, - bunker_key_name="spire-m1", - bunker_url="bunker://x?relay=r&secret=s", # pragma: allowlist secret - seed_url="spire-seed:v1:abc", - ) - - -def _wire(monkeypatch, *, pair="ok"): - state: dict = {"persisted": None, "collision": None} - - async def fake_owned(machine_id, user_id): - return _machine() - - async def fake_pair(machine, *, relays, admin_client, duration_hours=None): - if pair == "error": - raise PairingError("boom") - return _result() - - async def fake_collision(npub): - state["collision"] = npub - - async def fake_persist( - machine_id, *, machine_npub, bunker_spire_key_name, paired_at - ): - state["persisted"] = (machine_id, machine_npub, bunker_spire_key_name) - return _machine(npub=machine_npub) - - monkeypatch.setattr(views_api, "_machine_owned_by", fake_owned) - monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin) - monkeypatch.setattr(views_api, "pair_spire", fake_pair) - monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", fake_collision) - monkeypatch.setattr(views_api, "set_machine_pairing", fake_persist) - return state - - -def _call(relays): - user = SimpleNamespace(id="op1") - return asyncio.run( - views_api.api_pair_machine("m1", PairMachineData(relays=relays), user) - ) - - -def test_pair_persists_hex_npub_and_returns_seed(monkeypatch): - state = _wire(monkeypatch) - result = _call(["wss://r"]) - assert result.seed_url == "spire-seed:v1:abc" - # collision guard ran on the bunker-minted hex, and we persisted it as npub - assert state["collision"] == _SPIRE_HEX - assert state["persisted"] == ("m1", _SPIRE_HEX, "spire-m1") - - -def test_pair_empty_relays_rejected(monkeypatch): - _wire(monkeypatch) - with pytest.raises(HTTPException) as ei: - _call([]) - assert ei.value.status_code == 400 - - -def test_pair_failure_maps_to_bad_gateway(monkeypatch): - state = _wire(monkeypatch, pair="error") - with pytest.raises(HTTPException) as ei: - _call(["wss://r"]) - assert ei.value.status_code == 502 - # nothing persisted on failure - assert state["persisted"] is None - - -def _wire_revoke(monkeypatch, *, revoke="ok", count=2): - state = {"unpaired": None} - - async def fake_owned(machine_id, user_id): - return _machine() - - async def fake_revoke(machine, *, admin_client): - if revoke == "error": - raise PairingError("boom") - return count - - async def fake_unpaired(machine_id): - state["unpaired"] = machine_id - return _machine() - - monkeypatch.setattr(views_api, "_machine_owned_by", fake_owned) - monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin) - monkeypatch.setattr(views_api, "revoke_spire", fake_revoke) - monkeypatch.setattr(views_api, "set_machine_unpaired", fake_unpaired) - return state - - -def _call_revoke(): - user = SimpleNamespace(id="op1") - return asyncio.run(views_api.api_revoke_machine("m1", user)) - - -def test_revoke_cuts_access_and_marks_unpaired(monkeypatch): - state = _wire_revoke(monkeypatch, count=2) - result = _call_revoke() - assert result.revoked_count == 2 - assert state["unpaired"] == "m1" - - -def test_revoke_zero_when_nothing_bound(monkeypatch): - _wire_revoke(monkeypatch, count=0) - assert _call_revoke().revoked_count == 0 - - -def test_revoke_failure_maps_to_bad_gateway(monkeypatch): - state = _wire_revoke(monkeypatch, revoke="error") - with pytest.raises(HTTPException) as ei: - _call_revoke() - assert ei.value.status_code == 502 - assert state["unpaired"] is None # not persisted on failure diff --git a/tests/test_pairing.py b/tests/test_pairing.py deleted file mode 100644 index e3d2999..0000000 --- a/tests/test_pairing.py +++ /dev/null @@ -1,332 +0,0 @@ -"""Unit tests for the seed-URL pairing service (S0 / #9, model A1). - -The bunker admin client is faked — these exercise the orchestration -(create_new_key -> ensure-policy -> create_new_token -> get_key_tokens), -the policy reconciliation, and the seed-URL / bunker:// wire shape, with -no live nsecbunkerd. npub<->hex round-trips through lnbits' real helpers -so the parsing is exercised for real. - -Async is driven via asyncio.run (this venv has no pytest-asyncio), matching -the rest of the suite. -""" - -import asyncio -import base64 -import json -from datetime import datetime, timezone - -import pytest -from lnbits.core.services.nsec_bunker import NsecBunkerError -from lnbits.utils.nostr import hex_to_npub - -from ..models import Machine -from ..pairing import ( - SEED_URL_SCHEME, - SPIRE_POLICY_METHODS_NO_KIND, - SPIRE_POLICY_NAME, - SPIRE_POLICY_RULES, - PairingError, - build_seed_url, - pair_spire, - revoke_spire, - spire_key_name, -) - -_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc) -_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891" -_SPIRE_NPUB = hex_to_npub(_SPIRE_HEX) -_RELAYS = ["wss://lnbits.demo.aiolabs.dev/nostrrelay/demo"] -_BUNKER_RELAY = "wss://bunker.internal/relay" -_PASSPHRASE = "keystore-pass" # pragma: allowlist secret - - -@pytest.fixture(autouse=True) -def _clear_policy_cache(): - # lnbits' ensure_policy caches resolved policy ids on - # (admin_pubkey, name); clear between tests so each FakeBunker's - # canned policy state is honoured rather than a stale cached id. - from lnbits.core.signers import remote_bunker - - remote_bunker._POLICY_ID_CACHE.clear() - yield - - -def _machine(mid: str = "m1") -> Machine: - return Machine( - id=mid, - operator_user_id="op1", - machine_npub="placeholder", - wallet_id="w1", - name="sintra", - location=None, - fiat_code="EUR", - is_active=True, - created_at=_NOW, - updated_at=_NOW, - ) - - -class FakeBunker: - """Records calls; returns canned bunker responses.""" - - admin_pubkey = "fake-admin-pubkey" - - # pragma: allowlist secret - def __init__(self, *, policies=None, token_secret="s3cr3t", revoke_count=1): - self._policies = policies or [] - self._token_secret = token_secret - self._revoke_count = revoke_count - self.calls: list[tuple] = [] - self._next_policy_id = 7 - - async def create_new_key(self, name, passphrase): - self.calls.append(("create_new_key", name, passphrase)) - return _SPIRE_NPUB - - async def get_policies(self): - self.calls.append(("get_policies",)) - return list(self._policies) - - async def create_new_policy(self, name, rules): - self.calls.append(("create_new_policy", name, rules)) - pid = self._next_policy_id - self._policies.append({"id": pid, "name": name, "rules": list(rules)}) - return pid - - async def add_policy_rule(self, policy_id, rule): - self.calls.append(("add_policy_rule", policy_id, rule)) - - async def create_new_token( - self, key_name, client_name, policy_id, duration_hours=None - ): - self.calls.append( - ("create_new_token", key_name, client_name, policy_id, duration_hours) - ) - - async def revoke_key_user(self, key_name): - self.calls.append(("revoke_key_user", key_name)) - return self._revoke_count - - async def get_key_tokens(self, key_name): - self.calls.append(("get_key_tokens", key_name)) - return [ - { - "clientName": f"spire-client-{key_name.split('-', 1)[1]}", - "token": f"{_SPIRE_NPUB}#{self._token_secret}", - } - ] - - def named(self, name): - return [c for c in self.calls if c[0] == name] - - -def _pair(bunker, machine=None): - return asyncio.run( - pair_spire( - machine or _machine(), - relays=_RELAYS, - admin_client=bunker, - bunker_relay=_BUNKER_RELAY, - keystore_passphrase=_PASSPHRASE, - ) - ) - - -def test_pair_happy_path_mints_key_policy_token(): - bunker = FakeBunker(token_secret="abc123") # pragma: allowlist secret - result = _pair(bunker) - - assert ("create_new_key", "spire-m1", _PASSPHRASE) in bunker.calls - assert result.bunker_key_name == spire_key_name("m1") == "spire-m1" - - assert result.spire_npub == _SPIRE_NPUB - assert result.spire_pubkey_hex == _SPIRE_HEX - - created = bunker.named("create_new_policy") - assert created and created[0][1] == SPIRE_POLICY_NAME - token_call = bunker.named("create_new_token")[0] - assert token_call[1] == "spire-m1" # key_name - assert token_call[2] == "spire-client-m1" # client_name - assert token_call[3] == 7 # policy_id from the fake's create_new_policy - - -def test_bunker_url_carries_pubkey_relay_secret(): - result = _pair(FakeBunker(token_secret="topsecret")) # pragma: allowlist secret - assert result.bunker_url.startswith(f"bunker://{_SPIRE_HEX}?") - assert "relay=wss%3A%2F%2Fbunker.internal%2Frelay" in result.bunker_url - assert "secret=topsecret" in result.bunker_url - - -def test_seed_url_decodes_to_contract(): - result = _pair(FakeBunker(token_secret="zzz")) # pragma: allowlist secret - assert result.seed_url.startswith(SEED_URL_SCHEME) - blob = result.seed_url[len(SEED_URL_SCHEME) :] - payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4))) - assert payload == { - "v": 1, - "spire_npub": _SPIRE_NPUB, - "spire_pubkey": _SPIRE_HEX, - "bunker_url": result.bunker_url, - "relays": _RELAYS, - } - - -def test_fresh_policy_adds_kindless_nip44_rules(): - bunker = FakeBunker() # no existing policies - _pair(bunker) - added = [c[2]["method"] for c in bunker.named("add_policy_rule")] - # kind-scoped rules went in via create_new_policy; only the kind-less - # nip44 methods are reconciled in via add_policy_rule. - assert set(added) == set(SPIRE_POLICY_METHODS_NO_KIND) - - -def test_existing_policy_reused_not_recreated(): - existing = [ - { - "id": 42, - "name": SPIRE_POLICY_NAME, - "rules": [dict(r) for r in SPIRE_POLICY_RULES], - } - ] - bunker = FakeBunker(policies=existing) - _pair(bunker) - assert not bunker.named("create_new_policy") # reused, not recreated - assert bunker.named("create_new_token")[0][3] == 42 # used existing id - added = [c[2]["method"] for c in bunker.named("add_policy_rule")] - assert set(added) == set(SPIRE_POLICY_METHODS_NO_KIND) - - -def test_fully_provisioned_policy_adds_nothing(): - rules = [dict(r) for r in SPIRE_POLICY_RULES] + [ - {"method": m, "kind": None} for m in SPIRE_POLICY_METHODS_NO_KIND - ] - bunker = FakeBunker(policies=[{"id": 9, "name": SPIRE_POLICY_NAME, "rules": rules}]) - _pair(bunker) - assert not bunker.named("add_policy_rule") - assert not bunker.named("create_new_policy") - - -def test_malformed_token_raises(): - bunker = FakeBunker() - - async def _bad_tokens(key_name): - _ = key_name - return [{"token": "no-hash-here"}] - - bunker.get_key_tokens = _bad_tokens - with pytest.raises(PairingError, match="malformed token"): - _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( - pair_spire( - _machine(), - relays=_RELAYS, - admin_client=FakeBunker(token_secret="s"), # pragma: allowlist secret - bunker_relay=empty, - 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( - _machine(), - relays=_RELAYS, - admin_client=FakeBunker(), - bunker_relay=_BUNKER_RELAY, - keystore_passphrase="", - ) - ) - with pytest.raises(PairingError, match="relay is required"): - asyncio.run( - pair_spire( - _machine(), - relays=[], - admin_client=FakeBunker(), - bunker_relay=_BUNKER_RELAY, - keystore_passphrase=_PASSPHRASE, - ) - ) - - -def test_build_seed_url_roundtrip(): - url = build_seed_url( - spire_npub=_SPIRE_NPUB, - spire_pubkey_hex=_SPIRE_HEX, - bunker_url="bunker://x?relay=r&secret=s", - relays=_RELAYS, - ) - blob = url[len(SEED_URL_SCHEME) :] - payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4))) - assert payload["spire_pubkey"] == _SPIRE_HEX - assert payload["relays"] == _RELAYS - - -def test_pair_threads_duration_hours(): - bunker = FakeBunker() - asyncio.run( - pair_spire( - _machine(), - relays=_RELAYS, - admin_client=bunker, - bunker_relay=_BUNKER_RELAY, - keystore_passphrase=_PASSPHRASE, - duration_hours=720, - ) - ) - # create_new_token tuple is (name, key, client, policy_id, duration_hours) - assert bunker.named("create_new_token")[0][4] == 720 - - -def test_pair_default_duration_is_none(): - bunker = FakeBunker() - _pair(bunker) # no duration_hours - assert bunker.named("create_new_token")[0][4] is None - - -def test_revoke_spire_calls_revoke_key_user(): - # revoke goes through revoke_key_user (KeyUser.revokedAt) — the subject- - # level ban that cuts the whole binding, not just one token's grant. - # (Token-revoke also works post-bind since nsecbunkerd#27, but only - # severs a single token; revoke_key_user is the full-deauth call.) - bunker = FakeBunker(revoke_count=2) - count = asyncio.run(revoke_spire(_machine(), admin_client=bunker)) - assert count == 2 - assert bunker.named("revoke_key_user") == [("revoke_key_user", "spire-m1")] - assert not bunker.named("revoke_token") # never token-revoke - - -def test_revoke_spire_maps_bunker_error(): - bunker = FakeBunker() - - async def _boom(key_name): - raise NsecBunkerError("nope") - - bunker.revoke_key_user = _boom - with pytest.raises(PairingError, match="revoke"): - asyncio.run(revoke_spire(_machine(), admin_client=bunker)) - - -def test_policy_authorizes_required_signing_kinds(): - # Kinds the spire signs as its OWN identity, confirmed against the - # consumer signing sites in bitspire#52 (2026-06-18). A missing kind is a - # silent bunker reject. 22242 = NIP-42 relay AUTH (must be bunker-signed — - # it proves control of spire_pubkey). nip04 stays out (v1 path is dead). - kinds = {r["kind"] for r in SPIRE_POLICY_RULES if r["method"] == "sign_event"} - assert {21000, 30078, 22242} <= kinds - assert "nip04_encrypt" not in SPIRE_POLICY_METHODS_NO_KIND - assert "nip04_decrypt" not in SPIRE_POLICY_METHODS_NO_KIND 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..079794d 100644 --- a/views_api.py +++ b/views_api.py @@ -5,18 +5,12 @@ # LNbits instance can never see each other's machines, settlements, or # clients. The super-only platform-fee write endpoint lands in P2. -from datetime import datetime, timezone from http import HTTPStatus from fastapi import APIRouter, Depends, HTTPException from lnbits.core.crud import get_wallet from lnbits.core.crud.users import get_account_by_pubkey from lnbits.core.models import User -from lnbits.core.services.nsec_bunker import ( - NsecBunkerAdminClient, - NsecBunkerError, - NsecBunkerNotConfiguredError, -) from lnbits.decorators import check_super_user, check_user_exists from lnbits.utils.nostr import normalize_public_key @@ -29,13 +23,6 @@ from .cassette_transport import ( publish_to_atm, ) from .fee_transport import publish_fee_config -from .pairing import ( - PairResult, - PairingError, - RevokeResult, - pair_spire, - revoke_spire, -) from .crud import ( append_settlement_note, count_completed_legs_for_settlement, @@ -68,8 +55,6 @@ from .crud import ( lp_is_onboarded, replace_commission_splits, reset_settlement_for_retry, - set_machine_pairing, - set_machine_unpaired, update_cassette_config, update_dca_client, update_deposit, @@ -95,7 +80,6 @@ from .models import ( DcaPayment, DcaSettlement, Machine, - PairMachineData, PartialDispenseData, PublishCassettesPayload, SetCommissionSplitsData, @@ -248,7 +232,7 @@ async def _assert_super_config_cap_safe( HTTPStatus.BAD_REQUEST, ( f"super cash-in fee {effective_in:.4f} would exceed cap " - f"on machine {m.id} ({m.name or (m.machine_npub or m.id)[:12]}): " + f"on machine {m.id} ({m.name or m.machine_npub[:12]}): " f"+ operator {op_in:.4f} = " f"{total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" ), @@ -258,7 +242,7 @@ async def _assert_super_config_cap_safe( HTTPStatus.BAD_REQUEST, ( f"super cash-out fee {effective_out:.4f} would exceed cap " - f"on machine {m.id} ({m.name or (m.machine_npub or m.id)[:12]}): " + f"on machine {m.id} ({m.name or m.machine_npub[:12]}): " f"+ operator {op_out:.4f} = " f"{total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" ), @@ -275,13 +259,7 @@ async def api_create_machine( data: CreateMachineData, user: User = Depends(check_user_exists) ) -> Machine: await _assert_wallet_owned_by(data.wallet_id, user.id) - # machine_npub is optional: blank = register UNPAIRED — the bunker mints - # the identity at pairing (the normal path). An npub supplied up front is - # the development self-key path; only then do we collision-check + publish - # a fee config now (an unpaired machine has no target yet, so it gets its - # config at pairing instead — see api_pair_machine). - if data.machine_npub: - await _assert_no_pubkey_collision(data.machine_npub) + await _assert_no_pubkey_collision(data.machine_npub) await _assert_machine_fee_cap_safe( data.operator_cash_in_fee_fraction, data.operator_cash_out_fee_fraction, @@ -290,98 +268,10 @@ async def api_create_machine( # Layer 2 (#39): publish initial fee config to the ATM so it can # unblock past its `awaiting-fees` maintenance gate. Soft-fails on # transport errors — machine creation has already succeeded. - if machine.machine_npub: - super_config = await get_super_config() - if super_config is not None: - await publish_fee_config(machine, super_config, user.id) - return machine - - -@spirekeeper_api_router.post( - "/api/v1/dca/machines/{machine_id}/pair", response_model=PairResult -) -async def api_pair_machine( - machine_id: str, - data: PairMachineData, - user: User = Depends(check_user_exists), -) -> PairResult: - """Seed-URL pairing (S0 / #9, model A1). Mints a per-spire signing key - inside the operator's nsecbunkerd and returns the one-shot seed URL the - spire redeems at first boot. The bunker-minted key becomes the machine's - npub, so lnbits' path-B roster routes the spire's cash-out RPCs to this - operator's wallet — no nsec ever lands on the spire. - - Re-pair is supported (re-issues a token for the same spire key). - `duration_hours` (optional) time-bounds the token; revoke via the - sibling `POST .../revoke` endpoint.""" - machine = await _machine_owned_by(machine_id, user.id) - if not data.relays: - raise HTTPException(HTTPStatus.BAD_REQUEST, "at least one relay is required") - - try: - async with NsecBunkerAdminClient.from_settings() as client: - result = await pair_spire( - machine, - relays=data.relays, - admin_client=client, - bunker_relay=data.bunker_relay, - duration_hours=data.duration_hours, - ) - except NsecBunkerNotConfiguredError as exc: - raise HTTPException( - HTTPStatus.SERVICE_UNAVAILABLE, - f"nsecbunkerd is not configured on this LNbits instance: {exc}", - ) from exc - except (PairingError, NsecBunkerError) as exc: - raise HTTPException(HTTPStatus.BAD_GATEWAY, f"pairing failed: {exc}") from exc - - # The bunker-minted identity becomes the machine npub — run the same - # collision guard as create before persisting (fresh keys ~never collide, - # but defence-in-depth keeps the no-collision invariant intact). - await _assert_no_pubkey_collision(result.spire_pubkey_hex) - await set_machine_pairing( - machine_id, - machine_npub=result.spire_pubkey_hex, - bunker_spire_key_name=result.bunker_key_name, - paired_at=datetime.now(timezone.utc), - ) - # Now that the machine has a bunker identity, publish its fee config so - # the spire can clear its `awaiting-fees` gate. For a machine created - # unpaired, this is the first time it has a target. Soft-fails (mirrors - # create); pairing has already succeeded. super_config = await get_super_config() if super_config is not None: - paired = await _machine_owned_by(machine_id, user.id) - await publish_fee_config(paired, super_config, user.id) - return result - - -@spirekeeper_api_router.post( - "/api/v1/dca/machines/{machine_id}/revoke", response_model=RevokeResult -) -async def api_revoke_machine( - machine_id: str, - user: User = Depends(check_user_exists), -) -> RevokeResult: - """Revoke a spire's bunker access — the "Revoke spire access" UX - (#9/#12). Cuts the spire's signing ability at the bunker - (`KeyUser.revokedAt` via `revoke_key_user`; token-revoke alone is a - no-op once the token is redeemed — see #22), then marks the machine - unpaired. `revoked_count` >= 1 = access cut; 0 = nothing was bound.""" - machine = await _machine_owned_by(machine_id, user.id) - try: - async with NsecBunkerAdminClient.from_settings() as client: - revoked_count = await revoke_spire(machine, admin_client=client) - except NsecBunkerNotConfiguredError as exc: - raise HTTPException( - HTTPStatus.SERVICE_UNAVAILABLE, - f"nsecbunkerd is not configured on this LNbits instance: {exc}", - ) from exc - except (PairingError, NsecBunkerError) as exc: - raise HTTPException(HTTPStatus.BAD_GATEWAY, f"revoke failed: {exc}") from exc - - await set_machine_unpaired(machine_id) - return RevokeResult(revoked_count=revoked_count) + await publish_fee_config(machine, super_config, user.id) + return machine @spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine]) @@ -1081,12 +971,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 +1037,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}