feat(pairing,ui): optional machine_npub + bunker_relay override + fee decimal-input UX
Some checks failed
ci.yml / feat(pairing,ui): optional machine_npub + bunker_relay override + fee decimal-input UX (pull_request) Failing after 0s

Three changes from the nsecbunkerd#27 bunker-pairing smoke (validated
end-to-end on the Sintra, 2026-06-21); intermingled per-file, so landed
together.

1. Optional machine_npub (model A1) — register UNPAIRED, bunker mints the
   identity at pairing:
   - machine_npub now nullable (migration m011 rebuilds dca_machines for
     sqlite / ALTER ... DROP NOT NULL for postgres; UNIQUE stays, NULLs
     don't collide so any number of unpaired machines coexist).
   - CreateMachineData.machine_npub -> str | None; create skips the
     collision-check + fee publish when blank; api_pair_machine now
     publishes the fee config after minting, so an unpaired machine clears
     its awaiting-fees gate once paired.
   - Supplying an npub up front is the DEVELOPMENT self-key path (a machine
     holding its own signing key) — available to anyone but the form field
     is explicitly marked DEVELOPMENT ONLY.
   - Frontend: npub field optional, required rule dropped, null-safe
     display (shortNpub -> "unpaired", guarded slices), empty -> null.

2. bunker_relay override on POST /machines/{id}/pair: PairMachineData gains
   bunker_relay; api_pair_machine threads it to pair_spire. Lets the seed's
   bunker:// relay differ from the relay lnbits uses to reach the bunker
   (internal docker host vs LAN/public) — needed for split-relay / dev
   deploys. Without it the smoke had to mint via a script.

3. Fees are decimal fractions, not percents: relabel super + operator fee
   inputs ("decimal fraction, 0-0.15") + a shared _assertFeesDecimal()
   guard (super/add/edit submits) so a percent typo (3 instead of 0.03)
   gets a clear toast, not a raw 400.

refs: nsecbunkerd#27/#36; aiolabs/bitspire#52; coordination smoke 2026-06-21

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-21 12:31:55 +02:00
commit 73bd274979
5 changed files with 153 additions and 29 deletions

View file

@ -82,7 +82,7 @@ async def m001_satmachine_v2_initial(db):
CREATE TABLE IF NOT EXISTS spirekeeper.dca_machines (
id TEXT PRIMARY KEY,
operator_user_id TEXT NOT NULL,
machine_npub TEXT NOT NULL UNIQUE,
machine_npub TEXT UNIQUE,
wallet_id TEXT NOT NULL,
name TEXT,
location TEXT,
@ -776,3 +776,72 @@ async def m010_add_machine_bunker_pairing(db):
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)"
)