feat: seed-driven pairing over the LNbits nostr-transport (#70) #73
7 changed files with 224 additions and 33 deletions
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>
commit
883c599835
69
apps/machine/electron/__tests__/state-store-bunker.test.ts
Normal file
69
apps/machine/electron/__tests__/state-store-bunker.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -51,6 +51,10 @@ export interface BunkerBindingRecord {
|
||||||
bunkerUrl: string
|
bunkerUrl: string
|
||||||
seedFingerprint: string
|
seedFingerprint: string
|
||||||
pairedAt: number
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
||||||
|
|
||||||
let db: Database.Database | null = null
|
let db: Database.Database | null = null
|
||||||
|
|
||||||
const SCHEMA_VERSION = '11'
|
const SCHEMA_VERSION = '12'
|
||||||
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
const prodDir = '/var/lib/bitspire'
|
const prodDir = '/var/lib/bitspire'
|
||||||
|
|
@ -121,7 +121,9 @@ export function initDatabase(dbPath?: string): void {
|
||||||
spire_pubkey TEXT NOT NULL,
|
spire_pubkey TEXT NOT NULL,
|
||||||
bunker_url TEXT NOT NULL,
|
bunker_url TEXT NOT NULL,
|
||||||
seed_fingerprint 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')
|
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version')
|
||||||
console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)')
|
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.
|
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
||||||
|
|
@ -428,6 +445,14 @@ export interface StoredBunkerBinding {
|
||||||
seedFingerprint: string
|
seedFingerprint: string
|
||||||
/** Unix seconds when the pairing was redeemed. */
|
/** Unix seconds when the pairing was redeemed. */
|
||||||
pairedAt: number
|
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. */
|
/** 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')
|
if (!db) throw new Error('Database not initialized')
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.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
|
.get() as
|
||||||
| {
|
| {
|
||||||
|
|
@ -444,6 +469,8 @@ export function getBunkerBinding(): StoredBunkerBinding | null {
|
||||||
bunker_url: string
|
bunker_url: string
|
||||||
seed_fingerprint: string
|
seed_fingerprint: string
|
||||||
paired_at: number
|
paired_at: number
|
||||||
|
relays: string | null
|
||||||
|
lnbits_server_pubkey: string | null
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
|
|
@ -453,27 +480,47 @@ export function getBunkerBinding(): StoredBunkerBinding | null {
|
||||||
bunkerUrl: row.bunker_url,
|
bunkerUrl: row.bunker_url,
|
||||||
seedFingerprint: row.seed_fingerprint,
|
seedFingerprint: row.seed_fingerprint,
|
||||||
pairedAt: row.paired_at,
|
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. */
|
/** Upsert the bunker binding after a successful (re-)pairing. */
|
||||||
export function saveBunkerBinding(binding: StoredBunkerBinding): void {
|
export function saveBunkerBinding(binding: StoredBunkerBinding): void {
|
||||||
if (!db) throw new Error('Database not initialized')
|
if (!db) throw new Error('Database not initialized')
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at)
|
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey)
|
||||||
VALUES (1, ?, ?, ?, ?, ?)
|
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
client_secret_hex = excluded.client_secret_hex,
|
client_secret_hex = excluded.client_secret_hex,
|
||||||
spire_pubkey = excluded.spire_pubkey,
|
spire_pubkey = excluded.spire_pubkey,
|
||||||
bunker_url = excluded.bunker_url,
|
bunker_url = excluded.bunker_url,
|
||||||
seed_fingerprint = excluded.seed_fingerprint,
|
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(
|
).run(
|
||||||
binding.clientSecretHex,
|
binding.clientSecretHex,
|
||||||
binding.spirePubkey,
|
binding.spirePubkey,
|
||||||
binding.bunkerUrl,
|
binding.bunkerUrl,
|
||||||
binding.seedFingerprint,
|
binding.seedFingerprint,
|
||||||
binding.pairedAt
|
binding.pairedAt,
|
||||||
|
binding.relays ? JSON.stringify(binding.relays) : null,
|
||||||
|
binding.lnbitsServerPubkey ?? null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,8 @@ onMounted(async () => {
|
||||||
const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL
|
const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL
|
||||||
// Best-effort: resolve a signer (bunker resume / pairing, or dev nsec).
|
// 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.
|
// 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) {
|
if (signer && relayUrl) {
|
||||||
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
|
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
|
||||||
await client.connect()
|
await client.connect()
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,10 @@ interface LightningConfig {
|
||||||
*/
|
*/
|
||||||
async function loadLightningConfig(): Promise<LightningConfig> {
|
async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
const defaults: 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
|
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
|
||||||
operatorPubkeys: [],
|
operatorPubkeys: [],
|
||||||
lnbitsServerPubkey: '',
|
lnbitsServerPubkey: '',
|
||||||
|
|
@ -97,6 +100,9 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
// Config is loaded async now - will be set in initializeLightningServices
|
// Config is loaded async now - will be set in initializeLightningServices
|
||||||
let CONFIG: LightningConfig
|
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.
|
/** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime.
|
||||||
* Sessions are normally cleaned up by the state machine on idle transition.
|
* 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. */
|
* 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)
|
// Load configuration (async for Electron runtime config)
|
||||||
CONFIG = await loadLightningConfig()
|
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
|
// Resolve the signing identity BEFORE validating the LNbits transport
|
||||||
// config. An unpaired machine must reach the QR-pairing wizard regardless
|
// config. An unpaired machine must reach the QR-pairing wizard regardless
|
||||||
// of relay/server-pubkey provisioning — pairing is what provides those — so
|
// 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
|
// 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
|
// it falls back to an in-process LocalSigner. The Phase-A Signer seam means
|
||||||
// nothing downstream changes. See aiolabs/bitspire#52.
|
// 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)
|
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) {
|
if (options?.strict) {
|
||||||
const errors: string[] = []
|
const errors: string[] = []
|
||||||
if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) {
|
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) {
|
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) {
|
if (errors.length > 0) {
|
||||||
throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- '))
|
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
|
// Validate required configuration. Reached only for a paired machine (an
|
||||||
// unpaired one threw NoPairingError above) — it needs the LNbits server
|
// 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) {
|
if (!CONFIG.lnbitsServerPubkey) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'[Lightning] VITE_LNBITS_SERVER_PUBKEY is required. ' +
|
'[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' +
|
||||||
'Get it from: docker logs lnbits | grep nostr_transport pubkey',
|
'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Nostr client
|
// Create Nostr client
|
||||||
const nostrClient = new NostrClient({
|
const nostrClient = new NostrClient({
|
||||||
relays: [{ url: CONFIG.relayUrl }],
|
relays: relays.map((url) => ({ url })),
|
||||||
signer,
|
signer,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -449,7 +478,7 @@ export async function initializeLightningServices(options?: {
|
||||||
// LNbits nostr-transport client.
|
// LNbits nostr-transport client.
|
||||||
const lnbits = new LnbitsClient({
|
const lnbits = new LnbitsClient({
|
||||||
serverPubkey: CONFIG.lnbitsServerPubkey,
|
serverPubkey: CONFIG.lnbitsServerPubkey,
|
||||||
relays: [CONFIG.relayUrl],
|
relays,
|
||||||
})
|
})
|
||||||
lnbits.initialize(nostrClient, signer)
|
lnbits.initialize(nostrClient, signer)
|
||||||
_lnbitsRef = lnbits
|
_lnbitsRef = lnbits
|
||||||
|
|
@ -472,7 +501,7 @@ export async function initializeLightningServices(options?: {
|
||||||
nostrClient,
|
nostrClient,
|
||||||
signer,
|
signer,
|
||||||
operatorPubkey: CONFIG.operatorPubkeys,
|
operatorPubkey: CONFIG.operatorPubkeys,
|
||||||
relays: [CONFIG.relayUrl],
|
relays,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Callbacks for events
|
// Callbacks for events
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,25 @@ export interface ResolveSignerOptions {
|
||||||
allowEphemeral: boolean
|
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 {
|
interface PairingState {
|
||||||
spireSeed: string
|
spireSeed: string
|
||||||
binding: BunkerBindingRecord | null
|
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 }
|
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 { spireSeed, binding } = await loadPairingState()
|
||||||
|
|
||||||
const resume = (b: BunkerBindingRecord): Promise<Signer> =>
|
const resume = (b: BunkerBindingRecord): Promise<Signer> =>
|
||||||
|
|
@ -75,6 +94,18 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer>
|
||||||
bunkerUrl: b.bunkerUrl,
|
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) {
|
if (spireSeed) {
|
||||||
let seed: SpireSeed
|
let seed: SpireSeed
|
||||||
let fingerprint: string
|
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:',
|
'[Signer] Stored spire seed is unparseable; resuming from existing binding:',
|
||||||
(err as Error).message,
|
(err as Error).message,
|
||||||
)
|
)
|
||||||
return resume(binding)
|
return { signer: await resume(binding), transport: transportFromBinding(binding) }
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|
||||||
if (binding && binding.seedFingerprint === fingerprint) {
|
if (binding && binding.seedFingerprint === fingerprint) {
|
||||||
console.log('[Signer] Resuming bunker session for spire', seed.spirePubkey)
|
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.
|
// 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,
|
clientSecretHex: transport.secretHex,
|
||||||
})
|
})
|
||||||
if (isElectron && window.electronAPI) {
|
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({
|
await window.electronAPI.saveBunkerBinding({
|
||||||
clientSecretHex: transport.secretHex,
|
clientSecretHex: transport.secretHex,
|
||||||
spirePubkey: seed.spirePubkey,
|
spirePubkey: seed.spirePubkey,
|
||||||
bunkerUrl: seed.bunkerUrl,
|
bunkerUrl: seed.bunkerUrl,
|
||||||
seedFingerprint: fingerprint,
|
seedFingerprint: fingerprint,
|
||||||
pairedAt: Math.floor(Date.now() / 1000),
|
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).
|
// Re-pair → re-publish the cassette-state hello to the new operator (#56).
|
||||||
await window.electronAPI.resetBootstrapGate()
|
await window.electronAPI.resetBootstrapGate()
|
||||||
}
|
}
|
||||||
return signer
|
return { signer, transport: transportFromSeed(seed) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// No seed in this boot but a binding survives → resume.
|
// No seed in this boot but a binding survives → resume.
|
||||||
if (binding) {
|
if (binding) {
|
||||||
console.log('[Signer] Resuming bunker session from stored binding (no seed this boot)')
|
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) {
|
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) : ''
|
const devKey = !isElectron ? (import.meta.env.VITE_ATM_PRIVATE_KEY as string | undefined) : ''
|
||||||
if (devKey) {
|
if (devKey) {
|
||||||
console.warn('[Signer] No bunker pairing — using LocalSigner from VITE_ATM_PRIVATE_KEY (dev)')
|
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)')
|
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()
|
throw new NoPairingError()
|
||||||
|
|
|
||||||
4
apps/machine/src/types/electron.d.ts
vendored
4
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -46,6 +46,10 @@ export interface BunkerBindingRecord {
|
||||||
bunkerUrl: string
|
bunkerUrl: string
|
||||||
seedFingerprint: string
|
seedFingerprint: string
|
||||||
pairedAt: number
|
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 {
|
export interface AtmSecrets {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue