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>
This commit is contained in:
Padreug 2026-07-01 21:09:50 +02:00 • committed by padreug
commit 883c599835
7 changed files with 224 additions and 33 deletions

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 {