From 98bdd92044aa3873d0c3d8d0a931f02df303a0f0 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 1 Jul 2026 13:14:59 +0200 Subject: [PATCH 01/17] refactor(nostr-client): slim the spire-seed to carry the pubkey once, add lnbits_npub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1 seed spelled the spire pubkey three times — spire_npub, spire_pubkey (hex), and again inside a full bunker_url — which bloats a QR that's already hard to scan off the machine's camera. Carry it once, as an npub, and derive the rest: - spire_pubkey (hex) ← decode(spire_npub). npub is ~the same length as hex but carries a bech32 checksum, so a mis-scanned character is caught instead of yielding a wrong-but-valid-looking key. - bunker_url ← reconstructed from spire_pubkey + bunker_secret + bunker_relay. - bunker_relay is OPTIONAL, defaulting to relays[0] (option 3): minimal in the common case where the bunker shares the event relay, explicit when it differs. - lnbits_npub is NEW — gives a paired machine its LNbits transport server pubkey from the seed itself, so nothing else needs provisioning (bitspire-#70 part 2). Kept as v: 1 (redefined in place, no compat shim): the seed is a one-shot pairing token, no bitspire machine has shipped, and a paired machine resumes from its stored binding, not by re-parsing the seed. Roughly a third smaller encoded — ~180-200 fewer chars in the QR. Lockstep: aiolabs/spirekeeper pairing.py must emit the new shape (spire_npub + lnbits_npub + bunker_secret, drop spire_pubkey/bunker_url) before a new seed can be minted. Consumer wiring (relays + lnbitsServerPubkey into LightningConfig) and a resolver-resilience guard for machines holding an old-shape seed land separately. Co-Authored-By: Claude Opus 4.8 --- .../services/pairing/__tests__/ingest.test.ts | 7 +- .../nostr-client/src/__tests__/seed.test.ts | 51 +++++++---- packages/nostr-client/src/seed.ts | 89 ++++++++++++++----- 3 files changed, 107 insertions(+), 40 deletions(-) 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/packages/nostr-client/src/__tests__/seed.test.ts b/packages/nostr-client/src/__tests__/seed.test.ts index 0deef80..3e3bba7 100644 --- a/packages/nostr-client/src/__tests__/seed.test.ts +++ b/packages/nostr-client/src/__tests__/seed.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import { npubEncode } from 'nostr-tools/nip19' import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js' /** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */ @@ -12,41 +13,56 @@ function makeSeed(json: unknown): string { } const SPIRE_PUBKEY = 'a'.repeat(64) -const BUNKER_URL = `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef` +const LNBITS_PUBKEY = 'b'.repeat(64) +const SPIRE_NPUB = npubEncode(SPIRE_PUBKEY) +const LNBITS_NPUB = npubEncode(LNBITS_PUBKEY) const VALID = { v: 1, - spire_npub: 'npub1example', - spire_pubkey: SPIRE_PUBKEY, - bunker_url: BUNKER_URL, + spire_npub: SPIRE_NPUB, + lnbits_npub: LNBITS_NPUB, + bunker_secret: 'deadbeef', relays: ['wss://events.relay/'], } describe('parseSpireSeed', () => { - it('parses a well-formed seed (snake_case → camelCase)', () => { + it('derives hex pubkeys from npubs and reconstructs the bunker URL', () => { const seed = parseSpireSeed(makeSeed(VALID)) expect(seed).toEqual({ v: 1, spirePubkey: SPIRE_PUBKEY, - bunkerUrl: BUNKER_URL, + lnbitsServerPubkey: LNBITS_PUBKEY, + bunkerUrl: `bunker://${SPIRE_PUBKEY}?relay=${encodeURIComponent('wss://events.relay/')}&secret=deadbeef`, relays: ['wss://events.relay/'], }) }) - it('re-pads stripped base64url of any residue length', () => { - // Vary a field so the encoded payload lands on each mod-4 residue. - for (const suffix of ['', 'a', 'ab', 'abc']) { - const seed = makeSeed({ ...VALID, spire_npub: `npub1${suffix}` }) - expect(() => parseSpireSeed(seed)).not.toThrow() - } + it('defaults the bunker relay to relays[0] when bunker_relay is absent', () => { + const seed = parseSpireSeed(makeSeed(VALID)) + expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://events.relay/')}`) }) - it('keeps bunker_url verbatim (percent-decoding is parseBunkerInput’s job)', () => { + it('uses an explicit bunker_relay when present (distinct from event relays)', () => { + const seed = parseSpireSeed(makeSeed({ ...VALID, bunker_relay: 'wss://bunker.relay/' })) + expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://bunker.relay/')}`) + // event relays are unchanged + expect(seed.relays).toEqual(['wss://events.relay/']) + }) + + it('percent-encodes relay + secret for parseBunkerInput to decode', () => { const seed = parseSpireSeed(makeSeed(VALID)) expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F') expect(seed.bunkerUrl).toContain('secret=deadbeef') }) + it('re-pads stripped base64url of any residue length', () => { + // Vary the secret so the encoded payload lands on each mod-4 residue. + for (const suffix of ['', 'a', 'ab', 'abc']) { + const seed = makeSeed({ ...VALID, bunker_secret: `deadbeef${suffix}` }) + expect(() => parseSpireSeed(seed)).not.toThrow() + } + }) + it.each([ ['wrong scheme', 'spire-seed:v2:abc'], ['not a seed', 'bunker://whatever'], @@ -56,10 +72,15 @@ describe('parseSpireSeed', () => { it.each([ ['bad version', { ...VALID, v: 2 }], - ['short pubkey', { ...VALID, spire_pubkey: 'abc' }], - ['non-bunker url', { ...VALID, bunker_url: 'https://evil/' }], + ['missing spire_npub', { ...VALID, spire_npub: undefined }], + ['non-npub spire_npub', { ...VALID, spire_npub: 'a'.repeat(64) }], + ['missing lnbits_npub', { ...VALID, lnbits_npub: undefined }], + ['non-npub lnbits_npub', { ...VALID, lnbits_npub: 'notanpub' }], + ['empty bunker_secret', { ...VALID, bunker_secret: '' }], + ['missing bunker_secret', { ...VALID, bunker_secret: undefined }], ['empty relays', { ...VALID, relays: [] }], ['non-string relay', { ...VALID, relays: [123] }], + ['empty bunker_relay', { ...VALID, bunker_relay: '' }], ])('rejects %s', (_label, json) => { expect(() => parseSpireSeed(makeSeed(json))).toThrow() }) diff --git a/packages/nostr-client/src/seed.ts b/packages/nostr-client/src/seed.ts index e2ab902..482d6b9 100644 --- a/packages/nostr-client/src/seed.ts +++ b/packages/nostr-client/src/seed.ts @@ -3,40 +3,52 @@ * * The operator dashboard (aiolabs/spirekeeper `pairing.py`) hands each ATM a * one-time seed URL that encodes the bunker connection + the spire's signing - * identity. Wire contract (model A1): + * identity. Wire contract (model A1, minimal encoding): * * spire-seed:v1: * json = { * "v": 1, - * "spire_npub": "npub1…", // informational, ignored here - * "spire_pubkey": "<64-hex>", // the spire's bunker-held signing identity - * "bunker_url": "bunker://?relay=&secret=", - * "relays": ["wss://…"] // relays for the spire's OWN events (21000/30078) + * "spire_npub": "npub1…", // spire signing identity (bech32; hex derived) + * "lnbits_npub": "npub1…", // LNbits nostr-transport server identity + * "bunker_secret": "", // one-shot NIP-46 connect token + * "relays": ["wss://…"], // relays the spire's OWN events use (21000/30078) + * "bunker_relay": "wss://…" // OPTIONAL — NIP-46 relay; defaults to relays[0] * } * - * - base64url is `urlsafe_b64encode(...).rstrip("=")` → re-pad to a multiple - * of 4 before decoding. - * - `relay` / `secret` inside `bunker_url` are percent-encoded; decoding them - * is left to nostr-tools `parseBunkerInput` (see bunker-signer.ts), so we - * keep `bunker_url` verbatim. - * - `bunker_url`'s relay is the BUNKER relay; `relays[]` is where the spire - * publishes its own events. They may differ — both must be spire-reachable. + * Design (see aiolabs/bitspire#70): the pubkey is carried ONCE, as an npub. + * The old shape spelled it three times (spire_npub + spire_pubkey hex + inside + * a full bunker_url), which bloats a QR that's already hard to scan. Here: + * + * - `spire_pubkey` (hex) is derived from `spire_npub` (npub is ~the same length + * as hex but carries a bech32 checksum — real error-detection for a value + * read off a camera). + * - `bunker_url` is RECONSTRUCTED from `spire_pubkey`, `bunker_relay` (or + * `relays[0]`), and `bunker_secret`, then handed verbatim to nostr-tools + * `parseBunkerInput` (see bunker-signer.ts). + * - `lnbits_npub` gives the ATM its LNbits transport server pubkey so a paired + * machine needs nothing else provisioned to reach the backend (#70 part 2). + * + * base64url is `urlsafe_b64encode(...).rstrip("=")` → re-pad to a multiple of 4 + * before decoding. */ import { sha256 } from '@noble/hashes/sha2.js' import { bytesToHex } from 'nostr-tools/utils' +import { decode as nip19Decode } from 'nostr-tools/nip19' export const SPIRE_SEED_SCHEME = 'spire-seed:v1:' export interface SpireSeed { /** Seed format version (always 1 for this scheme). */ v: number - /** The spire's signing identity — 64-char hex. Every event is signed as this. */ + /** The spire's signing identity — 64-char hex, derived from `spire_npub`. */ spirePubkey: string - /** `bunker://?relay=&secret=` — handed to nostr-tools parseBunkerInput. */ + /** `bunker://?relay=&secret=` — reconstructed, handed to parseBunkerInput. */ bunkerUrl: string /** Relays where the spire publishes its own events (kind 21000 / 30078). */ relays: string[] + /** LNbits nostr-transport server pubkey — 64-char hex, derived from `lnbits_npub`. */ + lnbitsServerPubkey: string } const HEX64 = /^[0-9a-f]{64}$/ @@ -50,6 +62,23 @@ function base64urlDecode(input: string): string { return Buffer.from(padded, 'base64').toString('binary') } +/** Decode an `npub1…` to its 64-char hex pubkey, failing closed. */ +function hexFromNpub(value: unknown, field: string): string { + if (typeof value !== 'string') { + throw new Error(`parseSpireSeed: ${field} must be a string`) + } + let decoded: ReturnType + try { + decoded = nip19Decode(value) + } catch (err) { + throw new Error(`parseSpireSeed: ${field} is not a valid npub (${(err as Error).message})`) + } + if (decoded.type !== 'npub' || typeof decoded.data !== 'string' || !HEX64.test(decoded.data)) { + throw new Error(`parseSpireSeed: ${field} must be an npub`) + } + return decoded.data +} + /** * Parse + validate a `spire-seed:v1:` URL. Throws on any malformation — * the seed is a trust root, so we fail closed rather than connect to a @@ -77,14 +106,12 @@ export function parseSpireSeed(seedUrl: string): SpireSeed { throw new Error(`parseSpireSeed: unsupported version ${String(obj.v)}`) } - const spirePubkey = obj.spire_pubkey - if (typeof spirePubkey !== 'string' || !HEX64.test(spirePubkey)) { - throw new Error('parseSpireSeed: spire_pubkey must be 64-char hex') - } + const spirePubkey = hexFromNpub(obj.spire_npub, 'spire_npub') + const lnbitsServerPubkey = hexFromNpub(obj.lnbits_npub, 'lnbits_npub') - const bunkerUrl = obj.bunker_url - if (typeof bunkerUrl !== 'string' || !bunkerUrl.startsWith('bunker://')) { - throw new Error('parseSpireSeed: bunker_url must be a bunker:// URL') + const bunkerSecret = obj.bunker_secret + if (typeof bunkerSecret !== 'string' || bunkerSecret.length === 0) { + throw new Error('parseSpireSeed: bunker_secret must be a non-empty string') } const relays = obj.relays @@ -92,7 +119,25 @@ export function parseSpireSeed(seedUrl: string): SpireSeed { throw new Error('parseSpireSeed: relays must be a non-empty string array') } - return { v: 1, spirePubkey, bunkerUrl, relays: relays as string[] } + // Optional bunker relay; default to the first event relay. Keeps the common + // case (bunker on the same relay) one field lighter, while still allowing a + // distinct NIP-46 relay when the operator runs one. + let bunkerRelay = relays[0] as string + if (obj.bunker_relay !== undefined) { + if (typeof obj.bunker_relay !== 'string' || obj.bunker_relay.length === 0) { + throw new Error('parseSpireSeed: bunker_relay, if present, must be a non-empty string') + } + bunkerRelay = obj.bunker_relay + } + + // Reconstruct the bunker URL nostr-tools expects. relay + secret are + // percent-encoded here; parseBunkerInput decodes them downstream. + const bunkerUrl = + `bunker://${spirePubkey}` + + `?relay=${encodeURIComponent(bunkerRelay)}` + + `&secret=${encodeURIComponent(bunkerSecret)}` + + return { v: 1, spirePubkey, bunkerUrl, relays: relays as string[], lnbitsServerPubkey } } /** From 786789f5170cd1e8c3523498afa499bb14cd01f8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 1 Jul 2026 13:16:16 +0200 Subject: [PATCH 02/17] fix(machine): resume from binding when a stored spire seed won't parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveSigner parses the stored VITE_SPIRE_SEED on every boot before it checks the binding, so a machine whose .env still holds a legacy-shape seed would throw on the new parser (bitspire-#70) and surface "ATM Unavailable" on the next auto-pull — even though it has a perfectly good, server-persistent binding to resume from. Guard the parse: an unparseable stored seed with a binding present falls back to resuming the binding (authoritative); with no binding it still fails closed, since the seed is then the only pairing input. Also dedupes the three resume-from-binding call sites behind a small local. Co-Authored-By: Claude Opus 4.8 --- apps/machine/src/services/signer-resolver.ts | 42 ++++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/apps/machine/src/services/signer-resolver.ts b/apps/machine/src/services/signer-resolver.ts index 44aa4b5..65f1647 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' @@ -67,17 +68,38 @@ async function loadPairingState(): 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, + }) + 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 resume(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, - }) + return resume(binding) } // First pair or re-pair: redeem the one-shot connect secret. @@ -105,11 +127,7 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise // 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 resume(binding) } if (opts.allowEphemeral) { From 883c599835f9cccf00cbe6cd2ad293981cbf3b38 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 1 Jul 2026 21:09:50 +0200 Subject: [PATCH 03/17] feat(machine): source LNbits transport from the pairing seed, not just env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the consumer half of bitspire-#70: a paired machine gets its LNbits transport relay(s) + server pubkey from the pairing, so a blank-.env unit reaches the backend after scanning a seed — no VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY provisioning. - resolveSigner now returns { signer, transport }. transport (relays + lnbitsServerPubkey) comes from the seed on a fresh pair / seeded resume, and from the binding on a seedless resume. It's threaded out of resolveSigner rather than re-parsed in loadLightningConfig because the seed arrives over the one-shot get-atm-secrets IPC — a second consumer would break that contract. - bunker_binding persists relays + lnbits_server_pubkey (state.db v11→v12, nullable so pre-#70 bindings resume and fall back to env). Mirrored into BunkerBindingRecord (preload + electron.d.ts). - initializeLightningServices resolves effective transport with env-wins precedence (explicit env override for dev, else pairing, else a dev-only localhost relay), mutating CONFIG to a single source of truth and building the Nostr/LNbits/CLINK clients from the full relay list. Strict + required-config validation now run on the resolved values. state.db round-trip test covers the new columns + their absence on a pre-#70 binding. Renderer + electron typechecks and all 38 machine tests pass. Co-Authored-By: Claude Opus 4.8 --- .../__tests__/state-store-bunker.test.ts | 69 +++++++++++++++++++ apps/machine/electron/preload.ts | 4 ++ apps/machine/electron/state-store.ts | 69 ++++++++++++++++--- apps/machine/src/App.vue | 3 +- apps/machine/src/services/lightning.ts | 57 +++++++++++---- apps/machine/src/services/signer-resolver.ts | 51 ++++++++++++-- apps/machine/src/types/electron.d.ts | 4 ++ 7 files changed, 224 insertions(+), 33 deletions(-) create mode 100644 apps/machine/electron/__tests__/state-store-bunker.test.ts 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/preload.ts b/apps/machine/electron/preload.ts index f1320fb..5483e46 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -51,6 +51,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 } /** diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index d9282ac..7ea3e16 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 ) } diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index f28b639..c7e1537 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -106,7 +106,8 @@ onMounted(async () => { 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 if (signer && relayUrl) { const client = new NostrClient({ relays: [{ url: relayUrl }], signer }) await client.connect() diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index f673b24..039cf01 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -54,7 +54,10 @@ interface LightningConfig { */ async function loadLightningConfig(): Promise { 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,9 @@ 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. */ +const DEV_DEFAULT_RELAY = 'ws://localhost:7777' + /** 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 +401,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 +413,43 @@ 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)' : '', + ) + + // 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 +458,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 +478,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 +501,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/signer-resolver.ts b/apps/machine/src/services/signer-resolver.ts index 65f1647..2d29ab6 100644 --- a/apps/machine/src/services/signer-resolver.ts +++ b/apps/machine/src/services/signer-resolver.ts @@ -51,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 @@ -65,7 +84,7 @@ 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 => @@ -75,6 +94,18 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise 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) { let seed: SpireSeed let fingerprint: string @@ -92,14 +123,16 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise '[Signer] Stored spire seed is unparseable; resuming from existing binding:', (err as Error).message, ) - return resume(binding) + 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 resume(binding) + // 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. @@ -111,23 +144,27 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise clientSecretHex: transport.secretHex, }) if (isElectron && window.electronAPI) { + // 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 resume(binding) + return { signer: await resume(binding), transport: transportFromBinding(binding) } } if (opts.allowEphemeral) { @@ -135,10 +172,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..3b17d0e 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -46,6 +46,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 { From 5179a21da6350f46e92c2739b4148787bf491269 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 1 Jul 2026 23:43:28 +0200 Subject: [PATCH 04/17] fix(nostr-client): reject non-ws(s):// relays in the spire seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npubs in the seed are bech32-checksummed, so a mis-scanned character is caught — but the relay strings are raw inside the base64. A QR misread silently turned `ws://192.168.0.32:5001/...` into `As://192.168.0.32:5001/...`, which parsed fine and then crash-looped the machine on an unreachable NIP-46 relay. Validate every `relays[]` entry (and `bunker_relay`) is a `ws://`/`wss://` URL at parse time, so a garbled scan is rejected as an invalid seed instead of persisted. Part of bitspire-#70 pairing robustness. Co-Authored-By: Claude Opus 4.8 --- packages/nostr-client/src/__tests__/seed.test.ts | 3 +++ packages/nostr-client/src/seed.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/nostr-client/src/__tests__/seed.test.ts b/packages/nostr-client/src/__tests__/seed.test.ts index 3e3bba7..6e38159 100644 --- a/packages/nostr-client/src/__tests__/seed.test.ts +++ b/packages/nostr-client/src/__tests__/seed.test.ts @@ -80,7 +80,10 @@ describe('parseSpireSeed', () => { ['missing bunker_secret', { ...VALID, bunker_secret: undefined }], ['empty relays', { ...VALID, relays: [] }], ['non-string relay', { ...VALID, relays: [123] }], + ['non-ws relay (scan corruption ws://→As://)', { ...VALID, relays: ['As://events.relay/'] }], + ['non-ws relay (http)', { ...VALID, relays: ['http://events.relay/'] }], ['empty bunker_relay', { ...VALID, bunker_relay: '' }], + ['non-ws bunker_relay', { ...VALID, bunker_relay: 'As://bunker.relay/' }], ])('rejects %s', (_label, json) => { expect(() => parseSpireSeed(makeSeed(json))).toThrow() }) diff --git a/packages/nostr-client/src/seed.ts b/packages/nostr-client/src/seed.ts index 482d6b9..02a6470 100644 --- a/packages/nostr-client/src/seed.ts +++ b/packages/nostr-client/src/seed.ts @@ -53,6 +53,20 @@ export interface SpireSeed { const HEX64 = /^[0-9a-f]{64}$/ +/** + * A relay must be a `ws://` or `wss://` URL. Unlike the npubs (bech32-checksummed, + * so a mis-scanned character is caught), the relay strings are raw inside the + * seed's base64 — a QR misread can silently corrupt `ws://` into e.g. `As://` + * and the pairing then crash-loops on an unreachable relay. Reject at parse time + * so the wizard refuses a garbled scan instead of persisting it (bitspire#70). + */ +const WS_URL = /^wss?:\/\/[^\s]+$/ +function assertRelayUrl(value: string, field: string): void { + if (!WS_URL.test(value)) { + throw new Error(`parseSpireSeed: ${field} must be a ws:// or wss:// URL (got "${value}")`) + } +} + /** Decode an unpadded base64url string in both browser and Node. */ function base64urlDecode(input: string): string { const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '=') @@ -118,6 +132,7 @@ export function parseSpireSeed(seedUrl: string): SpireSeed { if (!Array.isArray(relays) || relays.length === 0 || !relays.every((r) => typeof r === 'string')) { throw new Error('parseSpireSeed: relays must be a non-empty string array') } + relays.forEach((r, i) => assertRelayUrl(r as string, `relays[${i}]`)) // Optional bunker relay; default to the first event relay. Keeps the common // case (bunker on the same relay) one field lighter, while still allowing a @@ -127,6 +142,7 @@ export function parseSpireSeed(seedUrl: string): SpireSeed { if (typeof obj.bunker_relay !== 'string' || obj.bunker_relay.length === 0) { throw new Error('parseSpireSeed: bunker_relay, if present, must be a non-empty string') } + assertRelayUrl(obj.bunker_relay, 'bunker_relay') bunkerRelay = obj.bunker_relay } From 0bc57dc754405b03a5c851241a84151bd74defa0 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 1 Jul 2026 23:43:28 +0200 Subject: [PATCH 05/17] feat(machine): pairing review step with a relay-reachability test A well-formed but unreachable relay (localhost baked into a seed for a remote machine, a wrong LAN IP, a relay that's down) parses fine and only fails later as a NIP-46 connect crash-loop. Give the operator a way to catch it on-machine before committing (bitspire-#70). The wizard no longer commits immediately on a good scan: it now parses (without persisting) and shows a review step with the decoded spire + relay(s), a "Test relay" button (opens a WebSocket + NIP-01 REQ, reports reachable/latency or unreachable), and Pair / Rescan. Only on "Pair" does it persist + relaunch into the real pairing path. - parseScannedSeed: validate-only split of ingestScannedSeed (no persist). - testRelay: WebSocket reachability probe. Co-Authored-By: Claude Opus 4.8 --- apps/machine/src/components/PairingWizard.vue | 141 ++++++++++++++++-- apps/machine/src/services/pairing/index.ts | 6 +- apps/machine/src/services/pairing/ingest.ts | 31 ++++ .../src/services/pairing/relay-test.ts | 69 +++++++++ 4 files changed, 233 insertions(+), 14 deletions(-) create mode 100644 apps/machine/src/services/pairing/relay-test.ts diff --git a/apps/machine/src/components/PairingWizard.vue b/apps/machine/src/components/PairingWizard.vue index f34409e..fb343e1 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) } @@ -121,6 +177,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 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)' }) + }) +} From eaa7cbe33c4c12b368791934630ec481650e828e Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 00:29:10 +0200 Subject: [PATCH 06/17] fix(machine): don't inject a localhost relay default in get-config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Electron main's get-config returned relayUrl = VITE_RELAY_URL || 'ws://localhost:7777'. On an unprovisioned (blank-.env) machine that non-empty localhost default reached the renderer and, via the env-first precedence, won over the pairing seed's relay — then failed strict validation as localhost. That defeated #70's "the seed provides the relay": the Sintra paired fine but booted with ws://localhost:7777 instead of the seed's nostrclient endpoint. Return '' when unset so the renderer falls through to the seed's transport relay (its own ws://localhost:7777 dev fallback only applies when neither env nor pairing supplies one). Mirror of the renderer default fixed in e578680. Co-Authored-By: Claude Opus 4.8 --- apps/machine/electron/main.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 516f32a..faedf2b 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -278,8 +278,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 || '', From 7896c122dae1c97c92ae826a6fead669b3d0d12e Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 15:38:14 +0200 Subject: [PATCH 07/17] fix(deploy): relay + LNbits pubkey are seed-provided, not env-pinned (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bitspire-env activation seeded VITE_RELAY_URL from the relayUrl option (default wss://relay.aiolabs.dev). Because env wins over the pairing seed, every fresh machine pinned itself to that relay — which is dead — so a scanned seed's relay was ignored ("No connected relays"; hit live on the aio-demo USB). Default relayUrl to "" so both relay and server pubkey come from the seed; a non-empty option now pins a machine (an explicit override) rather than being the default. Descriptions updated to match. Co-Authored-By: Claude Opus 4.8 --- deploy/nixos/bitspire-atm.nix | 32 +++++++++++++++++--------------- flake.nix | 9 +++++---- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/deploy/nixos/bitspire-atm.nix b/deploy/nixos/bitspire-atm.nix index 43f4d3c..a46e9b4 100644 --- a/deploy/nixos/bitspire-atm.nix +++ b/deploy/nixos/bitspire-atm.nix @@ -20,18 +20,17 @@ in relayUrl = mkOption { type = types.str; - default = "wss://relay.aiolabs.dev"; + default = ""; description = '' - Nostr relay URL the ATM and LNbits both subscribe to. - - On a fresh-boot disk image this value is seeded into - `/var/lib/bitspire/.env` as `VITE_RELAY_URL=…` (see flake.nix - `bitspire-env` activation script). The operator can override - the seeded value at runtime by editing `.env` directly or by - re-running `deploy/nixos/provision-atm.sh` with a different - `RELAY_URL`. The renderer's resolution order is: - `/var/lib/bitspire/.env` → this NixOS default → renderer - hardcoded fallback (`ws://localhost:7777`). + Optional override for the Nostr relay the ATM uses. Empty by + default (aiolabs/bitspire#70): the relay comes from the pairing + SEED, not from provisioning — a fresh machine boots blank, scans a + spire-seed, and the seed's relay drives the connection. A non-empty + value here is seeded into `/var/lib/bitspire/.env` as + `VITE_RELAY_URL=…` and WINS over the seed (env-first precedence), so + only set it to pin a machine to a specific relay. The renderer's + resolution order is: `VITE_RELAY_URL` (this / .env) → the pairing + seed's relay → a dev-only `ws://localhost:7777` fallback. ''; }; @@ -39,10 +38,13 @@ in type = types.str; default = ""; description = '' - LNbits nostr-transport server pubkey (hex, 64 chars). Published - by the LNbits server on startup. Required for the ATM to talk - to its wallet. Provisioned by provision-atm.sh; can be left - empty on disk-image builds. + Optional override for the LNbits nostr-transport server pubkey + (hex, 64 chars). Empty by default (aiolabs/bitspire#70): the + pubkey comes from the pairing SEED (the seed's `lnbits_npub`), so + a seed-paired machine needs nothing here. A non-empty value is + seeded into `.env` as `VITE_LNBITS_SERVER_PUBKEY=…` and WINS over + the seed (env-first precedence) — set it only to pin a machine to + a specific server. Mirrors `relayUrl`. ''; }; diff --git a/flake.nix b/flake.nix index 3a678da..c717719 100644 --- a/flake.nix +++ b/flake.nix @@ -191,10 +191,11 @@ # boots cleanly into the "needs provisioning" state; provision- # atm.sh SSHes in and overwrites with real values. # - # VITE_RELAY_URL seeds from `config.services.bitspire.relayUrl` - # so the NixOS module's `relayUrl` option becomes the default - # without losing the operator's ability to override via .env - # (edit the file or re-run provision-atm.sh). + # VITE_RELAY_URL + VITE_LNBITS_SERVER_PUBKEY seed EMPTY by default + # (relayUrl defaults to ""), so the pairing seed drives the relay + # + server pubkey (aiolabs/bitspire#70). A non-empty `relayUrl` + # option pins a machine to a specific relay (seeded here, wins over + # the seed via env-first precedence) — otherwise leave it blank. system.activationScripts.bitspire-env = '' mkdir -p /var/lib/bitspire if [ ! -f /var/lib/bitspire/.env ]; then From 20dbc8ca80cd20b630d5717ec3541f2cab8d4054 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 15:38:14 +0200 Subject: [PATCH 08/17] fix(deploy): provision-atm.sh writes relay/pubkey only on explicit override (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script unconditionally wrote VITE_RELAY_URL + VITE_LNBITS_SERVER_PUBKEY (and hard-exited if it couldn't scrape the pubkey), env-pinning every provisioned machine and defeating the seed — the same bug as the activation default. Make it seed-first: with a SPIRE_SEED, relay + pubkey come from the seed and are written only when the operator explicitly passes RELAY_URL / LNBITS_SERVER_PUBKEY as a deliberate pin. The no-seed dev-nsec path still scrapes/defaults them. Also drops the unused VITE_LNBITS_HTTP_URL line. Co-Authored-By: Claude Opus 4.8 --- deploy/nixos/provision-atm.sh | 100 +++++++++++++++++++--------------- 1 file changed, 57 insertions(+), 43 deletions(-) diff --git a/deploy/nixos/provision-atm.sh b/deploy/nixos/provision-atm.sh index 74f4229..31e08e8 100755 --- a/deploy/nixos/provision-atm.sh +++ b/deploy/nixos/provision-atm.sh @@ -4,19 +4,22 @@ # kind-21000 NIP-44 v2 events on a relay — there is no out-of-band token, # the ATM's nostr private key IS the credential. # pragma: allowlist secret # -# Required environment variables (or edit defaults below): -# LNBITS_SERVER_PUBKEY Hex pubkey published by the LNbits server at startup. -# From the LNbits compose: -# docker logs lnbits | grep 'nostr_transport pubkey' -# LNBITS_HTTP_URL Origin LNbits is reachable at over HTTP, used only -# to compose the LNURL-withdraw callback URL that -# customer wallets dereference. Default: http://10.0.2.2:5000 -# RELAY_URL Nostr relay LNbits + the bunker subscribe on. -# Default: ws://$HOST_IP:5001/nostrrelay/test (LNbits -# bundled nostrrelay). Override for a separate relay. -# SPIRE_SEED The spire pairing seed (`spire-seed:v1:`) -# minted by spirekeeper. THIS is the production -# identity under the NIP-46 bunker (aiolabs/bitspire#52). +# The primary input is SPIRE_SEED — the pairing seed carries the relay, the +# LNbits server pubkey AND the signing identity, so a seed-provisioned machine +# needs nothing else (aiolabs/bitspire#70). +# +# Environment variables: +# SPIRE_SEED RECOMMENDED. The spire pairing seed +# (`spire-seed:v1:`) minted by spirekeeper. +# Carries relay + LNbits server pubkey + the production +# identity under the NIP-46 bunker (aiolabs/bitspire#52 / #70). +# RELAY_URL OPTIONAL override — pins VITE_RELAY_URL and WINS over the +# seed's relay (env-first precedence). Leave unset to let the +# seed drive it. Required only on the no-seed dev path +# (default there: ws://$HOST_IP:5001/nostrrelay/test). +# LNBITS_SERVER_PUBKEY OPTIONAL override (hex). Leave unset with a seed. On the +# no-seed dev path it's scraped from +# `docker logs lnbits | grep 'nostr_transport pubkey'`. # ATM_PRIVATE_KEY DEV-ONLY 32-byte hex nsec fallback, used only when # SPIRE_SEED is unset (no bunker). Generated if unset # AND no SPIRE_SEED is provided. @@ -61,40 +64,51 @@ else echo "--- LAN ATM: using $HOST_IP as dev machine address ---" fi -# Step 2: Resolve the LNbits server pubkey. Prefer the env override; else -# fall back to scraping the local docker compose stack. -if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then - echo "" - echo "--- Step 1: Extracting LNbits nostr-transport pubkey from docker logs ---" - LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \ - | grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \ - | tail -1 || true) - if [ -z "$LNBITS_SERVER_PUBKEY" ]; then - echo "ERROR: Could not extract LNbits pubkey. Set LNBITS_SERVER_PUBKEY explicitly" - echo "or start the LNbits stack first (docker compose -f docker/docker-compose.dev.yml up lnbits)." - exit 1 - fi -fi -echo "LNbits server pubkey: ${LNBITS_SERVER_PUBKEY:0:16}..." +# Steps 2-4: transport config (relay + LNbits server pubkey) + signing identity. +# +# Under aiolabs/bitspire#70 the relay + server pubkey come from the pairing SEED, +# so a seed-provisioned machine needs NEITHER in .env. We only pin them when the +# operator EXPLICITLY passes RELAY_URL / LNBITS_SERVER_PUBKEY (a deliberate +# override that WINS over the seed via env-first precedence), or when there is no +# seed (the dev-nsec fallback has nothing else to supply them, so we scrape/default). +TRANSPORT_LINES="" -# Step 3: Pin LNbits HTTP origin. -LNBITS_HTTP_URL="${LNBITS_HTTP_URL:-http://$HOST_IP:5000}" - -# Step 4: Relay URL. Defaults to the LNbits bundled nostrrelay. -RELAY_URL="${RELAY_URL:-ws://$HOST_IP:5001/nostrrelay/test}" - -# Step 5: Signing identity. Prefer the spire pairing seed (bunker). Only fall -# back to a generated dev nsec when no seed is supplied. if [ -n "${SPIRE_SEED:-}" ]; then - echo "" - echo "--- Using spire pairing seed (bunker-backed identity) ---" case "$SPIRE_SEED" in spire-seed:v1:*) : ;; *) echo "ERROR: SPIRE_SEED must start with 'spire-seed:v1:'"; exit 1 ;; esac + echo "" + echo "--- Spire pairing seed: relay + LNbits pubkey come from the seed ---" + if [ -n "${RELAY_URL:-}" ]; then + echo " (pinning VITE_RELAY_URL=$RELAY_URL — overrides the seed's relay)" + TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL" + fi + if [ -n "${LNBITS_SERVER_PUBKEY:-}" ]; then + TRANSPORT_LINES="${TRANSPORT_LINES:+$TRANSPORT_LINES +}VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY" + fi IDENTITY_LINES="# Spire pairing seed — bunker-backed identity (aiolabs/bitspire#52) VITE_SPIRE_SEED=$SPIRE_SEED" else + # No seed → DEV-ONLY nsec fallback. Nothing else supplies the relay + pubkey, + # so scrape/default them. + if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then + echo "" + echo "--- No seed: extracting LNbits nostr-transport pubkey from docker logs ---" + LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \ + | grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \ + | tail -1 || true) + if [ -z "$LNBITS_SERVER_PUBKEY" ]; then + echo "ERROR: no SPIRE_SEED, and could not extract the LNbits pubkey." + echo "Provide a SPIRE_SEED (recommended — the seed carries relay + pubkey)," + echo "or set LNBITS_SERVER_PUBKEY explicitly." + exit 1 + fi + fi + RELAY_URL="${RELAY_URL:-ws://$HOST_IP:5001/nostrrelay/test}" + TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL +VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY" if [ -z "${ATM_PRIVATE_KEY:-}" ]; then ATM_PRIVATE_KEY=$(openssl rand -hex 32) echo "" @@ -110,10 +124,10 @@ echo "--- Step 2: Writing .env to ATM ---" ENV_CONTENT="# bitSpire Configuration # Auto-generated by provision-atm.sh on $(date -Iseconds) -# LNbits nostr-transport connection -VITE_RELAY_URL=$RELAY_URL -VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY -VITE_LNBITS_HTTP_URL=$LNBITS_HTTP_URL +# LNbits nostr-transport. Relay + server pubkey come from the pairing seed +# (aiolabs/bitspire#70); present below only as an explicit override or the +# no-seed dev fallback. +$TRANSPORT_LINES $IDENTITY_LINES @@ -132,6 +146,6 @@ echo "" echo "=== ATM provisioned successfully ===" echo "" echo "Credentials written to /var/lib/bitspire/.env" -echo "ATM service restarted. It should connect to LNbits via relay $RELAY_URL." +echo "ATM service restarted. Relay: ${RELAY_URL:-from the pairing seed}." echo "" echo "To check status: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'" From ce87f85a7387378c7d120c385c05794dc66a3d9f Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 15:38:14 +0200 Subject: [PATCH 09/17] fix(machine): maintenance beacon uses the pairing seed's relay (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintenance-mode beacon resolved the relay from env only (config.relayUrl || VITE_RELAY_URL), so on a blank-.env seed-driven machine it was undefined and the beacon was skipped — a paired ATM in maintenance never broadcast. It already resolves the signer (which carries the transport); fall back to resolved.transport.relays[0], mirroring lightning.ts's env → pairing precedence. Co-Authored-By: Claude Opus 4.8 --- apps/machine/src/App.vue | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index c7e1537..2b89eb7 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -103,11 +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 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() From e99628ef845cbda1e3a5bae089d47499259ea930 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 15:38:14 +0200 Subject: [PATCH 10/17] docs: relay + LNbits pubkey are seed-provided, not required (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Env table (CLAUDE.md), .env.example, and the deploy README still framed VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY as required/provisioned; they now come from the pairing seed and are env overrides only. Also refresh the slimmed seed shape, the relayUrl/pubkey module examples ("" not wss://relay.aiolabs.dev), and the stale lamassu-next autoUpgrade flake URL (→ aiolabs/bitspire). Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 6 +++--- apps/machine/.env.example | 10 +++++++--- deploy/nixos/README.md | 6 +++--- 3 files changed, 13 insertions(+), 9 deletions(-) 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/deploy/nixos/README.md b/deploy/nixos/README.md index af00ed6..2bfc333 100644 --- a/deploy/nixos/README.md +++ b/deploy/nixos/README.md @@ -187,7 +187,7 @@ The `dev`-branch `flake.nix` pins the auto-upgrade source to `?ref=dev` so any A ```nix system.autoUpgrade = { enable = true; - flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git?ref=dev#${machineModel}-installed"; + flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#${machineModel}-installed"; dates = "04:00"; allowReboot = false; }; @@ -262,8 +262,8 @@ ls -la /dev/serial/by-id/ { services.bitspire = { enable = true; - relayUrl = "wss://relay.aiolabs.dev"; # ATM ↔ LNbits relay - lnbitsServerPubkey = "<64-hex>"; # LNbits transport pubkey + relayUrl = ""; # seed-provided (#70); set to PIN a relay + lnbitsServerPubkey = ""; # seed-provided (#70); set to PIN a pubkey appDir = "/opt/bitspire"; # rarely overridden — defaults via flake dataDir = "/var/lib/bitspire"; # rarely overridden logLevel = "info"; # error | warn | info | debug From 7abc2e3305009965c5c81314eb5653ed748b11b6 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 18:33:31 +0200 Subject: [PATCH 11/17] refactor(machine): remove dead Lightning.Pub nprofile UI (post-3d cutover) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LP backend was deleted on dev, so VITE_LIGHTNING_PUB_PUBKEY / config.lightningPubPubkey are never set — the "add this ATM's node to your wallet" nprofile QR (IdleView dev button + overlay, SupportView ShockWallet card + deep-link) rendered empty, and the LP fields in RuntimeConfig (lightningPubPubkey/lightningPubApiUrl/extensionApiUrl) were never populated. Remove them. The concept has no clean LNbits analog (the ATM is a cash↔LN gateway, not a node customers peer with) — tracked as a fresh feature request on lnbits. ShockWallet stays listed as a downloadable wallet (plain URL). Co-Authored-By: Claude Opus 4.8 --- apps/machine/electron/preload.ts | 4 -- apps/machine/src/types/electron.d.ts | 4 -- apps/machine/src/views/IdleView.vue | 43 +------------- apps/machine/src/views/SupportView.vue | 80 +------------------------- 4 files changed, 3 insertions(+), 128 deletions(-) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 5483e46..ba53b62 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 diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 3b17d0e..bca4d0f 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 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 @@