Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
16 changed files with 40 additions and 1641 deletions
11
__init__.py
11
__init__.py
|
|
@ -4,7 +4,6 @@ from fastapi import APIRouter
|
||||||
from lnbits.tasks import create_permanent_unique_task
|
from lnbits.tasks import create_permanent_unique_task
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .cashin_transport import register_create_withdraw_rpc
|
|
||||||
from .crud import db
|
from .crud import db
|
||||||
from .nostr_transport_roster import register_with_lnbits as register_roster_with_lnbits
|
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
|
from .tasks import wait_for_cassette_state_events, wait_for_paid_invoices
|
||||||
|
|
@ -14,7 +13,9 @@ from .views_api import spirekeeper_api_router
|
||||||
logger.info("spirekeeper v2 loaded")
|
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_generic_router)
|
||||||
spirekeeper_ext.include_router(spirekeeper_api_router)
|
spirekeeper_ext.include_router(spirekeeper_api_router)
|
||||||
|
|
||||||
|
|
@ -56,12 +57,6 @@ def spirekeeper_start():
|
||||||
# wallet, not an auto-created machine wallet. Soft-fails on lnbits
|
# wallet, not an auto-created machine wallet. Soft-fails on lnbits
|
||||||
# versions that don't yet expose `register_roster_resolver`.
|
# versions that don't yet expose `register_roster_resolver`.
|
||||||
register_roster_with_lnbits()
|
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__ = [
|
__all__ = [
|
||||||
|
|
|
||||||
|
|
@ -126,14 +126,6 @@ def assert_nostr_attribution(machine: Machine, extra: dict) -> None:
|
||||||
"missing nostr_sender_pubkey on Payment.extra — invoice was not "
|
"missing nostr_sender_pubkey on Payment.extra — invoice was not "
|
||||||
"issued through the nostr-transport path"
|
"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
|
from lnbits.utils.nostr import normalize_public_key
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
"""
|
|
||||||
Secure cash-in: a `create_withdraw` nostr-transport RPC (aiolabs/spirekeeper#31).
|
|
||||||
|
|
||||||
Mirrors withdraw's `lnurlw_create_link`, but cash-in-semantic. The ATM sends the
|
|
||||||
gross `principal_sats` (the hardware-attested fiat value) over the bunker-signed
|
|
||||||
kind-21000 transport; the operator side derives the fee, the NET withdraw
|
|
||||||
amount, and the attribution **server-side** — none of it client-supplied. The
|
|
||||||
customer claims the NET link; the payout carries the stamped `extra`
|
|
||||||
(aiolabs/withdraw#3) so `_handle_payment` records the `cash_in` settlement with
|
|
||||||
cryptographic attribution (the verified transport sender), exactly like the
|
|
||||||
cash-out path.
|
|
||||||
|
|
||||||
Why this exists: `lnurlw_create_link` takes `min/max_withdrawable` and `extra`
|
|
||||||
straight from the client body, so an authenticated-but-malicious/buggy ATM could
|
|
||||||
set the gross amount (no fee), forge `nostr_sender_pubkey`, or request an
|
|
||||||
arbitrary amount. This RPC closes that by computing everything server-side.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from .crud import get_machine_by_atm_pubkey_hex, get_super_config
|
|
||||||
|
|
||||||
_RPC_NAME = "create_withdraw"
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_create_withdraw(auth, request) -> dict:
|
|
||||||
"""nostr-transport RPC handler. `auth` is a WalletTypeInfo (the operator
|
|
||||||
wallet, roster-resolved from the verified sender); `request` is a
|
|
||||||
NostrRpcRequest with `body`, `sender_pubkey` (verified), and `event_id`."""
|
|
||||||
# Import withdraw lazily so registration never hard-depends on the withdraw
|
|
||||||
# extension being importable at startup; a missing dep fails the request,
|
|
||||||
# not the daemon.
|
|
||||||
try:
|
|
||||||
from withdraw.crud import create_withdraw_link
|
|
||||||
from withdraw.helpers import create_lnurl_from_baseurl
|
|
||||||
from withdraw.models import CreateWithdrawData
|
|
||||||
except ImportError as exc:
|
|
||||||
raise ValueError(
|
|
||||||
"withdraw extension unavailable; cannot mint a cash-in link"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
body = request.body or {}
|
|
||||||
|
|
||||||
principal_sats = body.get("principal_sats")
|
|
||||||
if not isinstance(principal_sats, int) or principal_sats <= 0:
|
|
||||||
raise ValueError("principal_sats must be a positive integer")
|
|
||||||
|
|
||||||
# Attribution is the VERIFIED transport sender — never read it from the body.
|
|
||||||
sender = (request.sender_pubkey or "").lower()
|
|
||||||
if not sender:
|
|
||||||
raise ValueError("missing verified sender_pubkey")
|
|
||||||
|
|
||||||
machine = await get_machine_by_atm_pubkey_hex(sender)
|
|
||||||
if machine is None:
|
|
||||||
raise ValueError("no active machine for this signer")
|
|
||||||
# Defence in depth: the roster already resolved `auth.wallet` from `sender`;
|
|
||||||
# confirm it's the machine's own wallet before minting a payout link on it.
|
|
||||||
if machine.wallet_id != auth.wallet.id:
|
|
||||||
raise ValueError("machine wallet does not match the authenticated wallet")
|
|
||||||
|
|
||||||
super_config = await get_super_config()
|
|
||||||
if super_config is None:
|
|
||||||
raise ValueError("super_config not initialised")
|
|
||||||
|
|
||||||
# Per-tx cap (server-side; the bunker ACL / usage caps cannot see sats).
|
|
||||||
cap = getattr(super_config, "max_cash_in_sats", None)
|
|
||||||
if cap is not None and principal_sats > cap:
|
|
||||||
raise ValueError(
|
|
||||||
f"principal_sats {principal_sats} exceeds max_cash_in_sats {cap}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Round each leg independently so the split matches parse_settlement exactly
|
|
||||||
# (platform_fee + operator_fee == fee_sats → fee_mismatch_sats = 0).
|
|
||||||
platform_fee = round(
|
|
||||||
principal_sats * float(super_config.super_cash_in_fee_fraction)
|
|
||||||
)
|
|
||||||
operator_fee = round(principal_sats * float(machine.operator_cash_in_fee_fraction))
|
|
||||||
fee_sats = platform_fee + operator_fee
|
|
||||||
net_sats = principal_sats - fee_sats
|
|
||||||
if net_sats <= 0:
|
|
||||||
raise ValueError("fee >= principal; nothing to withdraw")
|
|
||||||
|
|
||||||
data = CreateWithdrawData(
|
|
||||||
title=body.get("title") or f"bitSpire Cash-In {machine.id}",
|
|
||||||
min_withdrawable=net_sats,
|
|
||||||
max_withdrawable=net_sats,
|
|
||||||
uses=1,
|
|
||||||
wait_time=int(body.get("wait_time") or 1),
|
|
||||||
is_unique=False,
|
|
||||||
extra={
|
|
||||||
"source": "bitspire",
|
|
||||||
"type": "cash_in",
|
|
||||||
"principal_sats": principal_sats,
|
|
||||||
"fee_sats": fee_sats,
|
|
||||||
"nostr_sender_pubkey": sender,
|
|
||||||
"nostr_event_id": body.get("client_ref") or request.event_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
link = await create_withdraw_link(data, auth.wallet.id)
|
|
||||||
lnurl = create_lnurl_from_baseurl(link)
|
|
||||||
logger.info(
|
|
||||||
f"spirekeeper: create_withdraw machine={machine.id} "
|
|
||||||
f"principal={principal_sats} fee={fee_sats} (super={platform_fee} "
|
|
||||||
f"op={operator_fee}) net={net_sats} link={link.id}"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"link_id": link.id,
|
|
||||||
# `Lnurl.__str__` is the raw URL — wallets need the bech32 LNURL1…
|
|
||||||
# (lud01). Mirror withdraw's `_populate_lnurl` field convention.
|
|
||||||
"lnurl": str(lnurl.bech32),
|
|
||||||
"lnurl_url": str(lnurl.url),
|
|
||||||
"net_sats": net_sats,
|
|
||||||
"principal_sats": principal_sats,
|
|
||||||
"fee_sats": fee_sats,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def register_create_withdraw_rpc() -> None:
|
|
||||||
"""Register `create_withdraw` with the lnbits nostr transport. Soft-fails if
|
|
||||||
the transport doesn't expose `register_rpc` (older lnbits)."""
|
|
||||||
try:
|
|
||||||
from lnbits.core.services.nostr_transport.dispatcher import ( # type: ignore
|
|
||||||
AUTH_WALLET,
|
|
||||||
register_rpc,
|
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
logger.warning(
|
|
||||||
"spirekeeper: nostr-transport register_rpc unavailable; "
|
|
||||||
"'create_withdraw' not registered (secure cash-in over RPC disabled)"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
register_rpc(_RPC_NAME, handle_create_withdraw, AUTH_WALLET)
|
|
||||||
logger.info("spirekeeper: registered nostr-transport RPC 'create_withdraw'")
|
|
||||||
|
|
@ -141,10 +141,8 @@ def _state_d_tag(atm_pubkey_hex: str) -> str:
|
||||||
|
|
||||||
def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]:
|
def build_state_d_tags_for_machines(machines: list[Machine]) -> list[str]:
|
||||||
"""Bootstrap-consumer subscription filter helper: returns the full
|
"""Bootstrap-consumer subscription filter helper: returns the full
|
||||||
`#d=[...]` list for all known PAIRED ATMs an operator subscribes to.
|
`#d=[...]` list for all known ATMs an operator subscribes to."""
|
||||||
Unpaired machines (machine_npub is None — nullable since #29/m011) have no
|
return [_state_d_tag(_atm_hex_pubkey(m)) for m in machines]
|
||||||
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,11 +180,6 @@ async def get_machine_by_atm_pubkey_hex(atm_pubkey_hex: str) -> Machine | None:
|
||||||
target = atm_pubkey_hex.lower()
|
target = atm_pubkey_hex.lower()
|
||||||
machines = await list_all_active_machines()
|
machines = await list_all_active_machines()
|
||||||
for m in 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:
|
try:
|
||||||
if normalize_public_key(m.machine_npub).lower() == target:
|
if normalize_public_key(m.machine_npub).lower() == target:
|
||||||
return m
|
return m
|
||||||
|
|
@ -207,55 +202,6 @@ async def update_machine(machine_id: str, data: UpdateMachineData) -> Machine |
|
||||||
return await get_machine(machine_id)
|
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:
|
async def delete_machine(machine_id: str) -> None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"DELETE FROM spirekeeper.dca_machines WHERE id = :id",
|
"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 (
|
CREATE TABLE IF NOT EXISTS spirekeeper.dca_machines (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
operator_user_id TEXT NOT NULL,
|
operator_user_id TEXT NOT NULL,
|
||||||
machine_npub TEXT UNIQUE,
|
machine_npub TEXT NOT NULL UNIQUE,
|
||||||
wallet_id TEXT NOT NULL,
|
wallet_id TEXT NOT NULL,
|
||||||
name TEXT,
|
name TEXT,
|
||||||
location TEXT,
|
location TEXT,
|
||||||
|
|
@ -735,128 +735,3 @@ async def m009_split_fee_fractions_by_direction(db):
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"ALTER TABLE spirekeeper.super_config DROP COLUMN super_fee_fraction"
|
"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,11 +28,7 @@ class CreateMachineData(BaseModel):
|
||||||
not against any fee total. See aiolabs/satmachineadmin#37 / #38.
|
not against any fee total. See aiolabs/satmachineadmin#37 / #38.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Optional: blank = register the machine UNPAIRED — the bunker mints its
|
machine_npub: str
|
||||||
# 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
|
wallet_id: str
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
location: str | None = None
|
location: str | None = None
|
||||||
|
|
@ -52,7 +48,7 @@ class CreateMachineData(BaseModel):
|
||||||
class Machine(BaseModel):
|
class Machine(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
operator_user_id: str
|
operator_user_id: str
|
||||||
machine_npub: str | None # NULL until paired (or supplied on the dev self-key path)
|
machine_npub: str
|
||||||
wallet_id: str
|
wallet_id: str
|
||||||
name: str | None
|
name: str | None
|
||||||
location: str | None
|
location: str | None
|
||||||
|
|
@ -60,9 +56,6 @@ class Machine(BaseModel):
|
||||||
is_active: bool
|
is_active: bool
|
||||||
operator_cash_in_fee_fraction: float = 0.0
|
operator_cash_in_fee_fraction: float = 0.0
|
||||||
operator_cash_out_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
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
@ -85,29 +78,6 @@ class UpdateMachineData(BaseModel):
|
||||||
return round(float(v), 4)
|
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).
|
# DCA Clients — LP registrations, scoped per (machine, user).
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -482,10 +452,6 @@ class SuperConfig(BaseModel):
|
||||||
super_cash_in_fee_fraction: float = 0.0
|
super_cash_in_fee_fraction: float = 0.0
|
||||||
super_cash_out_fee_fraction: float = 0.0
|
super_cash_out_fee_fraction: float = 0.0
|
||||||
super_fee_wallet_id: str | None
|
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
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -493,7 +459,6 @@ class UpdateSuperConfigData(BaseModel):
|
||||||
super_cash_in_fee_fraction: float | None = None
|
super_cash_in_fee_fraction: float | None = None
|
||||||
super_cash_out_fee_fraction: float | None = None
|
super_cash_out_fee_fraction: float | None = None
|
||||||
super_fee_wallet_id: str | None = None
|
super_fee_wallet_id: str | None = None
|
||||||
max_cash_in_sats: int | None = None
|
|
||||||
|
|
||||||
@validator(
|
@validator(
|
||||||
"super_cash_in_fee_fraction",
|
"super_cash_in_fee_fraction",
|
||||||
|
|
@ -506,14 +471,6 @@ class UpdateSuperConfigData(BaseModel):
|
||||||
raise ValueError("super fee fraction must be between 0 and 1")
|
raise ValueError("super fee fraction must be between 0 and 1")
|
||||||
return round(float(v), 4)
|
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.
|
# Operator UX action carriers — partial-tx and balance-settlement features.
|
||||||
|
|
|
||||||
277
pairing.py
277
pairing.py
|
|
@ -1,277 +0,0 @@
|
||||||
"""Seed-URL pairing for bitSpire machines (S0 / aiolabs/spirekeeper#9), model A1.
|
|
||||||
|
|
||||||
Mints a per-spire signing key *inside the operator's nsecbunkerd*, issues a
|
|
||||||
scoped NIP-46 connect token, and builds the one-shot **seed URL** the spire
|
|
||||||
redeems at first boot. The spire then self-signs all of its own events
|
|
||||||
(kind-21000 cash RPC, kind-30078 beacon + cassette-state, CLINK 21001-21003)
|
|
||||||
as that bunker-held key; lnbits' path-B roster (`nostr_transport/roster.py`)
|
|
||||||
maps the spire npub to the operator's wallet. No nsec ever lands on the
|
|
||||||
spire's disk.
|
|
||||||
|
|
||||||
Division of labour (vs. lnbits' `RemoteBunkerSigner.provision`, which is the
|
|
||||||
reference for the admin chain):
|
|
||||||
|
|
||||||
spirekeeper (here) spire, at first boot (bitspire#52)
|
|
||||||
────────────────── ──────────────────────────────────
|
|
||||||
1. create_new_key 5. NIP-46 connect — redeem the token with a
|
|
||||||
2. ensure_policy freshly-generated *client* keypair; bunker
|
|
||||||
3. create_new_token binds (client_pubkey → spire key). The
|
|
||||||
4. get_key_tokens ─ seed ─► client_nsec stays on the spire; the
|
|
||||||
(package token in URL) signing key never leaves the bunker.
|
|
||||||
|
|
||||||
We deliberately do NOT run the connect/eager-bind step here: the spire is the
|
|
||||||
NIP-46 client, so the binding must happen spire-side with the spire's own
|
|
||||||
client keypair. spirekeeper only mints + packages.
|
|
||||||
|
|
||||||
Seed URL wire format (contract shared with bitspire#52):
|
|
||||||
|
|
||||||
spire-seed:v1:<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: 47 KiB After Width: | Height: | Size: 39 KiB |
|
|
@ -103,8 +103,7 @@ window.app = Vue.createApp({
|
||||||
data: {
|
data: {
|
||||||
super_cash_in_fee_fraction: 0,
|
super_cash_in_fee_fraction: 0,
|
||||||
super_cash_out_fee_fraction: 0,
|
super_cash_out_fee_fraction: 0,
|
||||||
super_fee_wallet_id: '',
|
super_fee_wallet_id: ''
|
||||||
max_cash_in_sats: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -192,14 +191,6 @@ window.app = Vue.createApp({
|
||||||
saving: false,
|
saving: false,
|
||||||
data: {}
|
data: {}
|
||||||
},
|
},
|
||||||
pairDialog: {
|
|
||||||
show: false,
|
|
||||||
saving: false,
|
|
||||||
machine: null,
|
|
||||||
relays: '',
|
|
||||||
durationHours: null,
|
|
||||||
result: null
|
|
||||||
},
|
|
||||||
machineDetail: {
|
machineDetail: {
|
||||||
show: false,
|
show: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|
@ -577,50 +568,13 @@ window.app = Vue.createApp({
|
||||||
this.superConfig?.super_cash_in_fee_fraction ?? 0,
|
this.superConfig?.super_cash_in_fee_fraction ?? 0,
|
||||||
super_cash_out_fee_fraction:
|
super_cash_out_fee_fraction:
|
||||||
this.superConfig?.super_cash_out_fee_fraction ?? 0,
|
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
|
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() {
|
async submitSuperFee() {
|
||||||
const d = this.superFeeDialog.data
|
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
|
this.superFeeDialog.saving = true
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
|
|
@ -628,8 +582,7 @@ window.app = Vue.createApp({
|
||||||
{
|
{
|
||||||
super_cash_in_fee_fraction: Number(d.super_cash_in_fee_fraction),
|
super_cash_in_fee_fraction: Number(d.super_cash_in_fee_fraction),
|
||||||
super_cash_out_fee_fraction: Number(d.super_cash_out_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
|
this.superConfig = data
|
||||||
|
|
@ -738,17 +691,13 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
async submitAddMachine() {
|
async submitAddMachine() {
|
||||||
const body = this._cleanMachineForm(this.addMachineDialog.data)
|
const body = this._cleanMachineForm(this.addMachineDialog.data)
|
||||||
if (!body.wallet_id) {
|
if (!body.machine_npub || !body.wallet_id) {
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: 'A wallet is required'
|
message: 'machine_npub and wallet_id are required'
|
||||||
})
|
})
|
||||||
return
|
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
|
this.addMachineDialog.saving = true
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request('POST', MACHINES_PATH, null, body)
|
const {data} = await LNbits.api.request('POST', MACHINES_PATH, null, body)
|
||||||
|
|
@ -756,7 +705,7 @@ window.app = Vue.createApp({
|
||||||
this.addMachineDialog.show = false
|
this.addMachineDialog.show = false
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: `Machine ${data.name || (data.machine_npub || 'unpaired').slice(0, 12)} added`
|
message: `Machine ${data.name || data.machine_npub.slice(0, 12)} added`
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._notifyError(e, 'Failed to add machine')
|
this._notifyError(e, 'Failed to add machine')
|
||||||
|
|
@ -784,10 +733,6 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
async submitEditMachine() {
|
async submitEditMachine() {
|
||||||
const d = this.editMachineDialog.data
|
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
|
this.editMachineDialog.saving = true
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
|
|
@ -819,7 +764,7 @@ window.app = Vue.createApp({
|
||||||
Quasar.Dialog.create({
|
Quasar.Dialog.create({
|
||||||
title: 'Delete machine?',
|
title: 'Delete machine?',
|
||||||
message:
|
message:
|
||||||
`This removes <b>${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}</b>` +
|
`This removes <b>${machine.name || machine.machine_npub.slice(0, 12)}</b>` +
|
||||||
' from your fleet. Existing settlements and payment history are preserved' +
|
' from your fleet. Existing settlements and payment history are preserved' +
|
||||||
' — only the machine row itself is removed. Continue?',
|
' — only the machine row itself is removed. Continue?',
|
||||||
html: true,
|
html: true,
|
||||||
|
|
@ -836,93 +781,6 @@ window.app = Vue.createApp({
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
// Pair / revoke spire (S0 / #9, #12)
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
openPairDialog(machine) {
|
|
||||||
this.pairDialog.machine = machine
|
|
||||||
this.pairDialog.relays = ''
|
|
||||||
this.pairDialog.durationHours = null
|
|
||||||
this.pairDialog.result = null
|
|
||||||
this.pairDialog.show = true
|
|
||||||
},
|
|
||||||
|
|
||||||
async submitPair() {
|
|
||||||
const relays = (this.pairDialog.relays || '')
|
|
||||||
.split(/[\s,]+/)
|
|
||||||
.map(s => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
if (!relays.length) {
|
|
||||||
Quasar.Notify.create({
|
|
||||||
type: 'negative',
|
|
||||||
message: 'At least one relay is required'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const body = {relays}
|
|
||||||
if (this.pairDialog.durationHours) {
|
|
||||||
body.duration_hours = Number(this.pairDialog.durationHours)
|
|
||||||
}
|
|
||||||
this.pairDialog.saving = true
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'POST',
|
|
||||||
`${MACHINES_PATH}/${this.pairDialog.machine.id}/pair`,
|
|
||||||
null,
|
|
||||||
body
|
|
||||||
)
|
|
||||||
this.pairDialog.result = data
|
|
||||||
// The bunker-minted key becomes the machine identity; reflect it +
|
|
||||||
// the paired state in the row immediately.
|
|
||||||
const m = this.machines.find(x => x.id === this.pairDialog.machine.id)
|
|
||||||
if (m) {
|
|
||||||
m.machine_npub = data.spire_pubkey_hex
|
|
||||||
m.bunker_spire_key_name = data.bunker_key_name
|
|
||||||
m.paired_at = new Date().toISOString()
|
|
||||||
}
|
|
||||||
Quasar.Notify.create({
|
|
||||||
type: 'positive',
|
|
||||||
message: 'Spire paired — hand the seed URL to the device'
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
this._notifyError(e, 'Pairing failed')
|
|
||||||
} finally {
|
|
||||||
this.pairDialog.saving = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
confirmRevokeMachine(machine) {
|
|
||||||
Quasar.Dialog.create({
|
|
||||||
title: 'Revoke spire access?',
|
|
||||||
message:
|
|
||||||
`This cuts <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)
|
// Machine detail dialog (P9b)
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
@ -1562,7 +1420,7 @@ window.app = Vue.createApp({
|
||||||
// Helpers
|
// Helpers
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
shortNpub(npub) {
|
shortNpub(npub) {
|
||||||
if (!npub) return 'unpaired'
|
if (!npub) return ''
|
||||||
if (npub.length <= 16) return npub
|
if (npub.length <= 16) return npub
|
||||||
return npub.slice(0, 8) + '…' + npub.slice(-6)
|
return npub.slice(0, 8) + '…' + npub.slice(-6)
|
||||||
},
|
},
|
||||||
|
|
@ -1648,7 +1506,7 @@ window.app = Vue.createApp({
|
||||||
|
|
||||||
_cleanMachineForm(d) {
|
_cleanMachineForm(d) {
|
||||||
return {
|
return {
|
||||||
machine_npub: (d.machine_npub || '').trim() || null,
|
machine_npub: (d.machine_npub || '').trim(),
|
||||||
wallet_id: d.wallet_id,
|
wallet_id: d.wallet_id,
|
||||||
name: (d.name || '').trim() || null,
|
name: (d.name || '').trim() || null,
|
||||||
location: (d.location || '').trim() || null,
|
location: (d.location || '').trim() || null,
|
||||||
|
|
|
||||||
24
tasks.py
24
tasks.py
|
|
@ -130,11 +130,7 @@ async def _handle_payment(payment: Payment) -> None:
|
||||||
data = parse_settlement(
|
data = parse_settlement(
|
||||||
machine=machine,
|
machine=machine,
|
||||||
payment_hash=payment.payment_hash,
|
payment_hash=payment.payment_hash,
|
||||||
# `payment.sat` is signed by protocol direction (negative for an
|
wire_sats=payment.sat,
|
||||||
# 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,
|
extra=extra,
|
||||||
super_config=super_config,
|
super_config=super_config,
|
||||||
)
|
)
|
||||||
|
|
@ -209,8 +205,7 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -
|
||||||
data = CreateDcaSettlementData(
|
data = CreateDcaSettlementData(
|
||||||
machine_id=machine.id,
|
machine_id=machine.id,
|
||||||
payment_hash=payment.payment_hash,
|
payment_hash=payment.payment_hash,
|
||||||
# Magnitude, not the signed `payment.sat` (negative for outbound).
|
wire_sats=payment.sat,
|
||||||
wire_sats=abs(payment.sat),
|
|
||||||
fiat_amount=0.0,
|
fiat_amount=0.0,
|
||||||
fiat_code=machine.fiat_code,
|
fiat_code=machine.fiat_code,
|
||||||
exchange_rate=0.0,
|
exchange_rate=0.0,
|
||||||
|
|
@ -218,11 +213,11 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -
|
||||||
fee_sats=0,
|
fee_sats=0,
|
||||||
platform_fee_sats=0,
|
platform_fee_sats=0,
|
||||||
operator_fee_sats=0,
|
operator_fee_sats=0,
|
||||||
# The parsed tx_type is unavailable on the rejection path, but the
|
# tx_type is unknown for rejection paths; default to cash_out
|
||||||
# authenticated protocol direction is: an outbound payment is a
|
# (the only direction currently wired). When S8 lands the
|
||||||
# cash-in, an inbound one a cash-out. Use that so a rejected row shows
|
# listener will branch on tx_type from extra, and this default
|
||||||
# the right direction instead of always reading "cash-out".
|
# gets revisited.
|
||||||
tx_type="cash_in" if not payment.is_in else "cash_out",
|
tx_type="cash_out",
|
||||||
)
|
)
|
||||||
rejected = await create_settlement_idempotent(
|
rejected = await create_settlement_idempotent(
|
||||||
data, initial_status="rejected", error_message=str(exc)
|
data, initial_status="rejected", error_message=str(exc)
|
||||||
|
|
@ -235,10 +230,7 @@ async def _record_rejected(payment: Payment, machine: Machine, exc: Exception) -
|
||||||
return
|
return
|
||||||
logger.error(
|
logger.error(
|
||||||
f"spirekeeper: rejected settlement {rejected.id} "
|
f"spirekeeper: rejected settlement {rejected.id} "
|
||||||
# An unpaired machine (machine_npub None) reaches here now that
|
f"(machine={machine.machine_npub[:12]}..., "
|
||||||
# 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}"
|
f"payment_hash={payment.payment_hash[:12]}...): {exc}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,17 +131,6 @@
|
||||||
<div :style="{fontWeight: 500}" v-text="props.row.name || 'Unnamed'"></div>
|
<div :style="{fontWeight: 500}" v-text="props.row.name || 'Unnamed'"></div>
|
||||||
<div :style="{fontSize: '0.8em', opacity: 0.6}"
|
<div :style="{fontSize: '0.8em', opacity: 0.6}"
|
||||||
v-text="props.row.location || 'No location set'"></div>
|
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>
|
||||||
<q-td key="machine_npub">
|
<q-td key="machine_npub">
|
||||||
<code :style="{fontSize: '0.85em'}"
|
<code :style="{fontSize: '0.85em'}"
|
||||||
|
|
@ -167,17 +156,6 @@
|
||||||
@click="openEditMachineDialog(props.row)">
|
@click="openEditMachineDialog(props.row)">
|
||||||
<q-tooltip>Edit</q-tooltip>
|
<q-tooltip>Edit</q-tooltip>
|
||||||
</q-btn>
|
</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"
|
<q-btn flat dense round size="sm" icon="delete"
|
||||||
color="red-7"
|
color="red-7"
|
||||||
@click="confirmDeleteMachine(props.row)">
|
@click="confirmDeleteMachine(props.row)">
|
||||||
|
|
@ -792,13 +770,13 @@
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="addMachineDialog.data.machine_npub"
|
v-model="addMachineDialog.data.machine_npub"
|
||||||
label="Machine npub — DEVELOPMENT ONLY (blank = normal bunker pairing)"
|
label="Machine npub (hex or bech32)"
|
||||||
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…"
|
hint="64-char hex pubkey or npub1... bech32 string"
|
||||||
color="orange"
|
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
dense outlined
|
dense outlined
|
||||||
:rules="[
|
:rules="[
|
||||||
v => !v || v.length >= 32 || 'Looks too short — use a full hex/npub, or leave blank'
|
v => !!v || 'Required',
|
||||||
|
v => (v && v.length >= 32) || 'Looks too short'
|
||||||
]"></q-input>
|
]"></q-input>
|
||||||
|
|
||||||
<q-select
|
<q-select
|
||||||
|
|
@ -819,7 +797,7 @@
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model.number="addMachineDialog.data.operator_cash_in_fee_fraction"
|
v-model.number="addMachineDialog.data.operator_cash_in_fee_fraction"
|
||||||
label="Operator cash-in fee (decimal fraction, 0-0.15)"
|
label="Operator cash-in fee % (decimal, 0..0.15)"
|
||||||
hint="Your per-machine cut on cash-in. Sits on top of the platform fee; cap is 15% total per direction."
|
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"
|
type="number" step="0.0001" min="0" max="0.15"
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
|
|
@ -827,7 +805,7 @@
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model.number="addMachineDialog.data.operator_cash_out_fee_fraction"
|
v-model.number="addMachineDialog.data.operator_cash_out_fee_fraction"
|
||||||
label="Operator cash-out fee (decimal fraction, 0-0.15)"
|
label="Operator cash-out fee % (decimal, 0..0.15)"
|
||||||
hint="Your per-machine cut on cash-out. Sits on top of the platform fee; cap is 15% total per direction."
|
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"
|
type="number" step="0.0001" min="0" max="0.15"
|
||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
|
|
@ -843,101 +821,6 @@
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</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 -->
|
<!-- MACHINE DETAIL DIALOG — settlements list + per-row actions -->
|
||||||
<!-- =============================================================== -->
|
<!-- =============================================================== -->
|
||||||
|
|
@ -1368,12 +1251,12 @@
|
||||||
typically a wallet you (the super) own.
|
typically a wallet you (the super) own.
|
||||||
</p>
|
</p>
|
||||||
<q-input v-model.number="superFeeDialog.data.super_cash_in_fee_fraction"
|
<q-input v-model.number="superFeeDialog.data.super_cash_in_fee_fraction"
|
||||||
label="Cash-in fee (decimal fraction, 0-0.15)"
|
label="Cash-in fee % (decimal, 0..0.15)"
|
||||||
hint="0.03 = 3% of principal on cash-in transactions"
|
hint="0.03 = 3% of principal on cash-in transactions"
|
||||||
type="number" step="0.0001" min="0" max="0.15"
|
type="number" step="0.0001" min="0" max="0.15"
|
||||||
class="q-mb-md" dense outlined></q-input>
|
class="q-mb-md" dense outlined></q-input>
|
||||||
<q-input v-model.number="superFeeDialog.data.super_cash_out_fee_fraction"
|
<q-input v-model.number="superFeeDialog.data.super_cash_out_fee_fraction"
|
||||||
label="Cash-out fee (decimal fraction, 0-0.15)"
|
label="Cash-out fee % (decimal, 0..0.15)"
|
||||||
hint="0.03 = 3% of principal on cash-out transactions"
|
hint="0.03 = 3% of principal on cash-out transactions"
|
||||||
type="number" step="0.0001" min="0" max="0.15"
|
type="number" step="0.0001" min="0" max="0.15"
|
||||||
class="q-mb-md" dense outlined></q-input>
|
class="q-mb-md" dense outlined></q-input>
|
||||||
|
|
@ -1381,11 +1264,6 @@
|
||||||
label="Super fee destination wallet_id"
|
label="Super fee destination wallet_id"
|
||||||
hint="LNbits wallet that collects the platform fee"
|
hint="LNbits wallet that collects the platform fee"
|
||||||
class="q-mb-md" dense outlined></q-input>
|
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-section>
|
||||||
<q-card-actions align="right">
|
<q-card-actions align="right">
|
||||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
||||||
|
|
@ -1632,12 +1510,12 @@
|
||||||
<q-input v-model="editMachineDialog.data.fiat_code"
|
<q-input v-model="editMachineDialog.data.fiat_code"
|
||||||
label="Fiat code" class="q-mb-md" dense outlined></q-input>
|
label="Fiat code" class="q-mb-md" dense outlined></q-input>
|
||||||
<q-input v-model.number="editMachineDialog.data.operator_cash_in_fee_fraction"
|
<q-input v-model.number="editMachineDialog.data.operator_cash_in_fee_fraction"
|
||||||
label="Operator cash-in fee (decimal fraction, 0-0.15)"
|
label="Operator cash-in fee % (decimal, 0..0.15)"
|
||||||
hint="Sits on top of the platform cash-in fee. Cap 15% total per direction."
|
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"
|
type="number" step="0.0001" min="0" max="0.15"
|
||||||
class="q-mb-md" dense outlined></q-input>
|
class="q-mb-md" dense outlined></q-input>
|
||||||
<q-input v-model.number="editMachineDialog.data.operator_cash_out_fee_fraction"
|
<q-input v-model.number="editMachineDialog.data.operator_cash_out_fee_fraction"
|
||||||
label="Operator cash-out fee (decimal fraction, 0-0.15)"
|
label="Operator cash-out fee % (decimal, 0..0.15)"
|
||||||
hint="Sits on top of the platform cash-out fee. Cap 15% total per direction."
|
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"
|
type="number" step="0.0001" min="0" max="0.15"
|
||||||
class="q-mb-md" dense outlined></q-input>
|
class="q-mb-md" dense outlined></q-input>
|
||||||
|
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
"""Wiring tests for POST /machines/{id}/pair (S0 / #9).
|
|
||||||
|
|
||||||
The pairing *service* is covered in test_pairing.py with a fake bunker;
|
|
||||||
here we only exercise the endpoint glue — ownership, the empty-relays
|
|
||||||
guard, the post-mint collision guard, persistence of the bunker-minted
|
|
||||||
hex npub, and error mapping — by monkeypatching the module-level deps.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from lnbits.utils.nostr import hex_to_npub
|
|
||||||
|
|
||||||
from .. import views_api
|
|
||||||
from ..models import Machine, PairMachineData
|
|
||||||
from ..pairing import PairingError, PairResult
|
|
||||||
|
|
||||||
_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc)
|
|
||||||
_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
|
|
||||||
_SPIRE_NPUB = hex_to_npub(_SPIRE_HEX)
|
|
||||||
|
|
||||||
|
|
||||||
def _machine(npub: str = "placeholder") -> Machine:
|
|
||||||
return Machine(
|
|
||||||
id="m1",
|
|
||||||
operator_user_id="op1",
|
|
||||||
machine_npub=npub,
|
|
||||||
wallet_id="w1",
|
|
||||||
name="sintra",
|
|
||||||
location=None,
|
|
||||||
fiat_code="EUR",
|
|
||||||
is_active=True,
|
|
||||||
created_at=_NOW,
|
|
||||||
updated_at=_NOW,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeAdmin:
|
|
||||||
@classmethod
|
|
||||||
def from_settings(cls):
|
|
||||||
return cls()
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *exc):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _result() -> PairResult:
|
|
||||||
return PairResult(
|
|
||||||
spire_npub=_SPIRE_NPUB,
|
|
||||||
spire_pubkey_hex=_SPIRE_HEX,
|
|
||||||
bunker_key_name="spire-m1",
|
|
||||||
bunker_url="bunker://x?relay=r&secret=s", # pragma: allowlist secret
|
|
||||||
seed_url="spire-seed:v1:abc",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _wire(monkeypatch, *, pair="ok"):
|
|
||||||
state: dict = {"persisted": None, "collision": None}
|
|
||||||
|
|
||||||
async def fake_owned(machine_id, user_id):
|
|
||||||
return _machine()
|
|
||||||
|
|
||||||
async def fake_pair(machine, *, relays, admin_client, duration_hours=None):
|
|
||||||
if pair == "error":
|
|
||||||
raise PairingError("boom")
|
|
||||||
return _result()
|
|
||||||
|
|
||||||
async def fake_collision(npub):
|
|
||||||
state["collision"] = npub
|
|
||||||
|
|
||||||
async def fake_persist(
|
|
||||||
machine_id, *, machine_npub, bunker_spire_key_name, paired_at
|
|
||||||
):
|
|
||||||
state["persisted"] = (machine_id, machine_npub, bunker_spire_key_name)
|
|
||||||
return _machine(npub=machine_npub)
|
|
||||||
|
|
||||||
monkeypatch.setattr(views_api, "_machine_owned_by", fake_owned)
|
|
||||||
monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin)
|
|
||||||
monkeypatch.setattr(views_api, "pair_spire", fake_pair)
|
|
||||||
monkeypatch.setattr(views_api, "_assert_no_pubkey_collision", fake_collision)
|
|
||||||
monkeypatch.setattr(views_api, "set_machine_pairing", fake_persist)
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def _call(relays):
|
|
||||||
user = SimpleNamespace(id="op1")
|
|
||||||
return asyncio.run(
|
|
||||||
views_api.api_pair_machine("m1", PairMachineData(relays=relays), user)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pair_persists_hex_npub_and_returns_seed(monkeypatch):
|
|
||||||
state = _wire(monkeypatch)
|
|
||||||
result = _call(["wss://r"])
|
|
||||||
assert result.seed_url == "spire-seed:v1:abc"
|
|
||||||
# collision guard ran on the bunker-minted hex, and we persisted it as npub
|
|
||||||
assert state["collision"] == _SPIRE_HEX
|
|
||||||
assert state["persisted"] == ("m1", _SPIRE_HEX, "spire-m1")
|
|
||||||
|
|
||||||
|
|
||||||
def test_pair_empty_relays_rejected(monkeypatch):
|
|
||||||
_wire(monkeypatch)
|
|
||||||
with pytest.raises(HTTPException) as ei:
|
|
||||||
_call([])
|
|
||||||
assert ei.value.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
def test_pair_failure_maps_to_bad_gateway(monkeypatch):
|
|
||||||
state = _wire(monkeypatch, pair="error")
|
|
||||||
with pytest.raises(HTTPException) as ei:
|
|
||||||
_call(["wss://r"])
|
|
||||||
assert ei.value.status_code == 502
|
|
||||||
# nothing persisted on failure
|
|
||||||
assert state["persisted"] is None
|
|
||||||
|
|
||||||
|
|
||||||
def _wire_revoke(monkeypatch, *, revoke="ok", count=2):
|
|
||||||
state = {"unpaired": None}
|
|
||||||
|
|
||||||
async def fake_owned(machine_id, user_id):
|
|
||||||
return _machine()
|
|
||||||
|
|
||||||
async def fake_revoke(machine, *, admin_client):
|
|
||||||
if revoke == "error":
|
|
||||||
raise PairingError("boom")
|
|
||||||
return count
|
|
||||||
|
|
||||||
async def fake_unpaired(machine_id):
|
|
||||||
state["unpaired"] = machine_id
|
|
||||||
return _machine()
|
|
||||||
|
|
||||||
monkeypatch.setattr(views_api, "_machine_owned_by", fake_owned)
|
|
||||||
monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin)
|
|
||||||
monkeypatch.setattr(views_api, "revoke_spire", fake_revoke)
|
|
||||||
monkeypatch.setattr(views_api, "set_machine_unpaired", fake_unpaired)
|
|
||||||
return state
|
|
||||||
|
|
||||||
|
|
||||||
def _call_revoke():
|
|
||||||
user = SimpleNamespace(id="op1")
|
|
||||||
return asyncio.run(views_api.api_revoke_machine("m1", user))
|
|
||||||
|
|
||||||
|
|
||||||
def test_revoke_cuts_access_and_marks_unpaired(monkeypatch):
|
|
||||||
state = _wire_revoke(monkeypatch, count=2)
|
|
||||||
result = _call_revoke()
|
|
||||||
assert result.revoked_count == 2
|
|
||||||
assert state["unpaired"] == "m1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_revoke_zero_when_nothing_bound(monkeypatch):
|
|
||||||
_wire_revoke(monkeypatch, count=0)
|
|
||||||
assert _call_revoke().revoked_count == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_revoke_failure_maps_to_bad_gateway(monkeypatch):
|
|
||||||
state = _wire_revoke(monkeypatch, revoke="error")
|
|
||||||
with pytest.raises(HTTPException) as ei:
|
|
||||||
_call_revoke()
|
|
||||||
assert ei.value.status_code == 502
|
|
||||||
assert state["unpaired"] is None # not persisted on failure
|
|
||||||
|
|
@ -1,332 +0,0 @@
|
||||||
"""Unit tests for the seed-URL pairing service (S0 / #9, model A1).
|
|
||||||
|
|
||||||
The bunker admin client is faked — these exercise the orchestration
|
|
||||||
(create_new_key -> ensure-policy -> create_new_token -> get_key_tokens),
|
|
||||||
the policy reconciliation, and the seed-URL / bunker:// wire shape, with
|
|
||||||
no live nsecbunkerd. npub<->hex round-trips through lnbits' real helpers
|
|
||||||
so the parsing is exercised for real.
|
|
||||||
|
|
||||||
Async is driven via asyncio.run (this venv has no pytest-asyncio), matching
|
|
||||||
the rest of the suite.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from lnbits.core.services.nsec_bunker import NsecBunkerError
|
|
||||||
from lnbits.utils.nostr import hex_to_npub
|
|
||||||
|
|
||||||
from ..models import Machine
|
|
||||||
from ..pairing import (
|
|
||||||
SEED_URL_SCHEME,
|
|
||||||
SPIRE_POLICY_METHODS_NO_KIND,
|
|
||||||
SPIRE_POLICY_NAME,
|
|
||||||
SPIRE_POLICY_RULES,
|
|
||||||
PairingError,
|
|
||||||
build_seed_url,
|
|
||||||
pair_spire,
|
|
||||||
revoke_spire,
|
|
||||||
spire_key_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
_NOW = datetime(2026, 6, 16, tzinfo=timezone.utc)
|
|
||||||
_SPIRE_HEX = "522a4538f1df96508d9ee8b14072344dd4a566acfe03c25a92a39179c6fca891"
|
|
||||||
_SPIRE_NPUB = hex_to_npub(_SPIRE_HEX)
|
|
||||||
_RELAYS = ["wss://lnbits.demo.aiolabs.dev/nostrrelay/demo"]
|
|
||||||
_BUNKER_RELAY = "wss://bunker.internal/relay"
|
|
||||||
_PASSPHRASE = "keystore-pass" # pragma: allowlist secret
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clear_policy_cache():
|
|
||||||
# lnbits' ensure_policy caches resolved policy ids on
|
|
||||||
# (admin_pubkey, name); clear between tests so each FakeBunker's
|
|
||||||
# canned policy state is honoured rather than a stale cached id.
|
|
||||||
from lnbits.core.signers import remote_bunker
|
|
||||||
|
|
||||||
remote_bunker._POLICY_ID_CACHE.clear()
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
def _machine(mid: str = "m1") -> Machine:
|
|
||||||
return Machine(
|
|
||||||
id=mid,
|
|
||||||
operator_user_id="op1",
|
|
||||||
machine_npub="placeholder",
|
|
||||||
wallet_id="w1",
|
|
||||||
name="sintra",
|
|
||||||
location=None,
|
|
||||||
fiat_code="EUR",
|
|
||||||
is_active=True,
|
|
||||||
created_at=_NOW,
|
|
||||||
updated_at=_NOW,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FakeBunker:
|
|
||||||
"""Records calls; returns canned bunker responses."""
|
|
||||||
|
|
||||||
admin_pubkey = "fake-admin-pubkey"
|
|
||||||
|
|
||||||
# pragma: allowlist secret
|
|
||||||
def __init__(self, *, policies=None, token_secret="s3cr3t", revoke_count=1):
|
|
||||||
self._policies = policies or []
|
|
||||||
self._token_secret = token_secret
|
|
||||||
self._revoke_count = revoke_count
|
|
||||||
self.calls: list[tuple] = []
|
|
||||||
self._next_policy_id = 7
|
|
||||||
|
|
||||||
async def create_new_key(self, name, passphrase):
|
|
||||||
self.calls.append(("create_new_key", name, passphrase))
|
|
||||||
return _SPIRE_NPUB
|
|
||||||
|
|
||||||
async def get_policies(self):
|
|
||||||
self.calls.append(("get_policies",))
|
|
||||||
return list(self._policies)
|
|
||||||
|
|
||||||
async def create_new_policy(self, name, rules):
|
|
||||||
self.calls.append(("create_new_policy", name, rules))
|
|
||||||
pid = self._next_policy_id
|
|
||||||
self._policies.append({"id": pid, "name": name, "rules": list(rules)})
|
|
||||||
return pid
|
|
||||||
|
|
||||||
async def add_policy_rule(self, policy_id, rule):
|
|
||||||
self.calls.append(("add_policy_rule", policy_id, rule))
|
|
||||||
|
|
||||||
async def create_new_token(
|
|
||||||
self, key_name, client_name, policy_id, duration_hours=None
|
|
||||||
):
|
|
||||||
self.calls.append(
|
|
||||||
("create_new_token", key_name, client_name, policy_id, duration_hours)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def revoke_key_user(self, key_name):
|
|
||||||
self.calls.append(("revoke_key_user", key_name))
|
|
||||||
return self._revoke_count
|
|
||||||
|
|
||||||
async def get_key_tokens(self, key_name):
|
|
||||||
self.calls.append(("get_key_tokens", key_name))
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"clientName": f"spire-client-{key_name.split('-', 1)[1]}",
|
|
||||||
"token": f"{_SPIRE_NPUB}#{self._token_secret}",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
def named(self, name):
|
|
||||||
return [c for c in self.calls if c[0] == name]
|
|
||||||
|
|
||||||
|
|
||||||
def _pair(bunker, machine=None):
|
|
||||||
return asyncio.run(
|
|
||||||
pair_spire(
|
|
||||||
machine or _machine(),
|
|
||||||
relays=_RELAYS,
|
|
||||||
admin_client=bunker,
|
|
||||||
bunker_relay=_BUNKER_RELAY,
|
|
||||||
keystore_passphrase=_PASSPHRASE,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_pair_happy_path_mints_key_policy_token():
|
|
||||||
bunker = FakeBunker(token_secret="abc123") # pragma: allowlist secret
|
|
||||||
result = _pair(bunker)
|
|
||||||
|
|
||||||
assert ("create_new_key", "spire-m1", _PASSPHRASE) in bunker.calls
|
|
||||||
assert result.bunker_key_name == spire_key_name("m1") == "spire-m1"
|
|
||||||
|
|
||||||
assert result.spire_npub == _SPIRE_NPUB
|
|
||||||
assert result.spire_pubkey_hex == _SPIRE_HEX
|
|
||||||
|
|
||||||
created = bunker.named("create_new_policy")
|
|
||||||
assert created and created[0][1] == SPIRE_POLICY_NAME
|
|
||||||
token_call = bunker.named("create_new_token")[0]
|
|
||||||
assert token_call[1] == "spire-m1" # key_name
|
|
||||||
assert token_call[2] == "spire-client-m1" # client_name
|
|
||||||
assert token_call[3] == 7 # policy_id from the fake's create_new_policy
|
|
||||||
|
|
||||||
|
|
||||||
def test_bunker_url_carries_pubkey_relay_secret():
|
|
||||||
result = _pair(FakeBunker(token_secret="topsecret")) # pragma: allowlist secret
|
|
||||||
assert result.bunker_url.startswith(f"bunker://{_SPIRE_HEX}?")
|
|
||||||
assert "relay=wss%3A%2F%2Fbunker.internal%2Frelay" in result.bunker_url
|
|
||||||
assert "secret=topsecret" in result.bunker_url
|
|
||||||
|
|
||||||
|
|
||||||
def test_seed_url_decodes_to_contract():
|
|
||||||
result = _pair(FakeBunker(token_secret="zzz")) # pragma: allowlist secret
|
|
||||||
assert result.seed_url.startswith(SEED_URL_SCHEME)
|
|
||||||
blob = result.seed_url[len(SEED_URL_SCHEME) :]
|
|
||||||
payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4)))
|
|
||||||
assert payload == {
|
|
||||||
"v": 1,
|
|
||||||
"spire_npub": _SPIRE_NPUB,
|
|
||||||
"spire_pubkey": _SPIRE_HEX,
|
|
||||||
"bunker_url": result.bunker_url,
|
|
||||||
"relays": _RELAYS,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_fresh_policy_adds_kindless_nip44_rules():
|
|
||||||
bunker = FakeBunker() # no existing policies
|
|
||||||
_pair(bunker)
|
|
||||||
added = [c[2]["method"] for c in bunker.named("add_policy_rule")]
|
|
||||||
# kind-scoped rules went in via create_new_policy; only the kind-less
|
|
||||||
# nip44 methods are reconciled in via add_policy_rule.
|
|
||||||
assert set(added) == set(SPIRE_POLICY_METHODS_NO_KIND)
|
|
||||||
|
|
||||||
|
|
||||||
def test_existing_policy_reused_not_recreated():
|
|
||||||
existing = [
|
|
||||||
{
|
|
||||||
"id": 42,
|
|
||||||
"name": SPIRE_POLICY_NAME,
|
|
||||||
"rules": [dict(r) for r in SPIRE_POLICY_RULES],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
bunker = FakeBunker(policies=existing)
|
|
||||||
_pair(bunker)
|
|
||||||
assert not bunker.named("create_new_policy") # reused, not recreated
|
|
||||||
assert bunker.named("create_new_token")[0][3] == 42 # used existing id
|
|
||||||
added = [c[2]["method"] for c in bunker.named("add_policy_rule")]
|
|
||||||
assert set(added) == set(SPIRE_POLICY_METHODS_NO_KIND)
|
|
||||||
|
|
||||||
|
|
||||||
def test_fully_provisioned_policy_adds_nothing():
|
|
||||||
rules = [dict(r) for r in SPIRE_POLICY_RULES] + [
|
|
||||||
{"method": m, "kind": None} for m in SPIRE_POLICY_METHODS_NO_KIND
|
|
||||||
]
|
|
||||||
bunker = FakeBunker(policies=[{"id": 9, "name": SPIRE_POLICY_NAME, "rules": rules}])
|
|
||||||
_pair(bunker)
|
|
||||||
assert not bunker.named("add_policy_rule")
|
|
||||||
assert not bunker.named("create_new_policy")
|
|
||||||
|
|
||||||
|
|
||||||
def test_malformed_token_raises():
|
|
||||||
bunker = FakeBunker()
|
|
||||||
|
|
||||||
async def _bad_tokens(key_name):
|
|
||||||
_ = key_name
|
|
||||||
return [{"token": "no-hash-here"}]
|
|
||||||
|
|
||||||
bunker.get_key_tokens = _bad_tokens
|
|
||||||
with pytest.raises(PairingError, match="malformed token"):
|
|
||||||
_pair(bunker)
|
|
||||||
|
|
||||||
|
|
||||||
def test_bunker_relay_defaults_to_spire_event_relay():
|
|
||||||
"""No explicit bunker_relay -> the relay baked into bunker_url is the spire's
|
|
||||||
own public event relay (relays[0]), NOT lnbits's internal bunker URL. This
|
|
||||||
is the localhost-relay /pair gotcha: a UI-minted seed (the form has no
|
|
||||||
bunker_relay field) must embed a machine-reachable relay, not ws://127.0.0.1.
|
|
||||||
An empty bunker_relay falls back to the same default."""
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
for empty in (None, ""):
|
|
||||||
result = asyncio.run(
|
|
||||||
pair_spire(
|
|
||||||
_machine(),
|
|
||||||
relays=_RELAYS,
|
|
||||||
admin_client=FakeBunker(token_secret="s"), # pragma: allowlist secret
|
|
||||||
bunker_relay=empty,
|
|
||||||
keystore_passphrase=_PASSPHRASE,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert f"relay={quote(_RELAYS[0], safe='')}" in result.bunker_url
|
|
||||||
assert "127.0.0.1" not in result.bunker_url
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_relay_or_passphrase_raises():
|
|
||||||
with pytest.raises(PairingError, match="PASSPHRASE"):
|
|
||||||
asyncio.run(
|
|
||||||
pair_spire(
|
|
||||||
_machine(),
|
|
||||||
relays=_RELAYS,
|
|
||||||
admin_client=FakeBunker(),
|
|
||||||
bunker_relay=_BUNKER_RELAY,
|
|
||||||
keystore_passphrase="",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
with pytest.raises(PairingError, match="relay is required"):
|
|
||||||
asyncio.run(
|
|
||||||
pair_spire(
|
|
||||||
_machine(),
|
|
||||||
relays=[],
|
|
||||||
admin_client=FakeBunker(),
|
|
||||||
bunker_relay=_BUNKER_RELAY,
|
|
||||||
keystore_passphrase=_PASSPHRASE,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_seed_url_roundtrip():
|
|
||||||
url = build_seed_url(
|
|
||||||
spire_npub=_SPIRE_NPUB,
|
|
||||||
spire_pubkey_hex=_SPIRE_HEX,
|
|
||||||
bunker_url="bunker://x?relay=r&secret=s",
|
|
||||||
relays=_RELAYS,
|
|
||||||
)
|
|
||||||
blob = url[len(SEED_URL_SCHEME) :]
|
|
||||||
payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4)))
|
|
||||||
assert payload["spire_pubkey"] == _SPIRE_HEX
|
|
||||||
assert payload["relays"] == _RELAYS
|
|
||||||
|
|
||||||
|
|
||||||
def test_pair_threads_duration_hours():
|
|
||||||
bunker = FakeBunker()
|
|
||||||
asyncio.run(
|
|
||||||
pair_spire(
|
|
||||||
_machine(),
|
|
||||||
relays=_RELAYS,
|
|
||||||
admin_client=bunker,
|
|
||||||
bunker_relay=_BUNKER_RELAY,
|
|
||||||
keystore_passphrase=_PASSPHRASE,
|
|
||||||
duration_hours=720,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# create_new_token tuple is (name, key, client, policy_id, duration_hours)
|
|
||||||
assert bunker.named("create_new_token")[0][4] == 720
|
|
||||||
|
|
||||||
|
|
||||||
def test_pair_default_duration_is_none():
|
|
||||||
bunker = FakeBunker()
|
|
||||||
_pair(bunker) # no duration_hours
|
|
||||||
assert bunker.named("create_new_token")[0][4] is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_revoke_spire_calls_revoke_key_user():
|
|
||||||
# revoke goes through revoke_key_user (KeyUser.revokedAt) — the subject-
|
|
||||||
# level ban that cuts the whole binding, not just one token's grant.
|
|
||||||
# (Token-revoke also works post-bind since nsecbunkerd#27, but only
|
|
||||||
# severs a single token; revoke_key_user is the full-deauth call.)
|
|
||||||
bunker = FakeBunker(revoke_count=2)
|
|
||||||
count = asyncio.run(revoke_spire(_machine(), admin_client=bunker))
|
|
||||||
assert count == 2
|
|
||||||
assert bunker.named("revoke_key_user") == [("revoke_key_user", "spire-m1")]
|
|
||||||
assert not bunker.named("revoke_token") # never token-revoke
|
|
||||||
|
|
||||||
|
|
||||||
def test_revoke_spire_maps_bunker_error():
|
|
||||||
bunker = FakeBunker()
|
|
||||||
|
|
||||||
async def _boom(key_name):
|
|
||||||
raise NsecBunkerError("nope")
|
|
||||||
|
|
||||||
bunker.revoke_key_user = _boom
|
|
||||||
with pytest.raises(PairingError, match="revoke"):
|
|
||||||
asyncio.run(revoke_spire(_machine(), admin_client=bunker))
|
|
||||||
|
|
||||||
|
|
||||||
def test_policy_authorizes_required_signing_kinds():
|
|
||||||
# Kinds the spire signs as its OWN identity, confirmed against the
|
|
||||||
# consumer signing sites in bitspire#52 (2026-06-18). A missing kind is a
|
|
||||||
# silent bunker reject. 22242 = NIP-42 relay AUTH (must be bunker-signed —
|
|
||||||
# it proves control of spire_pubkey). nip04 stays out (v1 path is dead).
|
|
||||||
kinds = {r["kind"] for r in SPIRE_POLICY_RULES if r["method"] == "sign_event"}
|
|
||||||
assert {21000, 30078, 22242} <= kinds
|
|
||||||
assert "nip04_encrypt" not in SPIRE_POLICY_METHODS_NO_KIND
|
|
||||||
assert "nip04_decrypt" not in SPIRE_POLICY_METHODS_NO_KIND
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
"""
|
|
||||||
Regression: `machine_npub` is nullable (#29/m011 register-unpaired flow), so
|
|
||||||
every consumer that derives a Nostr identity from it must handle `None` rather
|
|
||||||
than crash `normalize_public_key(None)` (AttributeError: 'NoneType' has no
|
|
||||||
'startswith') or `machine_npub[:12]` (TypeError). See PR #33 — an unpaired
|
|
||||||
machine on the demo broke the platform-fee update (500) and the cassette
|
|
||||||
consumer.
|
|
||||||
|
|
||||||
These cover the pure-function guards; the DB-backed loops
|
|
||||||
(get_machine_by_atm_pubkey_hex, the super-config republish loop) are exercised
|
|
||||||
on the dev stack with an unpaired active machine.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ..bitspire import SettlementAttributionError, assert_nostr_attribution
|
|
||||||
from ..cassette_transport import build_state_d_tags_for_machines
|
|
||||||
from ..models import Machine
|
|
||||||
|
|
||||||
_PAIRED_HEX = "82341f882b6eabcbd6b1c2da5cd14df14b8e91dd0e6da41a72b78ad8f3a7d3b9"
|
|
||||||
|
|
||||||
|
|
||||||
def _machine(npub: str | None) -> Machine:
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
return Machine(
|
|
||||||
id="unpaired1",
|
|
||||||
operator_user_id="op1",
|
|
||||||
machine_npub=npub,
|
|
||||||
wallet_id="w1",
|
|
||||||
name="unpaired",
|
|
||||||
location=None,
|
|
||||||
fiat_code="EUR",
|
|
||||||
is_active=True,
|
|
||||||
created_at=now,
|
|
||||||
updated_at=now,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_attribution_rejects_unpaired_machine_cleanly():
|
|
||||||
"""An unpaired machine must raise the domain SettlementAttributionError
|
|
||||||
(which the listener records as 'rejected'), not an uncaught AttributeError
|
|
||||||
from normalize_public_key(None)."""
|
|
||||||
with pytest.raises(SettlementAttributionError):
|
|
||||||
assert_nostr_attribution(
|
|
||||||
_machine(None),
|
|
||||||
{"source": "bitspire", "nostr_sender_pubkey": _PAIRED_HEX},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_cassette_d_tags_skip_unpaired_machine():
|
|
||||||
"""build_state_d_tags_for_machines must skip unpaired machines rather than
|
|
||||||
crash _atm_hex_pubkey on a None npub — the cassette-consumer loop crash."""
|
|
||||||
tags = build_state_d_tags_for_machines([_machine(_PAIRED_HEX), _machine(None)])
|
|
||||||
assert len(tags) == 1 # only the paired machine contributes a d-tag
|
|
||||||
assert all("None" not in t for t in tags)
|
|
||||||
134
views_api.py
134
views_api.py
|
|
@ -5,18 +5,12 @@
|
||||||
# LNbits instance can never see each other's machines, settlements, or
|
# LNbits instance can never see each other's machines, settlements, or
|
||||||
# clients. The super-only platform-fee write endpoint lands in P2.
|
# clients. The super-only platform-fee write endpoint lands in P2.
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from lnbits.core.crud import get_wallet
|
from lnbits.core.crud import get_wallet
|
||||||
from lnbits.core.crud.users import get_account_by_pubkey
|
from lnbits.core.crud.users import get_account_by_pubkey
|
||||||
from lnbits.core.models import User
|
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.decorators import check_super_user, check_user_exists
|
||||||
from lnbits.utils.nostr import normalize_public_key
|
from lnbits.utils.nostr import normalize_public_key
|
||||||
|
|
||||||
|
|
@ -29,13 +23,6 @@ from .cassette_transport import (
|
||||||
publish_to_atm,
|
publish_to_atm,
|
||||||
)
|
)
|
||||||
from .fee_transport import publish_fee_config
|
from .fee_transport import publish_fee_config
|
||||||
from .pairing import (
|
|
||||||
PairResult,
|
|
||||||
PairingError,
|
|
||||||
RevokeResult,
|
|
||||||
pair_spire,
|
|
||||||
revoke_spire,
|
|
||||||
)
|
|
||||||
from .crud import (
|
from .crud import (
|
||||||
append_settlement_note,
|
append_settlement_note,
|
||||||
count_completed_legs_for_settlement,
|
count_completed_legs_for_settlement,
|
||||||
|
|
@ -68,8 +55,6 @@ from .crud import (
|
||||||
lp_is_onboarded,
|
lp_is_onboarded,
|
||||||
replace_commission_splits,
|
replace_commission_splits,
|
||||||
reset_settlement_for_retry,
|
reset_settlement_for_retry,
|
||||||
set_machine_pairing,
|
|
||||||
set_machine_unpaired,
|
|
||||||
update_cassette_config,
|
update_cassette_config,
|
||||||
update_dca_client,
|
update_dca_client,
|
||||||
update_deposit,
|
update_deposit,
|
||||||
|
|
@ -95,7 +80,6 @@ from .models import (
|
||||||
DcaPayment,
|
DcaPayment,
|
||||||
DcaSettlement,
|
DcaSettlement,
|
||||||
Machine,
|
Machine,
|
||||||
PairMachineData,
|
|
||||||
PartialDispenseData,
|
PartialDispenseData,
|
||||||
PublishCassettesPayload,
|
PublishCassettesPayload,
|
||||||
SetCommissionSplitsData,
|
SetCommissionSplitsData,
|
||||||
|
|
@ -248,7 +232,7 @@ async def _assert_super_config_cap_safe(
|
||||||
HTTPStatus.BAD_REQUEST,
|
HTTPStatus.BAD_REQUEST,
|
||||||
(
|
(
|
||||||
f"super cash-in fee {effective_in:.4f} would exceed cap "
|
f"super cash-in fee {effective_in:.4f} would exceed cap "
|
||||||
f"on machine {m.id} ({m.name or (m.machine_npub or m.id)[:12]}): "
|
f"on machine {m.id} ({m.name or m.machine_npub[:12]}): "
|
||||||
f"+ operator {op_in:.4f} = "
|
f"+ operator {op_in:.4f} = "
|
||||||
f"{total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
|
f"{total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
|
||||||
),
|
),
|
||||||
|
|
@ -258,7 +242,7 @@ async def _assert_super_config_cap_safe(
|
||||||
HTTPStatus.BAD_REQUEST,
|
HTTPStatus.BAD_REQUEST,
|
||||||
(
|
(
|
||||||
f"super cash-out fee {effective_out:.4f} would exceed cap "
|
f"super cash-out fee {effective_out:.4f} would exceed cap "
|
||||||
f"on machine {m.id} ({m.name or (m.machine_npub or m.id)[:12]}): "
|
f"on machine {m.id} ({m.name or m.machine_npub[:12]}): "
|
||||||
f"+ operator {op_out:.4f} = "
|
f"+ operator {op_out:.4f} = "
|
||||||
f"{total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
|
f"{total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}"
|
||||||
),
|
),
|
||||||
|
|
@ -275,13 +259,7 @@ async def api_create_machine(
|
||||||
data: CreateMachineData, user: User = Depends(check_user_exists)
|
data: CreateMachineData, user: User = Depends(check_user_exists)
|
||||||
) -> Machine:
|
) -> Machine:
|
||||||
await _assert_wallet_owned_by(data.wallet_id, user.id)
|
await _assert_wallet_owned_by(data.wallet_id, user.id)
|
||||||
# machine_npub is optional: blank = register UNPAIRED — the bunker mints
|
await _assert_no_pubkey_collision(data.machine_npub)
|
||||||
# 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(
|
await _assert_machine_fee_cap_safe(
|
||||||
data.operator_cash_in_fee_fraction,
|
data.operator_cash_in_fee_fraction,
|
||||||
data.operator_cash_out_fee_fraction,
|
data.operator_cash_out_fee_fraction,
|
||||||
|
|
@ -290,98 +268,10 @@ async def api_create_machine(
|
||||||
# Layer 2 (#39): publish initial fee config to the ATM so it can
|
# Layer 2 (#39): publish initial fee config to the ATM so it can
|
||||||
# unblock past its `awaiting-fees` maintenance gate. Soft-fails on
|
# unblock past its `awaiting-fees` maintenance gate. Soft-fails on
|
||||||
# transport errors — machine creation has already succeeded.
|
# 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()
|
super_config = await get_super_config()
|
||||||
if super_config is not None:
|
if super_config is not None:
|
||||||
paired = await _machine_owned_by(machine_id, user.id)
|
await publish_fee_config(machine, super_config, user.id)
|
||||||
await publish_fee_config(paired, super_config, user.id)
|
return machine
|
||||||
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])
|
@spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine])
|
||||||
|
|
@ -1081,12 +971,6 @@ async def api_update_super_config(
|
||||||
)
|
)
|
||||||
if super_fractions_changed:
|
if super_fractions_changed:
|
||||||
for machine in await list_all_active_machines():
|
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)
|
await publish_fee_config(machine, config, machine.operator_user_id)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
@ -1153,14 +1037,6 @@ async def api_publish_machine_cassettes(
|
||||||
500 — anything else from the publish path
|
500 — anything else from the publish path
|
||||||
"""
|
"""
|
||||||
machine = await _machine_owned_by(machine_id, user.id)
|
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 = await list_cassette_configs_for_machine(machine_id)
|
||||||
existing_positions = {row.position for row in existing}
|
existing_positions = {row.position for row in existing}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue