From 73bd274979e879d05e5bdc8217de5e1039ed8066 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 21 Jun 2026 12:31:55 +0200 Subject: [PATCH] feat(pairing,ui): optional machine_npub + bunker_relay override + fee decimal-input UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- migrations.py | 71 +++++++++++++++++++++++++++++++- models.py | 20 ++++++--- static/js/index.js | 43 +++++++++++++++---- templates/spirekeeper/index.html | 20 ++++----- views_api.py | 28 ++++++++++--- 5 files changed, 153 insertions(+), 29 deletions(-) diff --git a/migrations.py b/migrations.py index da8ba07..d3bcece 100644 --- a/migrations.py +++ b/migrations.py @@ -82,7 +82,7 @@ async def m001_satmachine_v2_initial(db): CREATE TABLE IF NOT EXISTS spirekeeper.dca_machines ( id TEXT PRIMARY KEY, operator_user_id TEXT NOT NULL, - machine_npub TEXT NOT NULL UNIQUE, + machine_npub TEXT UNIQUE, wallet_id TEXT NOT NULL, name TEXT, location TEXT, @@ -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)" + ) diff --git a/models.py b/models.py index f56cbcd..d779633 100644 --- a/models.py +++ b/models.py @@ -28,7 +28,11 @@ class CreateMachineData(BaseModel): not against any fee total. See aiolabs/satmachineadmin#37 / #38. """ - machine_npub: str + # Optional: blank = register the machine UNPAIRED — the bunker mints its + # identity at pairing (model A1, the normal path). Supplying an npub here + # is the development self-key path (a machine that holds its own signing + # key); see views_api.api_create_machine. + machine_npub: str | None = None wallet_id: str name: str | None = None location: str | None = None @@ -48,7 +52,7 @@ class CreateMachineData(BaseModel): class Machine(BaseModel): id: str operator_user_id: str - machine_npub: str + machine_npub: str | None # NULL until paired (or supplied on the dev self-key path) wallet_id: str name: str | None location: str | None @@ -84,11 +88,17 @@ class UpdateMachineData(BaseModel): 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; the bunker connection relay is added separately - from the lnbits bunker settings. `duration_hours` optionally time-bounds - the spire's connect token (None = non-expiring).""" + 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") diff --git a/static/js/index.js b/static/js/index.js index 52d3a3b..c9878d0 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -581,8 +581,29 @@ window.app = Vue.createApp({ this.superFeeDialog.show = true }, + // Guard the decimal-vs-percent trap shared by the super + operator fee + // forms: fees are decimal fractions (3% = 0.03), capped at 0.15. A value + // > 0.15 almost always means a percent was typed (3 instead of 0.03). + // Returns false + shows a clear toast so the operator never sees a raw 400. + _assertFeesDecimal(...fracs) { + if (fracs.some((v) => !Number.isFinite(v) || v < 0 || v > 0.15)) { + Quasar.Notify.create({ + type: 'negative', + message: 'Enter each fee as a decimal fraction (e.g. 3% = 0.03)', + caption: + 'Range 0–0.15. A value above 0.15 usually means a percent was typed (3 instead of 0.03).' + }) + return false + } + return true + }, + async submitSuperFee() { const d = this.superFeeDialog.data + if (!this._assertFeesDecimal( + Number(d.super_cash_in_fee_fraction), + Number(d.super_cash_out_fee_fraction) + )) return this.superFeeDialog.saving = true try { const {data} = await LNbits.api.request( @@ -699,13 +720,17 @@ window.app = Vue.createApp({ async submitAddMachine() { const body = this._cleanMachineForm(this.addMachineDialog.data) - if (!body.machine_npub || !body.wallet_id) { + if (!body.wallet_id) { Quasar.Notify.create({ type: 'negative', - message: 'machine_npub and wallet_id are required' + message: 'A wallet is required' }) return } + if (!this._assertFeesDecimal( + Number(body.operator_cash_in_fee_fraction) || 0, + Number(body.operator_cash_out_fee_fraction) || 0 + )) return this.addMachineDialog.saving = true try { const {data} = await LNbits.api.request('POST', MACHINES_PATH, null, body) @@ -713,7 +738,7 @@ window.app = Vue.createApp({ this.addMachineDialog.show = false Quasar.Notify.create({ type: 'positive', - message: `Machine ${data.name || data.machine_npub.slice(0, 12)} added` + message: `Machine ${data.name || (data.machine_npub || 'unpaired').slice(0, 12)} added` }) } catch (e) { this._notifyError(e, 'Failed to add machine') @@ -741,6 +766,10 @@ window.app = Vue.createApp({ async submitEditMachine() { const d = this.editMachineDialog.data + if (!this._assertFeesDecimal( + Number(d.operator_cash_in_fee_fraction) || 0, + Number(d.operator_cash_out_fee_fraction) || 0 + )) return this.editMachineDialog.saving = true try { const {data} = await LNbits.api.request( @@ -772,7 +801,7 @@ window.app = Vue.createApp({ Quasar.Dialog.create({ title: 'Delete machine?', message: - `This removes ${machine.name || machine.machine_npub.slice(0, 12)}` + + `This removes ${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}` + ' from your fleet. Existing settlements and payment history are preserved' + ' — only the machine row itself is removed. Continue?', html: true, @@ -848,7 +877,7 @@ window.app = Vue.createApp({ Quasar.Dialog.create({ title: 'Revoke spire access?', message: - `This cuts ${machine.name || machine.machine_npub.slice(0, 12)}'s` + + `This cuts ${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}'s` + ' signing access at the bunker — the spire can no longer submit' + ' cash-outs until you re-pair it. Continue?', html: true, @@ -1515,7 +1544,7 @@ window.app = Vue.createApp({ // Helpers // ----------------------------------------------------------------- shortNpub(npub) { - if (!npub) return '' + if (!npub) return 'unpaired' if (npub.length <= 16) return npub return npub.slice(0, 8) + '…' + npub.slice(-6) }, @@ -1601,7 +1630,7 @@ window.app = Vue.createApp({ _cleanMachineForm(d) { return { - machine_npub: (d.machine_npub || '').trim(), + machine_npub: (d.machine_npub || '').trim() || null, wallet_id: d.wallet_id, name: (d.name || '').trim() || null, location: (d.location || '').trim() || null, diff --git a/templates/spirekeeper/index.html b/templates/spirekeeper/index.html index 45d47a7..53c30d1 100644 --- a/templates/spirekeeper/index.html +++ b/templates/spirekeeper/index.html @@ -792,13 +792,13 @@ @@ -1627,12 +1627,12 @@ diff --git a/views_api.py b/views_api.py index 8dff07e..1a5bc62 100644 --- a/views_api.py +++ b/views_api.py @@ -248,7 +248,7 @@ async def _assert_super_config_cap_safe( HTTPStatus.BAD_REQUEST, ( f"super cash-in fee {effective_in:.4f} would exceed cap " - f"on machine {m.id} ({m.name or m.machine_npub[:12]}): " + f"on machine {m.id} ({m.name or (m.machine_npub or m.id)[:12]}): " f"+ operator {op_in:.4f} = " f"{total_in:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" ), @@ -258,7 +258,7 @@ async def _assert_super_config_cap_safe( HTTPStatus.BAD_REQUEST, ( f"super cash-out fee {effective_out:.4f} would exceed cap " - f"on machine {m.id} ({m.name or m.machine_npub[:12]}): " + f"on machine {m.id} ({m.name or (m.machine_npub or m.id)[:12]}): " f"+ operator {op_out:.4f} = " f"{total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" ), @@ -275,7 +275,13 @@ async def api_create_machine( data: CreateMachineData, user: User = Depends(check_user_exists) ) -> Machine: await _assert_wallet_owned_by(data.wallet_id, user.id) - await _assert_no_pubkey_collision(data.machine_npub) + # machine_npub is optional: blank = register UNPAIRED — the bunker mints + # the identity at pairing (the normal path). An npub supplied up front is + # the development self-key path; only then do we collision-check + publish + # a fee config now (an unpaired machine has no target yet, so it gets its + # config at pairing instead — see api_pair_machine). + if data.machine_npub: + await _assert_no_pubkey_collision(data.machine_npub) await _assert_machine_fee_cap_safe( data.operator_cash_in_fee_fraction, data.operator_cash_out_fee_fraction, @@ -284,9 +290,10 @@ async def api_create_machine( # Layer 2 (#39): publish initial fee config to the ATM so it can # unblock past its `awaiting-fees` maintenance gate. Soft-fails on # transport errors — machine creation has already succeeded. - super_config = await get_super_config() - if super_config is not None: - await publish_fee_config(machine, super_config, user.id) + 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 @@ -317,6 +324,7 @@ async def api_pair_machine( machine, relays=data.relays, admin_client=client, + bunker_relay=data.bunker_relay, duration_hours=data.duration_hours, ) except NsecBunkerNotConfiguredError as exc: @@ -337,6 +345,14 @@ async def api_pair_machine( bunker_spire_key_name=result.bunker_key_name, paired_at=datetime.now(timezone.utc), ) + # Now that the machine has a bunker identity, publish its fee config so + # the spire can clear its `awaiting-fees` gate. For a machine created + # unpaired, this is the first time it has a target. Soft-fails (mirrors + # create); pairing has already succeeded. + super_config = await get_super_config() + if super_config is not None: + paired = await _machine_owned_by(machine_id, user.id) + await publish_fee_config(paired, super_config, user.id) return result