diff --git a/__init__.py b/__init__.py
index 39de3d8..d40a411 100644
--- a/__init__.py
+++ b/__init__.py
@@ -4,6 +4,7 @@ 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
@@ -13,9 +14,7 @@ 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)
@@ -57,6 +56,12 @@ 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 9e0c70c..1609052 100644
--- a/bitspire.py
+++ b/bitspire.py
@@ -126,6 +126,14 @@ 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
new file mode 100644
index 0000000..c041f48
--- /dev/null
+++ b/cashin_transport.py
@@ -0,0 +1,135 @@
+"""
+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 9f60d9a..e6517a1 100644
--- a/cassette_transport.py
+++ b/cassette_transport.py
@@ -141,8 +141,10 @@ def _state_d_tag(atm_pubkey_hex: str) -> str:
def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]:
"""Bootstrap-consumer subscription filter helper: returns the full
- `#d=[...]` list for all known ATMs an operator subscribes to."""
- return [_state_d_tag(_atm_hex_pubkey(m)) for m in machines]
+ `#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]
# =============================================================================
diff --git a/crud.py b/crud.py
index 9646f8d..6eda100 100644
--- a/crud.py
+++ b/crud.py
@@ -180,6 +180,11 @@ 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
@@ -202,6 +207,55 @@ 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 b8e6ec0..6ede0eb 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 NOT NULL UNIQUE,
+ machine_npub TEXT UNIQUE,
wallet_id TEXT NOT NULL,
name TEXT,
location TEXT,
@@ -735,3 +735,128 @@ 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 c158fba..99ee552 100644
--- a/models.py
+++ b/models.py
@@ -28,7 +28,11 @@ class CreateMachineData(BaseModel):
not against any fee total. See aiolabs/satmachineadmin#37 / #38.
"""
- machine_npub: str
+ # 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
wallet_id: str
name: str | None = None
location: str | None = None
@@ -48,7 +52,7 @@ class CreateMachineData(BaseModel):
class Machine(BaseModel):
id: str
operator_user_id: str
- machine_npub: str
+ machine_npub: str | None # NULL until paired (or supplied on the dev self-key path)
wallet_id: str
name: str | None
location: str | None
@@ -56,6 +60,9 @@ 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
@@ -78,6 +85,29 @@ 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).
# =============================================================================
@@ -452,6 +482,10 @@ 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
@@ -459,6 +493,7 @@ 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",
@@ -471,6 +506,14 @@ 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
new file mode 100644
index 0000000..c2d923f
--- /dev/null
+++ b/pairing.py
@@ -0,0 +1,277 @@
+"""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 db8823b..63dc24b 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 f510e9c..053133d 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -103,7 +103,8 @@ window.app = Vue.createApp({
data: {
super_cash_in_fee_fraction: 0,
super_cash_out_fee_fraction: 0,
- super_fee_wallet_id: ''
+ super_fee_wallet_id: '',
+ max_cash_in_sats: null
}
},
@@ -191,6 +192,14 @@ window.app = Vue.createApp({
saving: false,
data: {}
},
+ pairDialog: {
+ show: false,
+ saving: false,
+ machine: null,
+ relays: '',
+ durationHours: null,
+ result: null
+ },
machineDetail: {
show: false,
loading: false,
@@ -568,13 +577,50 @@ 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 || ''
+ super_fee_wallet_id: this.superConfig?.super_fee_wallet_id || '',
+ max_cash_in_sats: this.superConfig?.max_cash_in_sats ?? null
}
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(
@@ -582,7 +628,8 @@ 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
+ super_fee_wallet_id: (d.super_fee_wallet_id || '').trim() || null,
+ max_cash_in_sats: cap
}
)
this.superConfig = data
@@ -691,13 +738,17 @@ window.app = Vue.createApp({
async submitAddMachine() {
const body = this._cleanMachineForm(this.addMachineDialog.data)
- if (!body.machine_npub || !body.wallet_id) {
+ if (!body.wallet_id) {
Quasar.Notify.create({
type: 'negative',
- message: 'machine_npub and wallet_id are required'
+ message: 'A wallet is 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)
@@ -705,7 +756,7 @@ window.app = Vue.createApp({
this.addMachineDialog.show = false
Quasar.Notify.create({
type: 'positive',
- message: `Machine ${data.name || data.machine_npub.slice(0, 12)} added`
+ message: `Machine ${data.name || (data.machine_npub || 'unpaired').slice(0, 12)} added`
})
} catch (e) {
this._notifyError(e, 'Failed to add machine')
@@ -733,6 +784,10 @@ 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(
@@ -764,7 +819,7 @@ window.app = Vue.createApp({
Quasar.Dialog.create({
title: 'Delete machine?',
message:
- `This removes ${machine.name || machine.machine_npub.slice(0, 12)}` +
+ `This removes ${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}` +
' from your fleet. Existing settlements and payment history are preserved' +
' — only the machine row itself is removed. Continue?',
html: true,
@@ -781,6 +836,93 @@ 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)
// -----------------------------------------------------------------
@@ -1420,7 +1562,7 @@ window.app = Vue.createApp({
// Helpers
// -----------------------------------------------------------------
shortNpub(npub) {
- if (!npub) return ''
+ if (!npub) return 'unpaired'
if (npub.length <= 16) return npub
return npub.slice(0, 8) + '…' + npub.slice(-6)
},
@@ -1506,7 +1648,7 @@ window.app = Vue.createApp({
_cleanMachineForm(d) {
return {
- machine_npub: (d.machine_npub || '').trim(),
+ machine_npub: (d.machine_npub || '').trim() || null,
wallet_id: d.wallet_id,
name: (d.name || '').trim() || null,
location: (d.location || '').trim() || null,
diff --git a/tasks.py b/tasks.py
index 4685c84..e3865d2 100644
--- a/tasks.py
+++ b/tasks.py
@@ -130,7 +130,11 @@ async def _handle_payment(payment: Payment) -> None:
data = parse_settlement(
machine=machine,
payment_hash=payment.payment_hash,
- wire_sats=payment.sat,
+ # `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),
extra=extra,
super_config=super_config,
)
@@ -205,7 +209,8 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -
data = CreateDcaSettlementData(
machine_id=machine.id,
payment_hash=payment.payment_hash,
- wire_sats=payment.sat,
+ # Magnitude, not the signed `payment.sat` (negative for outbound).
+ wire_sats=abs(payment.sat),
fiat_amount=0.0,
fiat_code=machine.fiat_code,
exchange_rate=0.0,
@@ -213,11 +218,11 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -
fee_sats=0,
platform_fee_sats=0,
operator_fee_sats=0,
- # 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",
+ # 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",
)
rejected = await create_settlement_idempotent(
data, initial_status="rejected", error_message=str(exc)
@@ -230,7 +235,10 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -
return
logger.error(
f"spirekeeper: rejected settlement {rejected.id} "
- f"(machine={machine.machine_npub[:12]}..., "
+ # 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"payment_hash={payment.payment_hash[:12]}...): {exc}"
)
diff --git a/templates/spirekeeper/index.html b/templates/spirekeeper/index.html
index b58714e..01bb973 100644
--- a/templates/spirekeeper/index.html
+++ b/templates/spirekeeper/index.html
@@ -131,6 +131,17 @@
+
+ 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
+
@@ -770,13 +792,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
+
+
+
+
+
+
+
+
+
+
@@ -1251,12 +1368,12 @@
typically a wallet you (the super) own.
@@ -1264,6 +1381,11 @@
label="Super fee destination wallet_id"
hint="LNbits wallet that collects the platform fee"
class="q-mb-md" dense outlined>
+
@@ -1510,12 +1632,12 @@
diff --git a/tests/test_pair_endpoint.py b/tests/test_pair_endpoint.py
new file mode 100644
index 0000000..0d50d95
--- /dev/null
+++ b/tests/test_pair_endpoint.py
@@ -0,0 +1,167 @@
+"""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
new file mode 100644
index 0000000..e3d2999
--- /dev/null
+++ b/tests/test_pairing.py
@@ -0,0 +1,332 @@
+"""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
new file mode 100644
index 0000000..78ae196
--- /dev/null
+++ b/tests/test_unpaired_machine_guards.py
@@ -0,0 +1,57 @@
+"""
+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 079794d..35d35b2 100644
--- a/views_api.py
+++ b/views_api.py
@@ -5,12 +5,18 @@
# 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
@@ -23,6 +29,13 @@ 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,
@@ -55,6 +68,8 @@ 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,
@@ -80,6 +95,7 @@ from .models import (
DcaPayment,
DcaSettlement,
Machine,
+ PairMachineData,
PartialDispenseData,
PublishCassettesPayload,
SetCommissionSplitsData,
@@ -232,7 +248,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[:12]}): "
+ f"on machine {m.id} ({m.name or (m.machine_npub or m.id)[:12]}): "
f"+ operator {op_in:.4f} = "
f"{total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
),
@@ -242,7 +258,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[:12]}): "
+ f"on machine {m.id} ({m.name or (m.machine_npub or m.id)[:12]}): "
f"+ operator {op_out:.4f} = "
f"{total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
),
@@ -259,7 +275,13 @@ async def api_create_machine(
data: CreateMachineData, user: User = Depends(check_user_exists)
) -> Machine:
await _assert_wallet_owned_by(data.wallet_id, user.id)
- await _assert_no_pubkey_collision(data.machine_npub)
+ # 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_machine_fee_cap_safe(
data.operator_cash_in_fee_fraction,
data.operator_cash_out_fee_fraction,
@@ -268,10 +290,98 @@ 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:
- await publish_fee_config(machine, super_config, user.id)
- return machine
+ 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)
@spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine])
@@ -971,6 +1081,12 @@ 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
@@ -1037,6 +1153,14 @@ 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}