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
25 changed files with 882 additions and 322 deletions

View file

@ -82,9 +82,9 @@ Renderer reads (Electron IPC or Vite `import.meta.env`):
| Var | Required | Notes | | 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_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` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) | | `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:<base64url>`) 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_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:<base64url>`) 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_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 | | `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands |

View file

@ -19,11 +19,15 @@ VITE_LAMASSU_FIAT_CODE=USD
# VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]' # 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. # Nostr relay WebSocket URL. Dev stack uses LNbits's bundled nostrrelay:
VITE_RELAY_URL=ws://localhost:7777 # VITE_RELAY_URL=ws://localhost:5001/nostrrelay/test
VITE_RELAY_URL=
# LNbits nostr-transport server pubkey (hex, 64 chars). # LNbits nostr-transport server pubkey (hex, 64 chars).
# Printed by the LNbits server on startup: # Printed by the LNbits server on startup:

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

@ -27,6 +27,7 @@ import {
getBootstrapPublishedAt, getBootstrapPublishedAt,
markBootstrapPublished, markBootstrapPublished,
resetBootstrapGate, resetBootstrapGate,
resetForRepair,
applyOperatorCassettesConfig, applyOperatorCassettesConfig,
getFeeConfig, getFeeConfig,
getLastKnownFeeConfigCreatedAt, getLastKnownFeeConfigCreatedAt,
@ -278,8 +279,11 @@ ipcMain.handle('watchdog:pong', () => {
// pragma: allowlist secret end // pragma: allowlist secret end
ipcMain.handle('get-config', () => { ipcMain.handle('get-config', () => {
return { return {
// LNbits nostr-transport connection (public info only) // LNbits nostr-transport connection (public info only). Empty when
relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777', // 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 || '', lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '',
appId: process.env.VITE_APP_ID || '', appId: process.env.VITE_APP_ID || '',
@ -352,6 +356,9 @@ ipcMain.handle('state:clear-bunker-binding', (): void => {
ipcMain.handle('state:reset-bootstrap-gate', (): void => { ipcMain.handle('state:reset-bootstrap-gate', (): void => {
resetBootstrapGate() resetBootstrapGate()
}) })
ipcMain.handle('state:reset-for-repair', (): void => {
resetForRepair()
})
// QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a // QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a
// spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the // spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the

View file

@ -17,10 +17,6 @@ export interface RuntimeConfig {
relayUrl: string relayUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */ /** LNbits nostr-transport server pubkey (hex, 64 chars). */
lnbitsServerPubkey: string lnbitsServerPubkey: string
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
lightningPubPubkey?: string
lightningPubApiUrl?: string
extensionApiUrl?: string
appId: string appId: string
machineModel: string machineModel: string
fiatCode: string fiatCode: string
@ -51,6 +47,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
} }
/** /**
@ -118,6 +118,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('state:save-bunker-binding', binding), ipcRenderer.invoke('state:save-bunker-binding', binding),
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'), clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'), resetBootstrapGate: (): Promise<void> => ipcRenderer.invoke('state:reset-bootstrap-gate'),
resetForRepair: (): Promise<void> => ipcRenderer.invoke('state:reset-for-repair'),
// QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed, // QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed,
// then relaunch so the normal boot flow pairs it. // then relaunch so the normal boot flow pairs it.
@ -239,6 +240,7 @@ declare global {
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void> saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void> clearBunkerBinding: () => Promise<void>
resetBootstrapGate: () => Promise<void> resetBootstrapGate: () => Promise<void>
resetForRepair: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void> saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void> relaunchApp: () => Promise<void>
applyOperatorCassettesConfig: ( applyOperatorCassettesConfig: (

View file

@ -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
) )
} }
@ -493,6 +540,32 @@ export function resetBootstrapGate(): void {
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
} }
/**
* Wipe operator-scoped CONFIG/TRUST state on a re-pair to a new operator/backend,
* so stale policy from the previous pairing can't linger or silently reject the
* new operator's config.
*
* Clears the fee config and resets BOTH replay watermarks to 0. The watermark
* reset is the load-bearing part: without it, a new backend whose first config
* event has a lower `created_at` than the old operator's last event is silently
* dropped as a replay — the exact remnant trap where re-pairing a long-lived
* install to a fresh backend appears to "work" but never picks up new config.
*
* Deliberately does NOT touch cassettes / cashbox / transactions: those track
* PHYSICAL cash, which survives an operator handover. A full wipe (decommission
* or a truly-fresh test) is the factory-reset path, not this.
*/
export function resetForRepair(): void {
if (!db) throw new Error('Database not initialized')
const database = db
database.transaction(() => {
database.prepare('DELETE FROM fee_config').run()
const setWatermark = database.prepare('UPDATE meta SET value = ? WHERE key = ?')
setWatermark.run('0', 'lastKnownFeeConfigCreatedAt')
setWatermark.run('0', 'lastKnownConfigCreatedAt')
})()
}
export type OperatorCassettesPayload = { export type OperatorCassettesPayload = {
positions: Record<string, { denomination: number; count: number }> positions: Record<string, { denomination: number; count: number }>
} }

View file

@ -103,10 +103,16 @@ onMounted(async () => {
try { try {
const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client') const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client')
const { resolveSigner } = await import('@/services/signer-resolver') 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). // 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
// 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) { 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()

View file

@ -10,15 +10,18 @@
* Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be * Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be
* offered later without changing this view. * offered later without changing this view.
*/ */
import { onMounted, onUnmounted, ref, shallowRef } from 'vue' import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import { import {
availablePairingSources, availablePairingSources,
ingestScannedSeed, ingestScannedSeed,
parseScannedSeed,
testRelay,
type PairingSource, type PairingSource,
type RelayTestResult,
type StopCapture, type StopCapture,
} from '@/services/pairing' } from '@/services/pairing'
type Phase = 'probing' | 'scanning' | 'no-source' | 'pairing' | 'error' type Phase = 'probing' | 'scanning' | 'review' | 'no-source' | 'pairing' | 'error'
const phase = ref<Phase>('probing') const phase = ref<Phase>('probing')
const errorMessage = ref('') const errorMessage = ref('')
@ -28,6 +31,19 @@ const sources = shallowRef<PairingSource[]>([])
const activeSource = shallowRef<PairingSource | null>(null) const activeSource = shallowRef<PairingSource | null>(null)
let stopCapture: StopCapture | null = 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<string[]>([])
type RelayState = { status: 'idle' | 'testing' | 'done'; result?: RelayTestResult }
const relayTests = ref<Record<string, RelayState>>({})
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) { async function startWith(source: PairingSource) {
await teardown() await teardown()
activeSource.value = source activeSource.value = source
@ -50,18 +66,58 @@ let handling = false
async function handleScan(raw: string) { async function handleScan(raw: string) {
if (handling) return if (handling) return
handling = true handling = true
const result = await ingestScannedSeed(raw) // Validate only — don't commit yet. Show a review step with the decoded
if (result.ok) { // relay + a "test relay" button so a well-formed but unreachable relay is
// saveSpireSeed succeeded; relaunch is in flight — hold a friendly screen. // caught before we relaunch into a pairing crash-loop (aiolabs/bitspire#70).
phase.value = 'pairing' 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 return
} }
// Reject non-seed scans (a stray QR) and resume scanning. // Reject non-seed / malformed scans (a stray QR, a corrupted relay) and resume.
console.warn('[Pairing] rejected scan:', result.reason, result.message) console.warn('[Pairing] rejected scan:', preview.reason, preview.message)
errorMessage.value = errorMessage.value = 'That code is not a valid pairing code. Show the operator pairing QR.'
result.reason === 'invalid-seed' handling = false
? 'That code is not a pairing code. Show the operator pairing QR.' if (activeSource.value) await startWith(activeSource.value)
: result.message }
/** 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 handling = false
if (activeSource.value) await startWith(activeSource.value) if (activeSource.value) await startWith(activeSource.value)
} }
@ -101,7 +157,17 @@ onUnmounted(teardown)
class="relative overflow-hidden rounded-2xl border-4 border-primary/40 bg-black" class="relative overflow-hidden rounded-2xl border-4 border-primary/40 bg-black"
style="width: min(80vw, 28rem); aspect-ratio: 1 / 1" style="width: min(80vw, 28rem); aspect-ratio: 1 / 1"
> >
<video ref="videoEl" class="h-full w-full object-cover" muted autoplay playsinline></video> <!-- The Sintra's camera is mounted rotated, so rotate the preview 90° CCW
for an upright image. Preview-only: qr-source decodes the raw (un-
rotated) frame and QR decoding is rotation-invariant. The container is
square + overflow-hidden, so the rotated square stays in the box. -->
<video
ref="videoEl"
class="h-full w-full -rotate-90 object-cover"
muted
autoplay
playsinline
></video>
<!-- Reticle --> <!-- Reticle -->
<div class="pointer-events-none absolute inset-6 rounded-xl border-2 border-white/70"></div> <div class="pointer-events-none absolute inset-6 rounded-xl border-2 border-white/70"></div>
</div> </div>
@ -121,6 +187,67 @@ onUnmounted(teardown)
<p class="text-base lg:text-2xl text-muted-foreground">Pairing accepted — restarting…</p> <p class="text-base lg:text-2xl text-muted-foreground">Pairing accepted — restarting…</p>
</div> </div>
<!-- Review: confirm the scanned relay is reachable before committing -->
<div v-if="phase === 'review'" class="flex w-full max-w-md flex-col items-center gap-5">
<p class="text-base lg:text-2xl text-muted-foreground">
Pairing code scanned. Test the relay, then pair.
</p>
<div class="w-full rounded-xl border border-border p-4 text-left">
<p class="text-xs uppercase text-muted-foreground">Spire</p>
<p class="mb-3 break-all font-mono text-sm">{{ previewSpire.slice(0, 16) }}…</p>
<p class="text-xs uppercase text-muted-foreground">Relay(s)</p>
<ul class="flex flex-col gap-2">
<li
v-for="url in previewRelays"
:key="url"
class="flex items-center justify-between gap-3"
>
<span class="break-all font-mono text-xs">{{ url }}</span>
<span class="shrink-0 text-sm">
<template v-if="relayTests[url]?.status === 'testing'">
<span class="text-muted-foreground">testing…</span>
</template>
<template v-else-if="relayTests[url]?.status === 'done'">
<span v-if="relayTests[url]?.result?.ok" class="text-green-500"
>✓ {{ relayTests[url]?.result?.ms }}ms</span
>
<span v-else class="text-destructive">✗ unreachable</span>
</template>
</span>
</li>
</ul>
</div>
<div class="flex flex-wrap justify-center gap-3">
<button
class="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"
:disabled="testingRelays || committing"
@click="testRelays"
>
{{ testingRelays ? 'Testing…' : 'Test relay' }}
</button>
<button
class="rounded-lg border border-border px-4 py-2 text-sm disabled:opacity-50"
:disabled="committing"
@click="rescan"
>
Rescan
</button>
<button
class="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
:disabled="committing"
@click="confirmPair"
>
{{ committing ? 'Pairing…' : 'Pair this machine' }}
</button>
</div>
<p v-if="anyRelayFailed" class="max-w-md text-center text-sm text-warning">
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.
</p>
</div>
<p <p
v-if="phase === 'no-source'" v-if="phase === 'no-source'"
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground" class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"

View file

@ -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,10 @@ 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. Matches
* the dev stack — LNbits's bundled nostrrelay (no separate strfry container). */
const DEV_DEFAULT_RELAY = 'ws://localhost:5001/nostrrelay/test'
/** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime. /** 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 +402,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 +414,53 @@ 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)' : '',
)
// Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS
// (env). An empty set disables the fees/operator-config services → the machine
// sits at "awaiting configuration" — so log it loudly rather than fail silent.
// (aiolabs/bitspire#70 P1 will source this from LNbits over the transport.)
console.log(
'[Lightning] Operator pubkey(s):',
CONFIG.operatorPubkeys.length
? CONFIG.operatorPubkeys.join(', ') + ' (env)'
: '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)',
)
// Strict mode: validate the RESOLVED config is production-ready (no
// localhost). Values may come from env or the pairing seed (#70).
if (options?.strict) { 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 +469,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 +489,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 +512,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

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest' import { describe, it, expect, vi, afterEach } from 'vitest'
import { ingestScannedSeed } from '../ingest' import { ingestScannedSeed } from '../ingest'
import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client' import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client'
import { npubEncode } from 'nostr-tools/nip19'
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */ /** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
function makeSeed(json: unknown): string { function makeSeed(json: unknown): string {
@ -15,9 +16,9 @@ function makeSeed(json: unknown): string {
const SPIRE_PUBKEY = 'a'.repeat(64) const SPIRE_PUBKEY = 'a'.repeat(64)
const VALID_SEED = makeSeed({ const VALID_SEED = makeSeed({
v: 1, v: 1,
spire_npub: 'npub1example', spire_npub: npubEncode(SPIRE_PUBKEY),
spire_pubkey: SPIRE_PUBKEY, lnbits_npub: npubEncode('b'.repeat(64)),
bunker_url: `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef`, bunker_secret: 'deadbeef',
relays: ['wss://events.relay/'], relays: ['wss://events.relay/'],
}) })

View file

@ -14,8 +14,10 @@ import type { PairingSource } from './types'
export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types' export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types'
export { QrPairingSource } from './qr-source' export { QrPairingSource } from './qr-source'
export { NfcPairingSource } from './nfc-source' export { NfcPairingSource } from './nfc-source'
export { ingestScannedSeed } from './ingest' export { ingestScannedSeed, parseScannedSeed } from './ingest'
export type { IngestResult } from './ingest' export type { IngestResult, SeedPreview } from './ingest'
export { testRelay } from './relay-test'
export type { RelayTestResult } from './relay-test'
/** All sources in preference order, regardless of availability. */ /** All sources in preference order, regardless of availability. */
export function allPairingSources(): PairingSource[] { export function allPairingSources(): PairingSource[] {

View file

@ -21,6 +21,37 @@ export type IngestResult =
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] } | { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
| { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string } | { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string }
export type SeedPreview =
| { ok: true; spirePubkey: string; fingerprint: string; relays: string[] }
| { ok: false; reason: 'invalid-seed'; message: string }
/**
* Validate-only: parse a scanned payload as a spire-seed WITHOUT persisting or
* relaunching. The wizard uses this to show a review step (decoded relay + a
* "test relay" button) before committing, so a well-formed but unreachable
* relay is caught before the machine relaunches into a pairing crash-loop.
* `parseSpireSeed` already rejects a malformed relay (e.g. a QR misread of
* `ws://` → `As://`); this surfaces that as an invalid-seed rejection.
*/
export function parseScannedSeed(raw: string): SeedPreview {
const trimmed = (raw || '').trim()
try {
const seed = parseSpireSeed(trimmed)
return {
ok: true,
spirePubkey: seed.spirePubkey,
fingerprint: seedFingerprint(trimmed),
relays: seed.relays,
}
} catch (e) {
return {
ok: false,
reason: 'invalid-seed',
message: e instanceof Error ? e.message : 'Not a valid pairing code',
}
}
}
export async function ingestScannedSeed(raw: string): Promise<IngestResult> { export async function ingestScannedSeed(raw: string): Promise<IngestResult> {
const trimmed = (raw || '').trim() const trimmed = (raw || '').trim()

View file

@ -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<RelayTestResult> {
return new Promise((resolve) => {
const start = Date.now()
let ws: WebSocket | null = null
let settled = false
const finish = (r: Omit<RelayTestResult, 'url'>): 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)' })
})
}

View file

@ -26,6 +26,7 @@ import {
parseSpireSeed, parseSpireSeed,
seedFingerprint, seedFingerprint,
type Signer, type Signer,
type SpireSeed,
} from '@bitSpire/nostr-client' } from '@bitSpire/nostr-client'
import type { BunkerBindingRecord } from '@/types/electron' import type { BunkerBindingRecord } from '@/types/electron'
@ -50,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
@ -64,20 +84,55 @@ 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> =>
resumeFromBinding({
clientSecretHex: b.clientSecretHex,
spirePubkey: b.spirePubkey,
bunkerUrl: b.bunkerUrl,
})
// Transport config from a binding — present only when the pairing seed
// carried it (post-#70) and it was persisted. Null on pre-#70 bindings.
const transportFromBinding = (b: BunkerBindingRecord): TransportConfig | null =>
b.relays && b.relays.length > 0 && b.lnbitsServerPubkey
? { relays: b.relays, lnbitsServerPubkey: b.lnbitsServerPubkey }
: null
const transportFromSeed = (s: SpireSeed): TransportConfig => ({
relays: s.relays,
lnbitsServerPubkey: s.lnbitsServerPubkey,
})
if (spireSeed) { if (spireSeed) {
const seed = parseSpireSeed(spireSeed) let seed: SpireSeed
const fingerprint = seedFingerprint(spireSeed) let fingerprint: string
try {
seed = parseSpireSeed(spireSeed)
fingerprint = seedFingerprint(spireSeed)
} catch (err) {
// A stored seed we can't parse — e.g. a legacy-shape seed left in .env
// after the seed format changed (bitspire-#70). If we already hold a
// binding it's authoritative (server-persistent), so resume from it
// rather than bricking a paired machine on the next boot. With no
// binding the seed is our only pairing input, so fail closed.
if (binding) {
console.warn(
'[Signer] Stored spire seed is unparseable; resuming from existing binding:',
(err as Error).message,
)
return { signer: await resume(binding), transport: transportFromBinding(binding) }
}
throw err
}
if (binding && binding.seedFingerprint === fingerprint) { 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 resumeFromBinding({ // Seed present + parsed → prefer its (fresh) transport config over the
clientSecretHex: binding.clientSecretHex, // binding's, which may predate the seed carrying transport (pre-#70).
spirePubkey: binding.spirePubkey, return { signer: await resume(binding), transport: transportFromSeed(seed) }
bunkerUrl: binding.bunkerUrl,
})
} }
// First pair or re-pair: redeem the one-shot connect secret. // First pair or re-pair: redeem the one-shot connect secret.
@ -89,27 +144,36 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer>
clientSecretHex: transport.secretHex, clientSecretHex: transport.secretHex,
}) })
if (isElectron && window.electronAPI) { if (isElectron && window.electronAPI) {
// Re-pair (a NEW seed replacing a prior binding) → wipe the previous
// operator's config/trust state (fee config + replay watermarks) so it
// can't linger or silently replay-block the new operator's config. A
// first pair (no prior binding) has nothing to reset. Cash accounting is
// preserved — see resetForRepair; a full wipe is the factory-reset path.
if (binding) {
console.log('[Signer] Re-pair (new seed fingerprint) — clearing prior operator config state')
await window.electronAPI.resetForRepair()
}
// Persist the seed's transport config alongside the binding so a later
// seedless resume still reaches the backend without env provisioning.
await window.electronAPI.saveBunkerBinding({ 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 resumeFromBinding({ return { signer: await resume(binding), transport: transportFromBinding(binding) }
clientSecretHex: binding.clientSecretHex,
spirePubkey: binding.spirePubkey,
bunkerUrl: binding.bunkerUrl,
})
} }
if (opts.allowEphemeral) { if (opts.allowEphemeral) {
@ -117,10 +181,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()

View file

@ -6,10 +6,6 @@ export interface RuntimeConfig {
relayUrl: string relayUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */ /** LNbits nostr-transport server pubkey (hex, 64 chars). */
lnbitsServerPubkey: string lnbitsServerPubkey: string
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
lightningPubPubkey?: string
lightningPubApiUrl?: string
extensionApiUrl?: string
appId: string appId: string
machineModel: string machineModel: string
fiatCode: string fiatCode: string
@ -46,6 +42,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 {
@ -98,6 +98,7 @@ declare global {
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void> saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void> clearBunkerBinding: () => Promise<void>
resetBootstrapGate: () => Promise<void> resetBootstrapGate: () => Promise<void>
resetForRepair: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void> saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void> relaunchApp: () => Promise<void>
applyOperatorCassettesConfig: ( applyOperatorCassettesConfig: (

View file

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { nip19 } from 'nostr-tools'
import { useAtmStore } from '@/stores/atm' import { useAtmStore } from '@/stores/atm'
import { useBranding } from '@/composables/useBranding' import { useBranding } from '@/composables/useBranding'
import { initialContext } from '@bitSpire/state-machine' import { initialContext } from '@bitSpire/state-machine'
@ -15,18 +14,8 @@ const atmStore = useAtmStore()
const { logoUrl, title: brandTitle } = useBranding() const { logoUrl, title: brandTitle } = useBranding()
const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || '' const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || ''
const showZeusQR = ref(false) const showZeusQR = ref(false)
const showLpQR = ref(false)
const copied = ref(false) const copied = ref(false)
// Build nprofile for Lightning.Pub (pubkey + relay hint)
const lpNprofile = computed(() => {
const pubkey = import.meta.env.VITE_LIGHTNING_PUB_PUBKEY
if (!pubkey) return ''
const relayUrl = import.meta.env.VITE_RELAY_URL
const relays = relayUrl ? [relayUrl.replace('ws://', 'wss://')] : []
return nip19.nprofileEncode({ pubkey, relays })
})
async function copyToClipboard(value: string) { async function copyToClipboard(value: string) {
try { try {
await navigator.clipboard.writeText(value) await navigator.clipboard.writeText(value)
@ -157,15 +146,6 @@ function handleCashOut() {
> >
Zeus QR (lnd-alice) Zeus QR (lnd-alice)
</Button> </Button>
<Button
v-if="lpNprofile"
variant="ghost"
size="sm"
class="text-xs text-muted-foreground"
@click="showLpQR = true"
>
Lightning.Pub nprofile
</Button>
</div> </div>
<!-- Zeus QR fullscreen overlay --> <!-- Zeus QR fullscreen overlay -->
@ -193,27 +173,6 @@ function handleCashOut() {
</div> </div>
</div> </div>
<!-- Lightning.Pub nprofile QR fullscreen overlay -->
<div
v-if="showLpQR"
class="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 bg-black/90 p-4"
@click.self="showLpQR = false"
>
<p class="text-sm text-white/70">Lightning.Pub nprofile</p>
<div class="rounded-2xl">
<QRCode :value="lpNprofile" :size="400" />
</div>
<code class="max-w-[90vw] truncate text-xs text-white/50">{{ lpNprofile }}</code>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" class="text-white" @click="copyToClipboard(lpNprofile)">
{{ copied ? 'Copied!' : 'Copy' }}
</Button>
<Button variant="outline" size="sm" class="text-white" @click="showLpQR = false">
Close
</Button>
</div>
</div>
<!-- Help button (top-left) --> <!-- Help button (top-left) -->
<Button <Button
variant="outline" variant="outline"

View file

@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { nip19 } from 'nostr-tools'
import { marked } from 'marked' import { marked } from 'marked'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
@ -23,35 +22,6 @@ import { QrCode, ExternalLink } from 'lucide-vue-next'
const router = useRouter() const router = useRouter()
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
// Lightning.Pub config loaded at runtime from Electron main process
const lpPubkey = ref('')
const relayUrl = ref('')
onMounted(async () => {
if (isElectron && window.electronAPI) {
const config = await window.electronAPI.getConfig()
lpPubkey.value = config.lightningPubPubkey || ''
relayUrl.value = config.relayUrl || ''
} else {
// Dev fallback: use Vite env vars
lpPubkey.value = import.meta.env.VITE_LIGHTNING_PUB_PUBKEY || ''
relayUrl.value = import.meta.env.VITE_RELAY_URL || ''
}
})
// Build nprofile for Lightning.Pub (pubkey + relay hint)
const lpNprofile = computed(() => {
if (!lpPubkey.value) return ''
const relays = relayUrl.value ? [relayUrl.value.replace('ws://', 'wss://')] : []
return nip19.nprofileEncode({ pubkey: lpPubkey.value, relays })
})
// Deep link URL: opens ShockWallet with this ATM's Lightning.Pub pre-filled
const shockwalletDeepLink = computed(() => {
if (!lpNprofile.value) return ''
return `https://wallet.aiolabs.dev/sources/add?nprofile=${encodeURIComponent(lpNprofile.value)}`
})
interface SupportPage { interface SupportPage {
id: string id: string
title: string title: string
@ -74,17 +44,11 @@ const defaultPages: SupportPage[] = [
| Blink | No | Partial | Yes | Yes | https://www.blink.sv | | Blink | No | Partial | Yes | Yes | https://www.blink.sv |
| Zeus | Yes | Yes | Yes | Yes | https://zeusln.com | | Zeus | Yes | Yes | Yes | Yes | https://zeusln.com |
| Breez | Yes | Yes | Yes | Yes | https://breez.technology | | Breez | Yes | Yes | Yes | Yes | https://breez.technology |
| ShockWallet | No | Yes | Yes | Yes | [shockwallet-deep-link] | | ShockWallet | No | Yes | Yes | Yes | https://shockwallet.app |
Tap a QR icon to scan and download a wallet. Tap a QR icon to scan and download a wallet.
**Non-custodial** means you hold your own keys and have full control of your Bitcoin. **KYC-free** means no identity verification is required. Partial (~) means limits apply without verification. **Non-custodial** means you hold your own keys and have full control of your Bitcoin. **KYC-free** means no identity verification is required. Partial (~) means limits apply without verification.`,
## Using ShockWallet with this ATM
Scan the QR code below to add this ATM's Lightning node to your ShockWallet. This lets you send and receive sats directly through the ATM's payment system.
[lp-nprofile]`,
}, },
{ {
id: 'faq', id: 'faq',
@ -153,7 +117,6 @@ type Segment =
| { type: 'qr'; content: string } | { type: 'qr'; content: string }
| { type: 'table'; table: ParsedTable } | { type: 'table'; table: ParsedTable }
| { type: 'qr-placeholder' } | { type: 'qr-placeholder' }
| { type: 'lp-nprofile' }
/** Parse markdown table into structured data */ /** Parse markdown table into structured data */
function parseMarkdownTable(tableLines: string[]): ParsedTable | null { function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
@ -176,17 +139,8 @@ function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
return { headers, rows } return { headers, rows }
} }
/** Resolve dynamic placeholders in markdown content */
function resolvePlaceholders(md: string): string {
return md.replace(
'[shockwallet-deep-link]',
shockwalletDeepLink.value || 'https://wallet.aiolabs.dev'
)
}
/** Parse content into segments: html, qr, or table */ /** Parse content into segments: html, qr, or table */
function parseContent(md: string): Segment[] { function parseContent(md: string): Segment[] {
md = resolvePlaceholders(md)
const segments: Segment[] = [] const segments: Segment[] = []
const lines = md.split('\n') const lines = md.split('\n')
let htmlBlock = '' let htmlBlock = ''
@ -235,9 +189,6 @@ function parseContent(md: string): Segment[] {
} else if (trimmed === '[operator-qr-placeholder]') { } else if (trimmed === '[operator-qr-placeholder]') {
flushHtml() flushHtml()
segments.push({ type: 'qr-placeholder' }) segments.push({ type: 'qr-placeholder' })
} else if (trimmed === '[lp-nprofile]') {
flushHtml()
segments.push({ type: 'lp-nprofile' })
} else { } else {
htmlBlock += line + '\n' htmlBlock += line + '\n'
} }
@ -375,33 +326,6 @@ onUnmounted(() => {
</CardContent> </CardContent>
</Card> </Card>
<!-- Lightning.Pub nprofile QR (scannable by ShockWallet) -->
<Card v-else-if="seg.type === 'lp-nprofile'" class="my-6 mx-auto max-w-xs">
<CardContent class="flex flex-col items-center gap-3 p-6">
<template v-if="lpNprofile">
<div class="rounded-xl bg-white p-3">
<QrcodeVue
:value="lpNprofile"
:size="180"
level="L"
render-as="svg"
background="#ffffff"
foreground="#000000"
/>
</div>
<span class="text-xs text-muted-foreground text-center px-2">
Scan with ShockWallet to connect
</span>
</template>
<template v-else>
<QrCode class="h-16 w-16 text-muted-foreground/30" />
<span class="text-sm text-muted-foreground/50 text-center">
Lightning.Pub not configured
</span>
</template>
</CardContent>
</Card>
<!-- Table with inline QR codes --> <!-- Table with inline QR codes -->
<div v-else-if="seg.type === 'table'" class="mb-8"> <div v-else-if="seg.type === 'table'" class="mb-8">
<Table class="text-sm sm:text-base lg:text-xl w-full"> <Table class="text-sm sm:text-base lg:text-xl w-full">

View file

@ -187,7 +187,7 @@ The `dev`-branch `flake.nix` pins the auto-upgrade source to `?ref=dev` so any A
```nix ```nix
system.autoUpgrade = { system.autoUpgrade = {
enable = true; 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"; dates = "04:00";
allowReboot = false; allowReboot = false;
}; };
@ -262,8 +262,8 @@ ls -la /dev/serial/by-id/
{ {
services.bitspire = { services.bitspire = {
enable = true; enable = true;
relayUrl = "wss://relay.aiolabs.dev"; # ATM ↔ LNbits relay relayUrl = ""; # seed-provided (#70); set to PIN a relay
lnbitsServerPubkey = "<64-hex>"; # LNbits transport pubkey lnbitsServerPubkey = ""; # seed-provided (#70); set to PIN a pubkey
appDir = "/opt/bitspire"; # rarely overridden — defaults via flake appDir = "/opt/bitspire"; # rarely overridden — defaults via flake
dataDir = "/var/lib/bitspire"; # rarely overridden dataDir = "/var/lib/bitspire"; # rarely overridden
logLevel = "info"; # error | warn | info | debug logLevel = "info"; # error | warn | info | debug

View file

@ -20,18 +20,17 @@ in
relayUrl = mkOption { relayUrl = mkOption {
type = types.str; type = types.str;
default = "wss://relay.aiolabs.dev"; default = "";
description = '' description = ''
Nostr relay URL the ATM and LNbits both subscribe to. Optional override for the Nostr relay the ATM uses. Empty by
default (aiolabs/bitspire#70): the relay comes from the pairing
On a fresh-boot disk image this value is seeded into SEED, not from provisioning — a fresh machine boots blank, scans a
`/var/lib/bitspire/.env` as `VITE_RELAY_URL=…` (see flake.nix spire-seed, and the seed's relay drives the connection. A non-empty
`bitspire-env` activation script). The operator can override value here is seeded into `/var/lib/bitspire/.env` as
the seeded value at runtime by editing `.env` directly or by `VITE_RELAY_URL=…` and WINS over the seed (env-first precedence), so
re-running `deploy/nixos/provision-atm.sh` with a different only set it to pin a machine to a specific relay. The renderer's
`RELAY_URL`. The renderer's resolution order is: resolution order is: `VITE_RELAY_URL` (this / .env) → the pairing
`/var/lib/bitspire/.env` → this NixOS default → renderer seed's relay → a dev-only `ws://localhost:7777` fallback.
hardcoded fallback (`ws://localhost:7777`).
''; '';
}; };
@ -39,10 +38,13 @@ in
type = types.str; type = types.str;
default = ""; default = "";
description = '' description = ''
LNbits nostr-transport server pubkey (hex, 64 chars). Published Optional override for the LNbits nostr-transport server pubkey
by the LNbits server on startup. Required for the ATM to talk (hex, 64 chars). Empty by default (aiolabs/bitspire#70): the
to its wallet. Provisioned by provision-atm.sh; can be left pubkey comes from the pairing SEED (the seed's `lnbits_npub`), so
empty on disk-image builds. 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`.
''; '';
}; };
@ -141,11 +143,14 @@ in
"d ${cfg.dataDir}/branding 0755 bitspire bitspire -" "d ${cfg.dataDir}/branding 0755 bitspire bitspire -"
]; ];
# Environment file for ATM configuration # Descriptive-only ATM info at /etc/bitspire/config.env. NOTE: this is NOT
# the runtime environment — the systemd service's EnvironmentFile is
# mkForce'd to /var/lib/bitspire/.env, and the renderer reads only VITE_*
# vars. Relay + server pubkey are deliberately omitted here: they come from
# the pairing seed (aiolabs/bitspire#70), and duplicating them as non-VITE
# RELAY_URL/LNBITS_SERVER_PUBKEY only invited "looks authoritative" confusion.
environment.etc."bitspire/config.env".text = '' environment.etc."bitspire/config.env".text = ''
# bitSpire ATM Configuration # bitSpire ATM Configuration (descriptive; not the runtime env)
RELAY_URL=${cfg.relayUrl}
LNBITS_SERVER_PUBKEY=${cfg.lnbitsServerPubkey}
LOG_LEVEL=${cfg.logLevel} LOG_LEVEL=${cfg.logLevel}
DATA_DIR=${cfg.dataDir} DATA_DIR=${cfg.dataDir}

View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Factory-reset a bitSpire ATM to a truly-fresh state — the deterministic way to
# reproduce a brand-new machine so tests aren't masked by leftover env/db values
# (aiolabs/bitspire#70 remnant hygiene).
#
# WIPES:
# - /var/lib/bitspire/state.db (bunker binding, fee config, cassettes, cashbox,
# transactions, operator commands, replay watermarks — recreated on next boot)
# - /var/lib/bitspire/.env (truncated to the minimal image-baked template:
# machine model + fiat + display; drops relay, server pubkey, operator pubkey
# and any stored spire seed)
#
# After this the ATM boots UNPAIRED into the pairing wizard, exactly like a fresh
# disk image — so a scanned seed is the sole source of truth.
#
# Usage:
# bash factory-reset-atm.sh # SSH to localhost:2222 (QEMU)
# bash factory-reset-atm.sh 192.168.1.50 # a real ATM on the LAN
# bash factory-reset-atm.sh 192.168.1.50 22 # custom SSH port
# FORCE=1 bash factory-reset-atm.sh … # skip the confirmation prompt
# ATM_USER=root bash factory-reset-atm.sh … # override SSH user (default: bitspire)
set -euo pipefail
ATM_HOST="${1:-localhost}"
ATM_SSH_PORT="${2:-2222}"
ATM_USER="${ATM_USER:-bitspire}"
echo "=== Factory-reset bitSpire ATM at $ATM_USER@$ATM_HOST:$ATM_SSH_PORT ==="
echo "This WIPES state.db and truncates .env to the minimal template (keeps only"
echo "machine model + fiat). ALL pairing, cash accounting, and transaction history"
echo "on the ATM will be lost."
if [ "${FORCE:-}" != "1" ]; then
read -r -p "Type 'yes' to proceed: " confirm
[ "$confirm" = "yes" ] || { echo "Aborted."; exit 1; }
fi
ssh -o StrictHostKeyChecking=no -p "$ATM_SSH_PORT" "$ATM_USER@$ATM_HOST" 'sudo bash -s' <<'REMOTE'
set -euo pipefail
ENV=/var/lib/bitspire/.env
DB=/var/lib/bitspire/state.db
# Preserve model + fiat from the existing .env (fall back to sintra/EUR).
model=$(grep -E '^VITE_LAMASSU_MACHINE_MODEL=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
fiat=$(grep -E '^VITE_LAMASSU_FIAT_CODE=' "$ENV" 2>/dev/null | cut -d= -f2- || true)
model=${model:-sintra}
fiat=${fiat:-EUR}
systemctl stop bitspire 2>/dev/null || true
# Wipe persisted state (db + WAL/SHM sidecars).
rm -f "$DB" "$DB-wal" "$DB-shm"
# Truncate .env to the minimal image-baked template.
cat > "$ENV" <<EOF
VITE_LAMASSU_MACHINE_MODEL=$model
VITE_LAMASSU_FIAT_CODE=$fiat
VITE_SPIRE_SEED=
ELECTRON_FORCE_PROD=1
DISPLAY=:0
EOF
chmod 600 "$ENV"
chown bitspire:bitspire "$ENV" 2>/dev/null || true
systemctl start bitspire 2>/dev/null || true
echo "--- .env is now (values blanked) ---"
sed -E 's/=.*/=/' "$ENV"
echo "--- state.db removed (recreated fresh on next boot) ---"
REMOTE
echo ""
echo "=== ATM factory-reset. It boots UNPAIRED → the pairing wizard. ==="
echo "Watch: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"

View file

@ -21,18 +21,17 @@ let
batm3 = "USD"; batm3 = "USD";
}.${machineModel} or "USD"; }.${machineModel} or "USD";
# .env template — runtime secrets are provisioned later via provision-atm.sh. # Minimal .env template (aiolabs/bitspire#70 remnant hygiene). Seed ONLY
# Only non-secret defaults and display vars go here. VITE_SPIRE_SEED (the # image-baked, non-maskable values. Relay + server pubkey come from the pairing
# NIP-46 bunker pairing seed) is written at provision time; the dev-only # SEED, operator pubkey + fee config come from LNbits over the transport — so we
# VITE_ATM_PRIVATE_KEY fallback is omitted here on purpose. # deliberately do NOT pre-seed those keys (a present-but-empty VITE_RELAY_URL /
# VITE_LNBITS_SERVER_PUBKEY / VITE_OPERATOR_PUBKEYS would win over the seed and
# mask its source). VITE_SPIRE_SEED is written by the wizard / provision-atm.sh;
# the dev-only VITE_ATM_PRIVATE_KEY fallback is omitted on purpose.
envTemplate = pkgs.writeText "bitspire-env" '' envTemplate = pkgs.writeText "bitspire-env" ''
VITE_RELAY_URL=
VITE_LNBITS_SERVER_PUBKEY=
VITE_SPIRE_SEED=
VITE_APP_ID=
VITE_OPERATOR_PUBKEYS=
VITE_LAMASSU_MACHINE_MODEL=${machineModel} VITE_LAMASSU_MACHINE_MODEL=${machineModel}
VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel} VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel}
VITE_SPIRE_SEED=
ELECTRON_FORCE_PROD=1 ELECTRON_FORCE_PROD=1
DISPLAY=:0 DISPLAY=:0
''; '';

View file

@ -4,19 +4,22 @@
# kind-21000 NIP-44 v2 events on a relay — there is no out-of-band token, # 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 # the ATM's nostr private key IS the credential. # pragma: allowlist secret
# #
# Required environment variables (or edit defaults below): # The primary input is SPIRE_SEED — the pairing seed carries the relay, the
# LNBITS_SERVER_PUBKEY Hex pubkey published by the LNbits server at startup. # LNbits server pubkey AND the signing identity, so a seed-provisioned machine
# From the LNbits compose: # needs nothing else (aiolabs/bitspire#70).
# docker logs lnbits | grep 'nostr_transport pubkey' #
# LNBITS_HTTP_URL Origin LNbits is reachable at over HTTP, used only # Environment variables:
# to compose the LNURL-withdraw callback URL that # SPIRE_SEED RECOMMENDED. The spire pairing seed
# customer wallets dereference. Default: http://10.0.2.2:5000 # (`spire-seed:v1:<base64url>`) minted by spirekeeper.
# RELAY_URL Nostr relay LNbits + the bunker subscribe on. # Carries relay + LNbits server pubkey + the production
# Default: ws://$HOST_IP:5001/nostrrelay/test (LNbits # identity under the NIP-46 bunker (aiolabs/bitspire#52 / #70).
# bundled nostrrelay). Override for a separate relay. # RELAY_URL OPTIONAL override — pins VITE_RELAY_URL and WINS over the
# SPIRE_SEED The spire pairing seed (`spire-seed:v1:<base64url>`) # seed's relay (env-first precedence). Leave unset to let the
# minted by spirekeeper. THIS is the production # seed drive it. Required only on the no-seed dev path
# identity under the NIP-46 bunker (aiolabs/bitspire#52). # (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 # ATM_PRIVATE_KEY DEV-ONLY 32-byte hex nsec fallback, used only when
# SPIRE_SEED is unset (no bunker). Generated if unset # SPIRE_SEED is unset (no bunker). Generated if unset
# AND no SPIRE_SEED is provided. # AND no SPIRE_SEED is provided.
@ -61,40 +64,51 @@ else
echo "--- LAN ATM: using $HOST_IP as dev machine address ---" echo "--- LAN ATM: using $HOST_IP as dev machine address ---"
fi fi
# Step 2: Resolve the LNbits server pubkey. Prefer the env override; else # Steps 2-4: transport config (relay + LNbits server pubkey) + signing identity.
# fall back to scraping the local docker compose stack. #
if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then # Under aiolabs/bitspire#70 the relay + server pubkey come from the pairing SEED,
echo "" # so a seed-provisioned machine needs NEITHER in .env. We only pin them when the
echo "--- Step 1: Extracting LNbits nostr-transport pubkey from docker logs ---" # operator EXPLICITLY passes RELAY_URL / LNBITS_SERVER_PUBKEY (a deliberate
LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \ # override that WINS over the seed via env-first precedence), or when there is no
| grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \ # seed (the dev-nsec fallback has nothing else to supply them, so we scrape/default).
| tail -1 || true) TRANSPORT_LINES=""
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}..."
# 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 if [ -n "${SPIRE_SEED:-}" ]; then
echo ""
echo "--- Using spire pairing seed (bunker-backed identity) ---"
case "$SPIRE_SEED" in case "$SPIRE_SEED" in
spire-seed:v1:*) : ;; spire-seed:v1:*) : ;;
*) echo "ERROR: SPIRE_SEED must start with 'spire-seed:v1:'"; exit 1 ;; *) echo "ERROR: SPIRE_SEED must start with 'spire-seed:v1:'"; exit 1 ;;
esac 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) IDENTITY_LINES="# Spire pairing seed — bunker-backed identity (aiolabs/bitspire#52)
VITE_SPIRE_SEED=$SPIRE_SEED" VITE_SPIRE_SEED=$SPIRE_SEED"
else 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 if [ -z "${ATM_PRIVATE_KEY:-}" ]; then
ATM_PRIVATE_KEY=$(openssl rand -hex 32) ATM_PRIVATE_KEY=$(openssl rand -hex 32)
echo "" echo ""
@ -110,10 +124,10 @@ echo "--- Step 2: Writing .env to ATM ---"
ENV_CONTENT="# bitSpire Configuration ENV_CONTENT="# bitSpire Configuration
# Auto-generated by provision-atm.sh on $(date -Iseconds) # Auto-generated by provision-atm.sh on $(date -Iseconds)
# LNbits nostr-transport connection # LNbits nostr-transport. Relay + server pubkey come from the pairing seed
VITE_RELAY_URL=$RELAY_URL # (aiolabs/bitspire#70); present below only as an explicit override or the
VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY # no-seed dev fallback.
VITE_LNBITS_HTTP_URL=$LNBITS_HTTP_URL $TRANSPORT_LINES
$IDENTITY_LINES $IDENTITY_LINES
@ -132,6 +146,6 @@ echo ""
echo "=== ATM provisioned successfully ===" echo "=== ATM provisioned successfully ==="
echo "" echo ""
echo "Credentials written to /var/lib/bitspire/.env" 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 ""
echo "To check status: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'" echo "To check status: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"

View file

@ -186,29 +186,34 @@
allowReboot = false; allowReboot = false;
}; };
# Env template — runtime secrets provisioned via provision-atm.sh. # Minimal env template (aiolabs/bitspire#70 remnant hygiene).
# Identity fields are intentionally empty so a fresh disk image # Seed ONLY image-baked, non-maskable values. Everything else the
# boots cleanly into the "needs provisioning" state; provision- # ATM needs comes from the pairing SEED (relay, lnbits_npub, bunker)
# atm.sh SSHes in and overwrites with real values. # or from LNbits over the transport (operator pubkey, fee config) —
# so we must NOT pre-seed those keys. A present-but-empty
# VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY / VITE_OPERATOR_PUBKEYS
# is a masking hazard: env WINS over the seed, and this activation
# only writes when .env is ABSENT, so any value written at first
# boot is frozen for the life of the disk. Leaving the keys out
# entirely lets the seed/transport be the sole source.
# #
# VITE_RELAY_URL seeds from `config.services.bitspire.relayUrl` # VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY are emitted ONLY when
# so the NixOS module's `relayUrl` option becomes the default # the operator deliberately pins them via the Nix options (non-empty
# without losing the operator's ability to override via .env # default ""), which is an explicit override that wins over the seed.
# (edit the file or re-run provision-atm.sh).
system.activationScripts.bitspire-env = '' system.activationScripts.bitspire-env = ''
mkdir -p /var/lib/bitspire mkdir -p /var/lib/bitspire
if [ ! -f /var/lib/bitspire/.env ]; then if [ ! -f /var/lib/bitspire/.env ]; then
cp ${pkgs.writeText "bitspire-env-default" '' cp ${pkgs.writeText "bitspire-env-default" (''
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
VITE_LNBITS_SERVER_PUBKEY=
VITE_SPIRE_SEED=
VITE_APP_ID=
VITE_OPERATOR_PUBKEYS=
VITE_LAMASSU_MACHINE_MODEL=${machineModel} VITE_LAMASSU_MACHINE_MODEL=${machineModel}
VITE_LAMASSU_FIAT_CODE=${fiatCode} VITE_LAMASSU_FIAT_CODE=${fiatCode}
VITE_SPIRE_SEED=
ELECTRON_FORCE_PROD=1 ELECTRON_FORCE_PROD=1
DISPLAY=:0 DISPLAY=:0
''} /var/lib/bitspire/.env '' + pkgs.lib.optionalString (config.services.bitspire.relayUrl != "") ''
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
'' + pkgs.lib.optionalString (config.services.bitspire.lnbitsServerPubkey != "") ''
VITE_LNBITS_SERVER_PUBKEY=${config.services.bitspire.lnbitsServerPubkey}
'')} /var/lib/bitspire/.env
chmod 600 /var/lib/bitspire/.env chmod 600 /var/lib/bitspire/.env
chown bitspire:bitspire /var/lib/bitspire/.env chown bitspire:bitspire /var/lib/bitspire/.env
fi fi

View file

@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { npubEncode } from 'nostr-tools/nip19'
import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js' import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js'
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */ /** 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 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 = { const VALID = {
v: 1, v: 1,
spire_npub: 'npub1example', spire_npub: SPIRE_NPUB,
spire_pubkey: SPIRE_PUBKEY, lnbits_npub: LNBITS_NPUB,
bunker_url: BUNKER_URL, bunker_secret: 'deadbeef',
relays: ['wss://events.relay/'], relays: ['wss://events.relay/'],
} }
describe('parseSpireSeed', () => { 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)) const seed = parseSpireSeed(makeSeed(VALID))
expect(seed).toEqual({ expect(seed).toEqual({
v: 1, v: 1,
spirePubkey: SPIRE_PUBKEY, spirePubkey: SPIRE_PUBKEY,
bunkerUrl: BUNKER_URL, lnbitsServerPubkey: LNBITS_PUBKEY,
bunkerUrl: `bunker://${SPIRE_PUBKEY}?relay=${encodeURIComponent('wss://events.relay/')}&secret=deadbeef`,
relays: ['wss://events.relay/'], relays: ['wss://events.relay/'],
}) })
}) })
it('re-pads stripped base64url of any residue length', () => { it('defaults the bunker relay to relays[0] when bunker_relay is absent', () => {
// Vary a field so the encoded payload lands on each mod-4 residue. const seed = parseSpireSeed(makeSeed(VALID))
for (const suffix of ['', 'a', 'ab', 'abc']) { expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://events.relay/')}`)
const seed = makeSeed({ ...VALID, spire_npub: `npub1${suffix}` })
expect(() => parseSpireSeed(seed)).not.toThrow()
}
}) })
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)) const seed = parseSpireSeed(makeSeed(VALID))
expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F') expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F')
expect(seed.bunkerUrl).toContain('secret=deadbeef') 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([ it.each([
['wrong scheme', 'spire-seed:v2:abc'], ['wrong scheme', 'spire-seed:v2:abc'],
['not a seed', 'bunker://whatever'], ['not a seed', 'bunker://whatever'],
@ -56,10 +72,18 @@ describe('parseSpireSeed', () => {
it.each([ it.each([
['bad version', { ...VALID, v: 2 }], ['bad version', { ...VALID, v: 2 }],
['short pubkey', { ...VALID, spire_pubkey: 'abc' }], ['missing spire_npub', { ...VALID, spire_npub: undefined }],
['non-bunker url', { ...VALID, bunker_url: 'https://evil/' }], ['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: [] }], ['empty relays', { ...VALID, relays: [] }],
['non-string relay', { ...VALID, relays: [123] }], ['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) => { ])('rejects %s', (_label, json) => {
expect(() => parseSpireSeed(makeSeed(json))).toThrow() expect(() => parseSpireSeed(makeSeed(json))).toThrow()
}) })

View file

@ -3,44 +3,70 @@
* *
* The operator dashboard (aiolabs/spirekeeper `pairing.py`) hands each ATM a * The operator dashboard (aiolabs/spirekeeper `pairing.py`) hands each ATM a
* one-time seed URL that encodes the bunker connection + the spire's signing * 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:<base64url(json, no padding)> * spire-seed:v1:<base64url(json, no padding)>
* json = { * json = {
* "v": 1, * "v": 1,
* "spire_npub": "npub1…", // informational, ignored here * "spire_npub": "npub1…", // spire signing identity (bech32; hex derived)
* "spire_pubkey": "<64-hex>", // the spire's bunker-held signing identity * "lnbits_npub": "npub1…", // LNbits nostr-transport server identity
* "bunker_url": "bunker://<spire_pubkey_hex>?relay=<url>&secret=<sec>", * "bunker_secret": "<sec>", // one-shot NIP-46 connect token
* "relays": ["wss://…"] // relays for the spire's OWN events (21000/30078) * "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 * Design (see aiolabs/bitspire#70): the pubkey is carried ONCE, as an npub.
* of 4 before decoding. * The old shape spelled it three times (spire_npub + spire_pubkey hex + inside
* - `relay` / `secret` inside `bunker_url` are percent-encoded; decoding them * a full bunker_url), which bloats a QR that's already hard to scan. Here:
* is left to nostr-tools `parseBunkerInput` (see bunker-signer.ts), so we *
* keep `bunker_url` verbatim. * - `spire_pubkey` (hex) is derived from `spire_npub` (npub is ~the same length
* - `bunker_url`'s relay is the BUNKER relay; `relays[]` is where the spire * as hex but carries a bech32 checksum — real error-detection for a value
* publishes its own events. They may differ — both must be spire-reachable. * 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 { sha256 } from '@noble/hashes/sha2.js'
import { bytesToHex } from 'nostr-tools/utils' import { bytesToHex } from 'nostr-tools/utils'
import { decode as nip19Decode } from 'nostr-tools/nip19'
export const SPIRE_SEED_SCHEME = 'spire-seed:v1:' export const SPIRE_SEED_SCHEME = 'spire-seed:v1:'
export interface SpireSeed { export interface SpireSeed {
/** Seed format version (always 1 for this scheme). */ /** Seed format version (always 1 for this scheme). */
v: number 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 spirePubkey: string
/** `bunker://<pubkey>?relay=&secret=` — handed to nostr-tools parseBunkerInput. */ /** `bunker://<pubkey>?relay=&secret=` — reconstructed, handed to parseBunkerInput. */
bunkerUrl: string bunkerUrl: string
/** Relays where the spire publishes its own events (kind 21000 / 30078). */ /** Relays where the spire publishes its own events (kind 21000 / 30078). */
relays: string[] relays: string[]
/** LNbits nostr-transport server pubkey — 64-char hex, derived from `lnbits_npub`. */
lnbitsServerPubkey: string
} }
const HEX64 = /^[0-9a-f]{64}$/ 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. */ /** Decode an unpadded base64url string in both browser and Node. */
function base64urlDecode(input: string): string { function base64urlDecode(input: string): string {
const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '=') const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '=')
@ -50,6 +76,23 @@ function base64urlDecode(input: string): string {
return Buffer.from(padded, 'base64').toString('binary') 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<typeof nip19Decode>
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 — * 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 * the seed is a trust root, so we fail closed rather than connect to a
@ -77,22 +120,40 @@ export function parseSpireSeed(seedUrl: string): SpireSeed {
throw new Error(`parseSpireSeed: unsupported version ${String(obj.v)}`) throw new Error(`parseSpireSeed: unsupported version ${String(obj.v)}`)
} }
const spirePubkey = obj.spire_pubkey const spirePubkey = hexFromNpub(obj.spire_npub, 'spire_npub')
if (typeof spirePubkey !== 'string' || !HEX64.test(spirePubkey)) { const lnbitsServerPubkey = hexFromNpub(obj.lnbits_npub, 'lnbits_npub')
throw new Error('parseSpireSeed: spire_pubkey must be 64-char hex')
}
const bunkerUrl = obj.bunker_url const bunkerSecret = obj.bunker_secret
if (typeof bunkerUrl !== 'string' || !bunkerUrl.startsWith('bunker://')) { if (typeof bunkerSecret !== 'string' || bunkerSecret.length === 0) {
throw new Error('parseSpireSeed: bunker_url must be a bunker:// URL') throw new Error('parseSpireSeed: bunker_secret must be a non-empty string')
} }
const relays = obj.relays const relays = obj.relays
if (!Array.isArray(relays) || relays.length === 0 || !relays.every((r) => typeof r === 'string')) { 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') throw new Error('parseSpireSeed: relays must be a non-empty string array')
} }
relays.forEach((r, i) => assertRelayUrl(r as string, `relays[${i}]`))
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')
}
assertRelayUrl(obj.bunker_relay, 'bunker_relay')
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 }
} }
/** /**