diff --git a/migrations.py b/migrations.py index d3bcece..da8ba07 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 UNIQUE, + machine_npub TEXT NOT NULL UNIQUE, wallet_id TEXT NOT NULL, name TEXT, location TEXT, @@ -776,72 +776,3 @@ 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 d779633..f56cbcd 100644 --- a/models.py +++ b/models.py @@ -28,11 +28,7 @@ class CreateMachineData(BaseModel): not against any fee total. See aiolabs/satmachineadmin#37 / #38. """ - # 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 + machine_npub: str wallet_id: str name: str | None = None location: str | None = None @@ -52,7 +48,7 @@ class CreateMachineData(BaseModel): class Machine(BaseModel): 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 name: str | None location: str | None @@ -88,17 +84,11 @@ 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. `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).""" + 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).""" 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 c9878d0..52d3a3b 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -581,29 +581,8 @@ 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( @@ -720,17 +699,13 @@ window.app = Vue.createApp({ async submitAddMachine() { const body = this._cleanMachineForm(this.addMachineDialog.data) - if (!body.wallet_id) { + if (!body.machine_npub || !body.wallet_id) { Quasar.Notify.create({ type: 'negative', - message: 'A wallet is required' + message: 'machine_npub and wallet_id are 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) @@ -738,7 +713,7 @@ window.app = Vue.createApp({ this.addMachineDialog.show = false Quasar.Notify.create({ 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) { this._notifyError(e, 'Failed to add machine') @@ -766,10 +741,6 @@ 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( @@ -801,7 +772,7 @@ window.app = Vue.createApp({ Quasar.Dialog.create({ title: 'Delete machine?', message: - `This removes ${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}` + + `This removes ${machine.name || machine.machine_npub.slice(0, 12)}` + ' from your fleet. Existing settlements and payment history are preserved' + ' — only the machine row itself is removed. Continue?', html: true, @@ -877,7 +848,7 @@ window.app = Vue.createApp({ Quasar.Dialog.create({ title: 'Revoke spire access?', message: - `This cuts ${machine.name || (machine.machine_npub || 'unpaired').slice(0, 12)}'s` + + `This cuts ${machine.name || machine.machine_npub.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, @@ -1544,7 +1515,7 @@ window.app = Vue.createApp({ // Helpers // ----------------------------------------------------------------- shortNpub(npub) { - if (!npub) return 'unpaired' + if (!npub) return '' if (npub.length <= 16) return npub return npub.slice(0, 8) + '…' + npub.slice(-6) }, @@ -1630,7 +1601,7 @@ window.app = Vue.createApp({ _cleanMachineForm(d) { return { - machine_npub: (d.machine_npub || '').trim() || null, + machine_npub: (d.machine_npub || '').trim(), 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 53c30d1..45d47a7 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 1a5bc62..8dff07e 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 or m.id)[:12]}): " + f"on machine {m.id} ({m.name or m.machine_npub[: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 or m.id)[:12]}): " + f"on machine {m.id} ({m.name or m.machine_npub[:12]}): " f"+ operator {op_out:.4f} = " f"{total_out:.4f} > {MAX_FEE_FRACTION_PER_DIRECTION}" ), @@ -275,13 +275,7 @@ async def api_create_machine( data: CreateMachineData, user: User = Depends(check_user_exists) ) -> Machine: await _assert_wallet_owned_by(data.wallet_id, user.id) - # machine_npub is optional: blank = register UNPAIRED — the bunker mints - # the identity at pairing (the normal path). An npub supplied up front is - # the development self-key path; only then do we collision-check + publish - # a fee config now (an unpaired machine has no target yet, so it gets its - # config at pairing instead — see api_pair_machine). - if data.machine_npub: - await _assert_no_pubkey_collision(data.machine_npub) + await _assert_no_pubkey_collision(data.machine_npub) await _assert_machine_fee_cap_safe( data.operator_cash_in_fee_fraction, data.operator_cash_out_fee_fraction, @@ -290,10 +284,9 @@ async def api_create_machine( # Layer 2 (#39): publish initial fee config to the ATM so it can # unblock past its `awaiting-fees` maintenance gate. Soft-fails on # transport errors — machine creation has already succeeded. - if machine.machine_npub: - super_config = await get_super_config() - if super_config is not None: - await publish_fee_config(machine, super_config, user.id) + super_config = await get_super_config() + if super_config is not None: + await publish_fee_config(machine, super_config, user.id) return machine @@ -324,7 +317,6 @@ 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: @@ -345,14 +337,6 @@ 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