diff --git a/CLAUDE.md b/CLAUDE.md index a1a5691..1a068ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,9 +82,9 @@ Renderer reads (Electron IPC or Vite `import.meta.env`): | Var | Required | Notes | |---|---|---| -| `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) | -| `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) | -| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:`) from spirekeeper. Carries a one-shot NIP-46 connect token + the spire signing pubkey + bunker URL. First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. Provisioning it up front is optional — an unpaired machine renders an on-screen QR-pairing wizard that scans the seed off the camera (see below). See aiolabs/bitspire#52. | +| `VITE_RELAY_URL` | no (seed-provided) | Relay both ATM and LNbits subscribe to. **Comes from the pairing seed** (aiolabs/bitspire#70); set this only as an override — it WINS over the seed via env-first precedence. Dev override: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) | +| `VITE_LNBITS_SERVER_PUBKEY` | no (seed-provided) | 64-char hex transport pubkey. **Comes from the seed's `lnbits_npub`** (#70); env override only. LNbits prints it on startup (`docker logs lnbits \| grep 'Public key (share this)'`) | +| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:`) from spirekeeper. Carries the relay(s), the LNbits transport pubkey (`lnbits_npub`), the spire signing pubkey (`spire_npub`), and a one-shot NIP-46 connect token (#70 slimmed the shape). First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. Provisioning it up front is optional — an unpaired machine renders an on-screen QR-pairing wizard that scans the seed off the camera (see below). See aiolabs/bitspire#52. | | `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. | | `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands | diff --git a/apps/machine/.env.example b/apps/machine/.env.example index e691e46..66541dc 100644 --- a/apps/machine/.env.example +++ b/apps/machine/.env.example @@ -19,11 +19,15 @@ VITE_LAMASSU_FIAT_CODE=USD # VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]' # ============================================================================= -# LNbits Connection (Required) — nostr-native-transport +# LNbits Connection (dev override — normally seed-provided) — nostr-native-transport # ============================================================================= +# On a real machine the pairing SEED (VITE_SPIRE_SEED) carries the relay AND the +# server pubkey (aiolabs/bitspire#70), so leave both blank there. Set them here +# only for browser dev without a seed/bunker — they WIN over the seed. -# Nostr relay WebSocket URL — relay LNbits is subscribed to. -VITE_RELAY_URL=ws://localhost:7777 +# Nostr relay WebSocket URL. Dev stack uses LNbits's bundled nostrrelay: +# VITE_RELAY_URL=ws://localhost:5001/nostrrelay/test +VITE_RELAY_URL= # LNbits nostr-transport server pubkey (hex, 64 chars). # Printed by the LNbits server on startup: diff --git a/apps/machine/electron/__tests__/state-store-bunker.test.ts b/apps/machine/electron/__tests__/state-store-bunker.test.ts new file mode 100644 index 0000000..a668059 --- /dev/null +++ b/apps/machine/electron/__tests__/state-store-bunker.test.ts @@ -0,0 +1,69 @@ +/** + * Tests for bunker-binding persistence in state-store (aiolabs/bitspire#52, + * transport config added in #70). + * + * Validates the round-trip of the binding singleton, including the v11→v12 + * transport columns (relays JSON + lnbits_server_pubkey) and their absence on + * a pre-#70 binding. + * + * Uses an in-memory SQLite database — fresh per test, no on-disk artifacts. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + clearBunkerBinding, + closeDatabase, + getBunkerBinding, + initDatabase, + saveBunkerBinding, + type StoredBunkerBinding, +} from '../state-store.js' + +const BASE: StoredBunkerBinding = { + clientSecretHex: 'aa'.repeat(32), + spirePubkey: 'bb'.repeat(32), + bunkerUrl: 'bunker://bb?relay=wss%3A%2F%2Fr%2F&secret=deadbeef', + seedFingerprint: 'cc'.repeat(32), + pairedAt: 1_780_000_000, +} + +beforeEach(() => { + initDatabase(':memory:') +}) +afterEach(() => { + closeDatabase() +}) + +describe('bunker binding persistence', () => { + it('round-trips a binding carrying transport config (#70)', () => { + const binding: StoredBunkerBinding = { + ...BASE, + relays: ['wss://one.relay/', 'wss://two.relay/'], + lnbitsServerPubkey: 'dd'.repeat(32), + } + saveBunkerBinding(binding) + expect(getBunkerBinding()).toEqual(binding) + }) + + it('round-trips a pre-#70 binding (no transport config) as undefined fields', () => { + saveBunkerBinding(BASE) + const got = getBunkerBinding() + expect(got).toEqual(BASE) + expect(got?.relays).toBeUndefined() + expect(got?.lnbitsServerPubkey).toBeUndefined() + }) + + it('upserts transport config in place (re-pair overwrites)', () => { + saveBunkerBinding({ ...BASE, relays: ['wss://old/'], lnbitsServerPubkey: 'ee'.repeat(32) }) + saveBunkerBinding({ ...BASE, relays: ['wss://new/'], lnbitsServerPubkey: 'ff'.repeat(32) }) + const got = getBunkerBinding() + expect(got?.relays).toEqual(['wss://new/']) + expect(got?.lnbitsServerPubkey).toBe('ff'.repeat(32)) + }) + + it('returns null after clear', () => { + saveBunkerBinding(BASE) + clearBunkerBinding() + expect(getBunkerBinding()).toBeNull() + }) +}) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 516f32a..9107951 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -27,6 +27,7 @@ import { getBootstrapPublishedAt, markBootstrapPublished, resetBootstrapGate, + resetForRepair, applyOperatorCassettesConfig, getFeeConfig, getLastKnownFeeConfigCreatedAt, @@ -278,8 +279,11 @@ ipcMain.handle('watchdog:pong', () => { // pragma: allowlist secret end ipcMain.handle('get-config', () => { return { - // LNbits nostr-transport connection (public info only) - relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777', + // LNbits nostr-transport connection (public info only). Empty when + // unprovisioned — the renderer then falls through to the pairing seed's + // relay (aiolabs/bitspire#70). A non-empty default here would win via the + // env-first precedence and override the seed. + relayUrl: process.env.VITE_RELAY_URL || '', lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '', appId: process.env.VITE_APP_ID || '', @@ -352,6 +356,9 @@ ipcMain.handle('state:clear-bunker-binding', (): void => { ipcMain.handle('state:reset-bootstrap-gate', (): void => { resetBootstrapGate() }) +ipcMain.handle('state:reset-for-repair', (): void => { + resetForRepair() +}) // QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a // spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index f1320fb..a811eab 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -17,10 +17,6 @@ export interface RuntimeConfig { relayUrl: string /** LNbits nostr-transport server pubkey (hex, 64 chars). */ lnbitsServerPubkey: string - /** Legacy LP fields — retained until 3d removes the LP backend. Optional. */ - lightningPubPubkey?: string - lightningPubApiUrl?: string - extensionApiUrl?: string appId: string machineModel: string fiatCode: string @@ -51,6 +47,10 @@ export interface BunkerBindingRecord { bunkerUrl: string seedFingerprint: string pairedAt: number + /** LNbits transport relays from the seed (#70); absent on pre-#70 bindings. */ + relays?: string[] + /** LNbits nostr-transport server pubkey (hex) from the seed (#70). */ + lnbitsServerPubkey?: string } /** @@ -118,6 +118,7 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('state:save-bunker-binding', binding), clearBunkerBinding: (): Promise => ipcRenderer.invoke('state:clear-bunker-binding'), resetBootstrapGate: (): Promise => ipcRenderer.invoke('state:reset-bootstrap-gate'), + resetForRepair: (): Promise => ipcRenderer.invoke('state:reset-for-repair'), // QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed, // then relaunch so the normal boot flow pairs it. @@ -239,6 +240,7 @@ declare global { saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise resetBootstrapGate: () => Promise + resetForRepair: () => Promise saveSpireSeed: (seed: string) => Promise relaunchApp: () => Promise applyOperatorCassettesConfig: ( diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index d9282ac..a274565 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -15,7 +15,7 @@ import fs from 'node:fs' let db: Database.Database | null = null -const SCHEMA_VERSION = '11' +const SCHEMA_VERSION = '12' function getDbPath(): string { const prodDir = '/var/lib/bitspire' @@ -121,7 +121,9 @@ export function initDatabase(dbPath?: string): void { spire_pubkey TEXT NOT NULL, bunker_url TEXT NOT NULL, seed_fingerprint TEXT NOT NULL, - paired_at INTEGER NOT NULL + paired_at INTEGER NOT NULL, + relays TEXT, + lnbits_server_pubkey TEXT ); `) @@ -352,6 +354,21 @@ export function initDatabase(dbPath?: string): void { `) db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version') console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)') + existing.value = '11' + } + + if (existing && existing.value === '11') { + // Migration v11 → v12: carry the LNbits transport config in the binding + // (aiolabs/bitspire#70). relays (JSON array) + lnbits_server_pubkey let a + // paired machine reach the backend from the pairing alone — no VITE_RELAY_URL + // / VITE_LNBITS_SERVER_PUBKEY provisioning. Nullable: bindings written before + // this (the seed didn't carry them) resume fine and fall back to env. + db.exec(` + ALTER TABLE bunker_binding ADD COLUMN relays TEXT; + ALTER TABLE bunker_binding ADD COLUMN lnbits_server_pubkey TEXT; + `) + db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('12', 'schema_version') + console.log('[StateStore] Migrated schema v11 → v12 (bunker_binding transport config)') } // Defensive: a fresh install at SCHEMA_VERSION skips all migrations. @@ -428,6 +445,14 @@ export interface StoredBunkerBinding { seedFingerprint: string /** Unix seconds when the pairing was redeemed. */ pairedAt: number + /** + * LNbits transport relays from the pairing seed (aiolabs/bitspire#70). Lets a + * resumed (seedless) boot reach the backend without env provisioning. + * Undefined for bindings written before the seed carried them. + */ + relays?: string[] + /** LNbits nostr-transport server pubkey (hex) from the seed (#70). */ + lnbitsServerPubkey?: string } /** Read the persisted bunker binding, or null if the ATM is unpaired. */ @@ -435,7 +460,7 @@ export function getBunkerBinding(): StoredBunkerBinding | null { if (!db) throw new Error('Database not initialized') const row = db .prepare( - 'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at FROM bunker_binding WHERE id = 1' + 'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey FROM bunker_binding WHERE id = 1' ) .get() as | { @@ -444,6 +469,8 @@ export function getBunkerBinding(): StoredBunkerBinding | null { bunker_url: string seed_fingerprint: string paired_at: number + relays: string | null + lnbits_server_pubkey: string | null } | undefined if (!row) return null @@ -453,27 +480,47 @@ export function getBunkerBinding(): StoredBunkerBinding | null { bunkerUrl: row.bunker_url, seedFingerprint: row.seed_fingerprint, pairedAt: row.paired_at, + relays: parseRelaysColumn(row.relays), + lnbitsServerPubkey: row.lnbits_server_pubkey ?? undefined, } } +/** Decode the JSON-array `relays` column, tolerating null/legacy/garbage. */ +function parseRelaysColumn(value: string | null): string[] | undefined { + if (!value) return undefined + try { + const parsed = JSON.parse(value) + if (Array.isArray(parsed) && parsed.every((r) => typeof r === 'string')) { + return parsed as string[] + } + } catch { + // fall through + } + return undefined +} + /** Upsert the bunker binding after a successful (re-)pairing. */ export function saveBunkerBinding(binding: StoredBunkerBinding): void { if (!db) throw new Error('Database not initialized') db.prepare( - `INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at) - VALUES (1, ?, ?, ?, ?, ?) + `INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey) + VALUES (1, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET - client_secret_hex = excluded.client_secret_hex, - spire_pubkey = excluded.spire_pubkey, - bunker_url = excluded.bunker_url, - seed_fingerprint = excluded.seed_fingerprint, - paired_at = excluded.paired_at` + client_secret_hex = excluded.client_secret_hex, + spire_pubkey = excluded.spire_pubkey, + bunker_url = excluded.bunker_url, + seed_fingerprint = excluded.seed_fingerprint, + paired_at = excluded.paired_at, + relays = excluded.relays, + lnbits_server_pubkey = excluded.lnbits_server_pubkey` ).run( binding.clientSecretHex, binding.spirePubkey, binding.bunkerUrl, binding.seedFingerprint, - binding.pairedAt + binding.pairedAt, + binding.relays ? JSON.stringify(binding.relays) : null, + binding.lnbitsServerPubkey ?? null ) } @@ -493,6 +540,32 @@ export function resetBootstrapGate(): void { db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt') } +/** + * Wipe operator-scoped CONFIG/TRUST state on a re-pair to a new operator/backend, + * so stale policy from the previous pairing can't linger or silently reject the + * new operator's config. + * + * Clears the fee config and resets BOTH replay watermarks to 0. The watermark + * reset is the load-bearing part: without it, a new backend whose first config + * event has a lower `created_at` than the old operator's last event is silently + * dropped as a replay — the exact remnant trap where re-pairing a long-lived + * install to a fresh backend appears to "work" but never picks up new config. + * + * Deliberately does NOT touch cassettes / cashbox / transactions: those track + * PHYSICAL cash, which survives an operator handover. A full wipe (decommission + * or a truly-fresh test) is the factory-reset path, not this. + */ +export function resetForRepair(): void { + if (!db) throw new Error('Database not initialized') + const database = db + database.transaction(() => { + database.prepare('DELETE FROM fee_config').run() + const setWatermark = database.prepare('UPDATE meta SET value = ? WHERE key = ?') + setWatermark.run('0', 'lastKnownFeeConfigCreatedAt') + setWatermark.run('0', 'lastKnownConfigCreatedAt') + })() +} + export type OperatorCassettesPayload = { positions: Record } diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index f28b639..2b89eb7 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -103,10 +103,16 @@ onMounted(async () => { try { const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client') const { resolveSigner } = await import('@/services/signer-resolver') - const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL // Best-effort: resolve a signer (bunker resume / pairing, or dev nsec). // If the ATM isn't paired yet, skip the beacon rather than fail the screen. - const signer = await resolveSigner({ allowEphemeral: true }).catch(() => null) + const resolved = await resolveSigner({ allowEphemeral: true }).catch(() => null) + const signer = resolved?.signer ?? null + // Same env → pairing-seed precedence as lightning.ts: on a blank-.env + // seed-driven machine the relay comes from the pairing transport, not env. + const relayUrl = + config?.relayUrl || + import.meta.env.VITE_RELAY_URL || + resolved?.transport?.relays?.[0] if (signer && relayUrl) { const client = new NostrClient({ relays: [{ url: relayUrl }], signer }) await client.connect() diff --git a/apps/machine/src/components/PairingWizard.vue b/apps/machine/src/components/PairingWizard.vue index f34409e..7057730 100644 --- a/apps/machine/src/components/PairingWizard.vue +++ b/apps/machine/src/components/PairingWizard.vue @@ -10,15 +10,18 @@ * Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be * offered later without changing this view. */ -import { onMounted, onUnmounted, ref, shallowRef } from 'vue' +import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue' import { availablePairingSources, ingestScannedSeed, + parseScannedSeed, + testRelay, type PairingSource, + type RelayTestResult, type StopCapture, } from '@/services/pairing' -type Phase = 'probing' | 'scanning' | 'no-source' | 'pairing' | 'error' +type Phase = 'probing' | 'scanning' | 'review' | 'no-source' | 'pairing' | 'error' const phase = ref('probing') const errorMessage = ref('') @@ -28,6 +31,19 @@ const sources = shallowRef([]) const activeSource = shallowRef(null) let stopCapture: StopCapture | null = null +// Review-step state: the scanned-but-not-yet-committed seed + relay tests. +const scannedRaw = ref('') +const previewSpire = ref('') +const previewRelays = ref([]) +type RelayState = { status: 'idle' | 'testing' | 'done'; result?: RelayTestResult } +const relayTests = ref>({}) +const testingRelays = ref(false) +const committing = ref(false) + +const anyRelayFailed = computed(() => + Object.values(relayTests.value).some((s) => s.status === 'done' && s.result != null && !s.result.ok), +) + async function startWith(source: PairingSource) { await teardown() activeSource.value = source @@ -50,18 +66,58 @@ let handling = false async function handleScan(raw: string) { if (handling) return handling = true - const result = await ingestScannedSeed(raw) - if (result.ok) { - // saveSpireSeed succeeded; relaunch is in flight — hold a friendly screen. - phase.value = 'pairing' + // Validate only — don't commit yet. Show a review step with the decoded + // relay + a "test relay" button so a well-formed but unreachable relay is + // caught before we relaunch into a pairing crash-loop (aiolabs/bitspire#70). + const preview = parseScannedSeed(raw) + if (preview.ok) { + await teardown() // camera off during review + scannedRaw.value = raw.trim() + previewSpire.value = preview.spirePubkey + previewRelays.value = preview.relays + relayTests.value = Object.fromEntries(preview.relays.map((r) => [r, { status: 'idle' }])) + errorMessage.value = '' + phase.value = 'review' return } - // Reject non-seed scans (a stray QR) and resume scanning. - console.warn('[Pairing] rejected scan:', result.reason, result.message) - errorMessage.value = - result.reason === 'invalid-seed' - ? 'That code is not a pairing code. Show the operator pairing QR.' - : result.message + // Reject non-seed / malformed scans (a stray QR, a corrupted relay) and resume. + console.warn('[Pairing] rejected scan:', preview.reason, preview.message) + errorMessage.value = 'That code is not a valid pairing code. Show the operator pairing QR.' + handling = false + if (activeSource.value) await startWith(activeSource.value) +} + +/** Probe every relay in the scanned seed and record reachability. */ +async function testRelays() { + testingRelays.value = true + await Promise.all( + previewRelays.value.map(async (url) => { + relayTests.value[url] = { status: 'testing' } + const result = await testRelay(url) + relayTests.value[url] = { status: 'done', result } + }), + ) + testingRelays.value = false +} + +/** Commit the reviewed seed: persist + relaunch into the real pairing path. */ +async function confirmPair() { + committing.value = true + const result = await ingestScannedSeed(scannedRaw.value) + if (result.ok) { + phase.value = 'pairing' // relaunch in flight + return + } + committing.value = false + errorMessage.value = result.message + phase.value = 'error' +} + +/** Discard the scan and go back to scanning. */ +async function rescan() { + scannedRaw.value = '' + previewRelays.value = [] + relayTests.value = {} handling = false if (activeSource.value) await startWith(activeSource.value) } @@ -101,7 +157,17 @@ onUnmounted(teardown) class="relative overflow-hidden rounded-2xl border-4 border-primary/40 bg-black" style="width: min(80vw, 28rem); aspect-ratio: 1 / 1" > - + +
@@ -121,6 +187,67 @@ onUnmounted(teardown)

Pairing accepted — restarting…

+ +
+

+ Pairing code scanned. Test the relay, then pair. +

+
+

Spire

+

{{ previewSpire.slice(0, 16) }}…

+

Relay(s)

+
    +
  • + {{ url }} + + + + +
  • +
+
+ +
+ + + +
+ +

+ A relay looks unreachable from this machine — pairing will fail unless it can reach the + relay. Check the URL/network, or rescan a corrected code. +

+
+

{ const defaults: LightningConfig = { - relayUrl: 'ws://localhost:7777', + // Empty when unset (not the dev relay) so initializeLightningServices can + // tell "operator gave us a relay" from "fall back to the pairing seed". See + // aiolabs/bitspire#70 and DEV_DEFAULT_RELAY. + relayUrl: '', appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID operatorPubkeys: [], lnbitsServerPubkey: '', @@ -97,6 +100,10 @@ async function loadLightningConfig(): Promise { // Config is loaded async now - will be set in initializeLightningServices let CONFIG: LightningConfig +/** Dev-only relay used when neither env nor the pairing supplies one. Matches + * the dev stack — LNbits's bundled nostrrelay (no separate strfry container). */ +const DEV_DEFAULT_RELAY = 'ws://localhost:5001/nostrrelay/test' + /** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime. * Sessions are normally cleaned up by the state machine on idle transition. * This is a safety net in case the state machine doesn't clean up properly. */ @@ -395,9 +402,6 @@ export async function initializeLightningServices(options?: { // Load configuration (async for Electron runtime config) CONFIG = await loadLightningConfig() - console.log('[Lightning] Relay URL:', CONFIG.relayUrl) - console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)') - // Resolve the signing identity BEFORE validating the LNbits transport // config. An unpaired machine must reach the QR-pairing wizard regardless // of relay/server-pubkey provisioning — pairing is what provides those — so @@ -410,17 +414,53 @@ export async function initializeLightningServices(options?: { // transport key; the operator's nsecbunkerd holds the signing key); in dev // it falls back to an in-process LocalSigner. The Phase-A Signer seam means // nothing downstream changes. See aiolabs/bitspire#52. - const signer: Signer = await resolveSigner({ allowEphemeral: !options?.strict }) + const { signer, transport } = await resolveSigner({ allowEphemeral: !options?.strict }) console.log('[Lightning] ATM pubkey:', signer.pubkey) - // Strict mode: validate config is production-ready (no localhost). + // Resolve the effective LNbits transport. Precedence: explicit env wins (dev + // + operator override), else the pairing (seed/binding) supplies it (#70) so + // a blank-.env paired machine reaches the backend from the seed alone, else a + // dev-only localhost fallback. CONFIG is mutated to the resolved values so + // downstream (and the exported CONFIG) see a single source of truth. + const envRelay = CONFIG.relayUrl + const envPubkey = CONFIG.lnbitsServerPubkey + const relays: string[] = envRelay + ? [envRelay] + : transport && transport.relays.length > 0 + ? transport.relays + : [DEV_DEFAULT_RELAY] + CONFIG.relayUrl = relays[0]! + CONFIG.lnbitsServerPubkey = envPubkey || transport?.lnbitsServerPubkey || '' + console.log( + '[Lightning] Relay(s):', + relays.join(', '), + envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)', + ) + console.log( + '[Lightning] LNbits server pubkey:', + CONFIG.lnbitsServerPubkey || '(not configured)', + envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : '', + ) + // Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS + // (env). An empty set disables the fees/operator-config services → the machine + // sits at "awaiting configuration" — so log it loudly rather than fail silent. + // (aiolabs/bitspire#70 P1 will source this from LNbits over the transport.) + console.log( + '[Lightning] Operator pubkey(s):', + CONFIG.operatorPubkeys.length + ? CONFIG.operatorPubkeys.join(', ') + ' (env)' + : '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)', + ) + + // Strict mode: validate the RESOLVED config is production-ready (no + // localhost). Values may come from env or the pairing seed (#70). if (options?.strict) { const errors: string[] = [] if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) { - errors.push('VITE_RELAY_URL contains localhost') + errors.push('relay resolves to localhost (VITE_RELAY_URL / seed relays)') } if (!CONFIG.lnbitsServerPubkey) { - errors.push('VITE_LNBITS_SERVER_PUBKEY is not set') + errors.push('no LNbits server pubkey (VITE_LNBITS_SERVER_PUBKEY / seed lnbits_npub)') } if (errors.length > 0) { throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- ')) @@ -429,17 +469,17 @@ export async function initializeLightningServices(options?: { // Validate required configuration. Reached only for a paired machine (an // unpaired one threw NoPairingError above) — it needs the LNbits server - // pubkey to talk to the transport. + // pubkey to talk to the transport, from either env or the pairing seed. if (!CONFIG.lnbitsServerPubkey) { throw new Error( - '[Lightning] VITE_LNBITS_SERVER_PUBKEY is required. ' + - 'Get it from: docker logs lnbits | grep nostr_transport pubkey', + '[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' + + 'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).', ) } // Create Nostr client const nostrClient = new NostrClient({ - relays: [{ url: CONFIG.relayUrl }], + relays: relays.map((url) => ({ url })), signer, }) @@ -449,7 +489,7 @@ export async function initializeLightningServices(options?: { // LNbits nostr-transport client. const lnbits = new LnbitsClient({ serverPubkey: CONFIG.lnbitsServerPubkey, - relays: [CONFIG.relayUrl], + relays, }) lnbits.initialize(nostrClient, signer) _lnbitsRef = lnbits @@ -472,7 +512,7 @@ export async function initializeLightningServices(options?: { nostrClient, signer, operatorPubkey: CONFIG.operatorPubkeys, - relays: [CONFIG.relayUrl], + relays, }) // Callbacks for events diff --git a/apps/machine/src/services/pairing/__tests__/ingest.test.ts b/apps/machine/src/services/pairing/__tests__/ingest.test.ts index 7392de3..f6c069e 100644 --- a/apps/machine/src/services/pairing/__tests__/ingest.test.ts +++ b/apps/machine/src/services/pairing/__tests__/ingest.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest' import { ingestScannedSeed } from '../ingest' import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client' +import { npubEncode } from 'nostr-tools/nip19' /** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */ function makeSeed(json: unknown): string { @@ -15,9 +16,9 @@ function makeSeed(json: unknown): string { const SPIRE_PUBKEY = 'a'.repeat(64) const VALID_SEED = makeSeed({ v: 1, - spire_npub: 'npub1example', - spire_pubkey: SPIRE_PUBKEY, - bunker_url: `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef`, + spire_npub: npubEncode(SPIRE_PUBKEY), + lnbits_npub: npubEncode('b'.repeat(64)), + bunker_secret: 'deadbeef', relays: ['wss://events.relay/'], }) diff --git a/apps/machine/src/services/pairing/index.ts b/apps/machine/src/services/pairing/index.ts index 67e3ade..faaad95 100644 --- a/apps/machine/src/services/pairing/index.ts +++ b/apps/machine/src/services/pairing/index.ts @@ -14,8 +14,10 @@ import type { PairingSource } from './types' export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types' export { QrPairingSource } from './qr-source' export { NfcPairingSource } from './nfc-source' -export { ingestScannedSeed } from './ingest' -export type { IngestResult } from './ingest' +export { ingestScannedSeed, parseScannedSeed } from './ingest' +export type { IngestResult, SeedPreview } from './ingest' +export { testRelay } from './relay-test' +export type { RelayTestResult } from './relay-test' /** All sources in preference order, regardless of availability. */ export function allPairingSources(): PairingSource[] { diff --git a/apps/machine/src/services/pairing/ingest.ts b/apps/machine/src/services/pairing/ingest.ts index a758f1e..dc22ad2 100644 --- a/apps/machine/src/services/pairing/ingest.ts +++ b/apps/machine/src/services/pairing/ingest.ts @@ -21,6 +21,37 @@ export type IngestResult = | { ok: true; spirePubkey: string; fingerprint: string; relays: string[] } | { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string } +export type SeedPreview = + | { ok: true; spirePubkey: string; fingerprint: string; relays: string[] } + | { ok: false; reason: 'invalid-seed'; message: string } + +/** + * Validate-only: parse a scanned payload as a spire-seed WITHOUT persisting or + * relaunching. The wizard uses this to show a review step (decoded relay + a + * "test relay" button) before committing, so a well-formed but unreachable + * relay is caught before the machine relaunches into a pairing crash-loop. + * `parseSpireSeed` already rejects a malformed relay (e.g. a QR misread of + * `ws://` → `As://`); this surfaces that as an invalid-seed rejection. + */ +export function parseScannedSeed(raw: string): SeedPreview { + const trimmed = (raw || '').trim() + try { + const seed = parseSpireSeed(trimmed) + return { + ok: true, + spirePubkey: seed.spirePubkey, + fingerprint: seedFingerprint(trimmed), + relays: seed.relays, + } + } catch (e) { + return { + ok: false, + reason: 'invalid-seed', + message: e instanceof Error ? e.message : 'Not a valid pairing code', + } + } +} + export async function ingestScannedSeed(raw: string): Promise { const trimmed = (raw || '').trim() diff --git a/apps/machine/src/services/pairing/relay-test.ts b/apps/machine/src/services/pairing/relay-test.ts new file mode 100644 index 0000000..338c983 --- /dev/null +++ b/apps/machine/src/services/pairing/relay-test.ts @@ -0,0 +1,69 @@ +/** + * Relay reachability probe for the pairing wizard (aiolabs/bitspire#70). + * + * `parseSpireSeed` catches a MALFORMED relay (e.g. a QR misread of `ws://` into + * `As://`), but a well-formed-yet-unreachable relay — `ws://localhost:…` baked + * into a seed for a remote machine, a wrong LAN IP, or a relay that's simply + * down — still parses fine and would only fail later as a NIP-46 connect + * crash-loop. This opens a WebSocket to the relay (and sends a NIP-01 REQ so a + * real relay answers) so the operator can confirm reachability on-machine, + * before committing the pairing. + */ + +export interface RelayTestResult { + url: string + ok: boolean + /** Round-trip time to open (ms), when reachable. */ + ms?: number + /** True when the relay answered our REQ — i.e. it's actually a nostr relay. */ + answered?: boolean + error?: string +} + +/** Open a WebSocket to `url` and report whether it connects within `timeoutMs`. */ +export function testRelay(url: string, timeoutMs = 6000): Promise { + return new Promise((resolve) => { + const start = Date.now() + let ws: WebSocket | null = null + let settled = false + + const finish = (r: Omit): void => { + if (settled) return + settled = true + clearTimeout(timer) + try { + ws?.close() + } catch { + /* already closing */ + } + resolve({ url, ...r }) + } + + const timer = setTimeout( + () => finish({ ok: false, error: `timed out after ${timeoutMs}ms` }), + timeoutMs, + ) + + try { + ws = new WebSocket(url) + } catch (e) { + finish({ ok: false, error: e instanceof Error ? e.message : 'invalid relay URL' }) + return + } + + ws.onopen = () => { + // Connected. Probe it as a nostr relay; a genuine relay replies (EOSE / + // notice). If it stays silent we still count the open as reachable. + try { + ws?.send(JSON.stringify(['REQ', 'bitspire-relay-test', { limit: 0 }])) + } catch { + /* send failed, but the socket opened → still reachable */ + } + const graceMs = Math.min(600, timeoutMs) + setTimeout(() => finish({ ok: true, ms: Date.now() - start, answered: false }), graceMs) + } + ws.onmessage = () => finish({ ok: true, ms: Date.now() - start, answered: true }) + ws.onerror = () => + finish({ ok: false, error: 'connection failed (unreachable or not a relay)' }) + }) +} diff --git a/apps/machine/src/services/signer-resolver.ts b/apps/machine/src/services/signer-resolver.ts index 44aa4b5..f830073 100644 --- a/apps/machine/src/services/signer-resolver.ts +++ b/apps/machine/src/services/signer-resolver.ts @@ -26,6 +26,7 @@ import { parseSpireSeed, seedFingerprint, type Signer, + type SpireSeed, } from '@bitSpire/nostr-client' import type { BunkerBindingRecord } from '@/types/electron' @@ -50,6 +51,25 @@ export interface ResolveSignerOptions { allowEphemeral: boolean } +/** LNbits transport config carried by the pairing (aiolabs/bitspire#70). */ +export interface TransportConfig { + /** LNbits transport relays (kind-21000 / 30078). */ + relays: string[] + /** LNbits nostr-transport server pubkey (hex). */ + lnbitsServerPubkey: string +} + +export interface ResolvedSigner { + signer: Signer + /** + * Transport config sourced from the pairing — the seed on a fresh pair / + * seeded resume, the binding on a seedless resume. Null when unavailable (an + * ephemeral dev signer, or a pre-#70 binding that never stored it); the + * caller then falls back to env provisioning. + */ + transport: TransportConfig | null +} + interface PairingState { spireSeed: string binding: BunkerBindingRecord | null @@ -64,20 +84,55 @@ async function loadPairingState(): Promise { return { spireSeed: (import.meta.env.VITE_SPIRE_SEED as string | undefined) || '', binding: null } } -export async function resolveSigner(opts: ResolveSignerOptions): Promise { +export async function resolveSigner(opts: ResolveSignerOptions): Promise { const { spireSeed, binding } = await loadPairingState() + const resume = (b: BunkerBindingRecord): Promise => + resumeFromBinding({ + clientSecretHex: b.clientSecretHex, + spirePubkey: b.spirePubkey, + bunkerUrl: b.bunkerUrl, + }) + + // Transport config from a binding — present only when the pairing seed + // carried it (post-#70) and it was persisted. Null on pre-#70 bindings. + const transportFromBinding = (b: BunkerBindingRecord): TransportConfig | null => + b.relays && b.relays.length > 0 && b.lnbitsServerPubkey + ? { relays: b.relays, lnbitsServerPubkey: b.lnbitsServerPubkey } + : null + + const transportFromSeed = (s: SpireSeed): TransportConfig => ({ + relays: s.relays, + lnbitsServerPubkey: s.lnbitsServerPubkey, + }) + if (spireSeed) { - const seed = parseSpireSeed(spireSeed) - const fingerprint = seedFingerprint(spireSeed) + let seed: SpireSeed + let fingerprint: string + try { + seed = parseSpireSeed(spireSeed) + fingerprint = seedFingerprint(spireSeed) + } catch (err) { + // A stored seed we can't parse — e.g. a legacy-shape seed left in .env + // after the seed format changed (bitspire-#70). If we already hold a + // binding it's authoritative (server-persistent), so resume from it + // rather than bricking a paired machine on the next boot. With no + // binding the seed is our only pairing input, so fail closed. + if (binding) { + console.warn( + '[Signer] Stored spire seed is unparseable; resuming from existing binding:', + (err as Error).message, + ) + return { signer: await resume(binding), transport: transportFromBinding(binding) } + } + throw err + } if (binding && binding.seedFingerprint === fingerprint) { console.log('[Signer] Resuming bunker session for spire', seed.spirePubkey) - return resumeFromBinding({ - clientSecretHex: binding.clientSecretHex, - spirePubkey: binding.spirePubkey, - bunkerUrl: binding.bunkerUrl, - }) + // Seed present + parsed → prefer its (fresh) transport config over the + // binding's, which may predate the seed carrying transport (pre-#70). + return { signer: await resume(binding), transport: transportFromSeed(seed) } } // First pair or re-pair: redeem the one-shot connect secret. @@ -89,27 +144,36 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise clientSecretHex: transport.secretHex, }) if (isElectron && window.electronAPI) { + // Re-pair (a NEW seed replacing a prior binding) → wipe the previous + // operator's config/trust state (fee config + replay watermarks) so it + // can't linger or silently replay-block the new operator's config. A + // first pair (no prior binding) has nothing to reset. Cash accounting is + // preserved — see resetForRepair; a full wipe is the factory-reset path. + if (binding) { + console.log('[Signer] Re-pair (new seed fingerprint) — clearing prior operator config state') + await window.electronAPI.resetForRepair() + } + // Persist the seed's transport config alongside the binding so a later + // seedless resume still reaches the backend without env provisioning. await window.electronAPI.saveBunkerBinding({ clientSecretHex: transport.secretHex, spirePubkey: seed.spirePubkey, bunkerUrl: seed.bunkerUrl, seedFingerprint: fingerprint, pairedAt: Math.floor(Date.now() / 1000), + relays: seed.relays, + lnbitsServerPubkey: seed.lnbitsServerPubkey, }) // Re-pair → re-publish the cassette-state hello to the new operator (#56). await window.electronAPI.resetBootstrapGate() } - return signer + return { signer, transport: transportFromSeed(seed) } } // No seed in this boot but a binding survives → resume. if (binding) { console.log('[Signer] Resuming bunker session from stored binding (no seed this boot)') - return resumeFromBinding({ - clientSecretHex: binding.clientSecretHex, - spirePubkey: binding.spirePubkey, - bunkerUrl: binding.bunkerUrl, - }) + return { signer: await resume(binding), transport: transportFromBinding(binding) } } if (opts.allowEphemeral) { @@ -117,10 +181,10 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise const devKey = !isElectron ? (import.meta.env.VITE_ATM_PRIVATE_KEY as string | undefined) : '' if (devKey) { console.warn('[Signer] No bunker pairing — using LocalSigner from VITE_ATM_PRIVATE_KEY (dev)') - return new LocalSigner(loadIdentityFromHex(devKey)) + return { signer: new LocalSigner(loadIdentityFromHex(devKey)), transport: null } } console.warn('[Signer] No bunker pairing — generated ephemeral LocalSigner (dev only)') - return new LocalSigner(generateIdentity()) + return { signer: new LocalSigner(generateIdentity()), transport: null } } throw new NoPairingError() diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 64af629..b018a39 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -6,10 +6,6 @@ export interface RuntimeConfig { relayUrl: string /** LNbits nostr-transport server pubkey (hex, 64 chars). */ lnbitsServerPubkey: string - /** Legacy LP fields — retained until 3d removes the LP backend. Optional. */ - lightningPubPubkey?: string - lightningPubApiUrl?: string - extensionApiUrl?: string appId: string machineModel: string fiatCode: string @@ -46,6 +42,10 @@ export interface BunkerBindingRecord { bunkerUrl: string seedFingerprint: string pairedAt: number + /** LNbits transport relays from the seed (#70); absent on pre-#70 bindings. */ + relays?: string[] + /** LNbits nostr-transport server pubkey (hex) from the seed (#70). */ + lnbitsServerPubkey?: string } export interface AtmSecrets { @@ -98,6 +98,7 @@ declare global { saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise resetBootstrapGate: () => Promise + resetForRepair: () => Promise saveSpireSeed: (seed: string) => Promise relaunchApp: () => Promise applyOperatorCassettesConfig: ( diff --git a/apps/machine/src/views/IdleView.vue b/apps/machine/src/views/IdleView.vue index f05d9a2..3465cc1 100644 --- a/apps/machine/src/views/IdleView.vue +++ b/apps/machine/src/views/IdleView.vue @@ -1,7 +1,6 @@