Compare commits
29 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5ab02e4b6 | |||
| b55fc8bc1c | |||
| d0d20b0f94 | |||
| 8dad72a00d | |||
| d52a3bfafe | |||
| 622c1be5d3 | |||
| f67cb49bc3 | |||
| 9abf695fd5 | |||
| 607b71e796 | |||
| 56ac4a69e9 | |||
| 7b55dc152b | |||
| 490605333c | |||
| 73bd274979 | |||
| 47b7efc53c | |||
| b193f6262d | |||
| cc8d786331 | |||
| 554b2e2e17 | |||
| 76090ab5da | |||
| 22678dfb4f | |||
| a18f653ca7 | |||
| 846d5d9d0f | |||
| 4db5c3de4e | |||
| 32959d1533 | |||
| a5efdf22a1 | |||
| 9c5f07c72e | |||
| 761f078053 | |||
| a77f5bcb5c | |||
| bb473f5385 | |||
| c651d53419 |
16 changed files with 1641 additions and 40 deletions
11
__init__.py
11
__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__ = [
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
135
cashin_transport.py
Normal file
135
cashin_transport.py
Normal file
|
|
@ -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'")
|
||||
|
|
@ -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]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
54
crud.py
54
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",
|
||||
|
|
|
|||
127
migrations.py
127
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-<machine_id>`). 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"
|
||||
)
|
||||
|
|
|
|||
47
models.py
47
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.
|
||||
|
|
|
|||
277
pairing.py
Normal file
277
pairing.py
Normal file
|
|
@ -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:<base64url(json)> json = {
|
||||
"v": 1,
|
||||
"spire_npub": "npub1…", # the bunker-minted spire identity
|
||||
"spire_pubkey": "<64-hex>", # same key, hex (consumer convenience)
|
||||
"bunker_url": "bunker://<spire_pubkey>?relay=<bunker_relay>&secret=<sec>",
|
||||
"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 `<npub>#<secret>` 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
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 47 KiB |
|
|
@ -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 <b>${machine.name || machine.machine_npub.slice(0, 12)}</b>` +
|
||||
`This removes <b>${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}</b>` +
|
||||
' 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 <b>${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}</b>'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,
|
||||
|
|
|
|||
24
tasks.py
24
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}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,17 @@
|
|||
<div :style="{fontWeight: 500}" v-text="props.row.name || 'Unnamed'"></div>
|
||||
<div :style="{fontSize: '0.8em', opacity: 0.6}"
|
||||
v-text="props.row.location || 'No location set'"></div>
|
||||
<q-chip v-if="props.row.paired_at"
|
||||
dense size="sm" color="green-2" text-color="green-9"
|
||||
icon="link" :style="{marginTop: '2px'}">
|
||||
paired
|
||||
<q-tooltip>Bunker key minted; paired ${ new Date(props.row.paired_at).toLocaleString() }</q-tooltip>
|
||||
</q-chip>
|
||||
<q-chip v-else
|
||||
dense size="sm" color="grey-3" text-color="grey-8"
|
||||
icon="link_off" :style="{marginTop: '2px'}">
|
||||
not paired
|
||||
</q-chip>
|
||||
</q-td>
|
||||
<q-td key="machine_npub">
|
||||
<code :style="{fontSize: '0.85em'}"
|
||||
|
|
@ -156,6 +167,17 @@
|
|||
@click="openEditMachineDialog(props.row)">
|
||||
<q-tooltip>Edit</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat dense round size="sm" icon="qr_code_2"
|
||||
color="teal"
|
||||
@click="openPairDialog(props.row)">
|
||||
<q-tooltip>${ props.row.paired_at ? 'Re-pair (new seed URL)' : 'Pair (seed URL)' }</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn v-if="props.row.paired_at"
|
||||
flat dense round size="sm" icon="link_off"
|
||||
color="orange-8"
|
||||
@click="confirmRevokeMachine(props.row)">
|
||||
<q-tooltip>Revoke spire access</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat dense round size="sm" icon="delete"
|
||||
color="red-7"
|
||||
@click="confirmDeleteMachine(props.row)">
|
||||
|
|
@ -770,13 +792,13 @@
|
|||
|
||||
<q-input
|
||||
v-model="addMachineDialog.data.machine_npub"
|
||||
label="Machine npub (hex or bech32)"
|
||||
hint="64-char hex pubkey or npub1... bech32 string"
|
||||
label="Machine npub — DEVELOPMENT ONLY (blank = normal bunker pairing)"
|
||||
hint="⚠ Leave blank for normal operation: the bunker mints this machine's key when you pair it (no nsec ever lands on the machine). Only fill this to register a machine that holds its OWN signing key — development / self-signing. Hex or npub1…"
|
||||
color="orange"
|
||||
class="q-mb-md"
|
||||
dense outlined
|
||||
:rules="[
|
||||
v => !!v || 'Required',
|
||||
v => (v && v.length >= 32) || 'Looks too short'
|
||||
v => !v || v.length >= 32 || 'Looks too short — use a full hex/npub, or leave blank'
|
||||
]"></q-input>
|
||||
|
||||
<q-select
|
||||
|
|
@ -797,7 +819,7 @@
|
|||
|
||||
<q-input
|
||||
v-model.number="addMachineDialog.data.operator_cash_in_fee_fraction"
|
||||
label="Operator cash-in fee % (decimal, 0..0.15)"
|
||||
label="Operator cash-in fee (decimal fraction, 0-0.15)"
|
||||
hint="Your per-machine cut on cash-in. Sits on top of the platform fee; cap is 15% total per direction."
|
||||
type="number" step="0.0001" min="0" max="0.15"
|
||||
class="q-mb-md"
|
||||
|
|
@ -805,7 +827,7 @@
|
|||
|
||||
<q-input
|
||||
v-model.number="addMachineDialog.data.operator_cash_out_fee_fraction"
|
||||
label="Operator cash-out fee % (decimal, 0..0.15)"
|
||||
label="Operator cash-out fee (decimal fraction, 0-0.15)"
|
||||
hint="Your per-machine cut on cash-out. Sits on top of the platform fee; cap is 15% total per direction."
|
||||
type="number" step="0.0001" min="0" max="0.15"
|
||||
class="q-mb-md"
|
||||
|
|
@ -821,6 +843,101 @@
|
|||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- =============================================================== -->
|
||||
<!-- PAIR SPIRE DIALOG — mint bunker key + one-shot seed URL (S0/#9) -->
|
||||
<!-- =============================================================== -->
|
||||
<q-dialog v-model="pairDialog.show" persistent>
|
||||
<q-card :style="{minWidth: '480px', maxWidth: '95vw'}">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6">Pair spire</div>
|
||||
<q-space></q-space>
|
||||
<q-btn icon="close" flat round dense v-close-popup></q-btn>
|
||||
</q-card-section>
|
||||
|
||||
<!-- Step 1 — configure + generate -->
|
||||
<template v-if="!pairDialog.result">
|
||||
<q-card-section>
|
||||
<p class="text-caption q-mb-md" :style="{opacity: 0.7}">
|
||||
Mints a dedicated signing key for
|
||||
<b v-text="(pairDialog.machine && pairDialog.machine.name) || 'this spire'"></b>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<q-input
|
||||
v-model="pairDialog.relays"
|
||||
label="Relay(s) for the spire's events"
|
||||
hint="One per line. The same relay the spire publishes to (its VITE_RELAY_URL), e.g. wss://your-host/nostrrelay/<id>"
|
||||
type="textarea" autogrow
|
||||
class="q-mb-md"
|
||||
dense outlined></q-input>
|
||||
|
||||
<q-input
|
||||
v-model.number="pairDialog.durationHours"
|
||||
label="Token lifetime in hours (optional)"
|
||||
hint="Blank = non-expiring. Set e.g. 720 (30 days) to force periodic re-pairing."
|
||||
type="number" min="1"
|
||||
class="q-mb-md"
|
||||
dense outlined></q-input>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right" class="text-primary">
|
||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
||||
<q-btn
|
||||
color="primary" label="Generate seed URL" icon="vpn_key"
|
||||
:loading="pairDialog.saving"
|
||||
@click="submitPair"></q-btn>
|
||||
</q-card-actions>
|
||||
</template>
|
||||
|
||||
<!-- Step 2 — show the seed URL -->
|
||||
<template v-else>
|
||||
<q-card-section>
|
||||
<q-banner dense rounded class="bg-green-1 text-grey-9 q-mb-md">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="check_circle" color="green"></q-icon>
|
||||
</template>
|
||||
Paired. Scan this on the spire at first boot, or paste the seed URL
|
||||
into <code>provision-atm</code>. Shown once — copy it now.
|
||||
</q-banner>
|
||||
|
||||
<div class="row justify-center q-mb-md">
|
||||
<lnbits-qrcode
|
||||
:value="pairDialog.result.seed_url"
|
||||
:options="{width: 280}"></lnbits-qrcode>
|
||||
</div>
|
||||
|
||||
<q-input
|
||||
v-model="pairDialog.result.seed_url"
|
||||
label="Seed URL"
|
||||
type="textarea" autogrow readonly
|
||||
class="q-mb-sm"
|
||||
dense outlined>
|
||||
<template v-slot:append>
|
||||
<q-btn flat dense round icon="content_copy"
|
||||
@click="copy(pairDialog.result.seed_url)">
|
||||
<q-tooltip>Copy seed URL</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<div class="text-caption" :style="{opacity: 0.7}">
|
||||
Spire identity:
|
||||
<code :style="{fontSize: '0.85em'}"
|
||||
v-text="shortNpub(pairDialog.result.spire_npub)"></code>
|
||||
<q-btn flat dense round size="xs" icon="content_copy"
|
||||
@click="copy(pairDialog.result.spire_npub)">
|
||||
<q-tooltip>Copy npub</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right" class="text-primary">
|
||||
<q-btn flat label="Done" color="primary" v-close-popup></q-btn>
|
||||
</q-card-actions>
|
||||
</template>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- =============================================================== -->
|
||||
<!-- MACHINE DETAIL DIALOG — settlements list + per-row actions -->
|
||||
<!-- =============================================================== -->
|
||||
|
|
@ -1251,12 +1368,12 @@
|
|||
typically a wallet you (the super) own.
|
||||
</p>
|
||||
<q-input v-model.number="superFeeDialog.data.super_cash_in_fee_fraction"
|
||||
label="Cash-in fee % (decimal, 0..0.15)"
|
||||
label="Cash-in fee (decimal fraction, 0-0.15)"
|
||||
hint="0.03 = 3% of principal on cash-in transactions"
|
||||
type="number" step="0.0001" min="0" max="0.15"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
<q-input v-model.number="superFeeDialog.data.super_cash_out_fee_fraction"
|
||||
label="Cash-out fee % (decimal, 0..0.15)"
|
||||
label="Cash-out fee (decimal fraction, 0-0.15)"
|
||||
hint="0.03 = 3% of principal on cash-out transactions"
|
||||
type="number" step="0.0001" min="0" max="0.15"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
|
|
@ -1264,6 +1381,11 @@
|
|||
label="Super fee destination wallet_id"
|
||||
hint="LNbits wallet that collects the platform fee"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
<q-input v-model.number="superFeeDialog.data.max_cash_in_sats"
|
||||
label="Max cash-in per transaction (sats — blank = no cap)"
|
||||
hint="Server-side ceiling on a single cash-in's principal. The ATM attests the amount; this bounds a compromised/buggy machine to one capped tx."
|
||||
type="number" step="1" min="0"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
||||
|
|
@ -1510,12 +1632,12 @@
|
|||
<q-input v-model="editMachineDialog.data.fiat_code"
|
||||
label="Fiat code" class="q-mb-md" dense outlined></q-input>
|
||||
<q-input v-model.number="editMachineDialog.data.operator_cash_in_fee_fraction"
|
||||
label="Operator cash-in fee % (decimal, 0..0.15)"
|
||||
label="Operator cash-in fee (decimal fraction, 0-0.15)"
|
||||
hint="Sits on top of the platform cash-in fee. Cap 15% total per direction."
|
||||
type="number" step="0.0001" min="0" max="0.15"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
<q-input v-model.number="editMachineDialog.data.operator_cash_out_fee_fraction"
|
||||
label="Operator cash-out fee % (decimal, 0..0.15)"
|
||||
label="Operator cash-out fee (decimal fraction, 0-0.15)"
|
||||
hint="Sits on top of the platform cash-out fee. Cap 15% total per direction."
|
||||
type="number" step="0.0001" min="0" max="0.15"
|
||||
class="q-mb-md" dense outlined></q-input>
|
||||
|
|
|
|||
167
tests/test_pair_endpoint.py
Normal file
167
tests/test_pair_endpoint.py
Normal file
|
|
@ -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
|
||||
332
tests/test_pairing.py
Normal file
332
tests/test_pairing.py
Normal file
|
|
@ -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
|
||||
57
tests/test_unpaired_machine_guards.py
Normal file
57
tests/test_unpaired_machine_guards.py
Normal file
|
|
@ -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)
|
||||
128
views_api.py
128
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,6 +275,12 @@ 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_machine_fee_cap_safe(
|
||||
data.operator_cash_in_fee_fraction,
|
||||
|
|
@ -268,12 +290,100 @@ 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)
|
||||
|
||||
|
||||
@spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine])
|
||||
async def api_list_machines(
|
||||
user: User = Depends(check_user_exists),
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue