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
-
-
-
-
-
-
-
-
- Mints a dedicated signing key for
-
- inside the operator bunker and issues a one-shot seed URL. The
- spire's key never touches its disk; its cash-outs route to this
- machine's wallet. Re-pairing issues a fresh seed.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Paired. Scan this on the spire at first boot, or paste the seed URL
- into provision-atm. Shown once — copy it now.
-
-
-
-
-
-
-
-
-
- Copy seed URL
-
-
-
-
-
- Spire identity:
-
-
- Copy npub
-
-
-
-
-
-
-
-
-
-
@@ -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}