feat: seed-driven pairing over the LNbits nostr-transport (#70) #73

Merged
padreug merged 17 commits from feat/seed-driven-pairing into dev 2026-07-02 21:54:11 +00:00
7 changed files with 224 additions and 33 deletions
Showing only changes of commit 883c599835 - Show all commits

feat(machine): source LNbits transport from the pairing seed, not just env

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 <noreply@anthropic.com>
Padreug 2026-07-01 21:09:50 +02:00 • committed by padreug

View file

@ -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()
})
})

View file

@ -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
}
/**

View file

@ -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`
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
)
}

View file

@ -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()

View file

@ -54,7 +54,10 @@ interface LightningConfig {
*/
async function loadLightningConfig(): Promise<LightningConfig> {
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<LightningConfig> {
// 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

View file

@ -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<PairingState> {
return { spireSeed: (import.meta.env.VITE_SPIRE_SEED as string | undefined) || '', binding: null }
}
export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer> {
export async function resolveSigner(opts: ResolveSignerOptions): Promise<ResolvedSigner> {
const { spireSeed, binding } = await loadPairingState()
const resume = (b: BunkerBindingRecord): Promise<Signer> =>
@ -75,6 +94,18 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer>
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>
'[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<Signer>
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<Signer>
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()

View file

@ -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 {