Compare commits
18 commits
fdb9a507c2
...
9d0e8d8fea
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d0e8d8fea | |||
| 0cc48652aa | |||
| 2af3e29f15 | |||
| 4745790b40 | |||
| 1877959ab5 | |||
| 5f5257daa6 | |||
| 4a45181d12 | |||
| ee2e71d543 | |||
| a8b8b707be | |||
| f003483599 | |||
| 78804b4f89 | |||
| e20faa61ff | |||
| 675b6bfb7c | |||
| d094cf090c | |||
| 1e2a653ad1 | |||
| e57868020b | |||
| 549491a432 | |||
| 53a0c2db51 |
27 changed files with 961 additions and 322 deletions
|
|
@ -82,9 +82,9 @@ Renderer reads (Electron IPC or Vite `import.meta.env`):
|
|||
|
||||
| Var | Required | Notes |
|
||||
|---|---|---|
|
||||
| `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) |
|
||||
| `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) |
|
||||
| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:<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_RELAY_URL` | no (seed-provided) | Relay both ATM and LNbits subscribe to. **Comes from the pairing seed** (aiolabs/bitspire#70); set this only as an override — it WINS over the seed via env-first precedence. Dev override: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) |
|
||||
| `VITE_LNBITS_SERVER_PUBKEY` | no (seed-provided) | 64-char hex transport pubkey. **Comes from the seed's `lnbits_npub`** (#70); env override only. LNbits prints it on startup (`docker logs lnbits \| grep 'Public key (share this)'`) |
|
||||
| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:<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_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands |
|
||||
|
||||
|
|
|
|||
|
|
@ -19,11 +19,15 @@ VITE_LAMASSU_FIAT_CODE=USD
|
|||
# VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]'
|
||||
|
||||
# =============================================================================
|
||||
# LNbits Connection (Required) — nostr-native-transport
|
||||
# LNbits Connection (dev override — normally seed-provided) — nostr-native-transport
|
||||
# =============================================================================
|
||||
# On a real machine the pairing SEED (VITE_SPIRE_SEED) carries the relay AND the
|
||||
# server pubkey (aiolabs/bitspire#70), so leave both blank there. Set them here
|
||||
# only for browser dev without a seed/bunker — they WIN over the seed.
|
||||
|
||||
# Nostr relay WebSocket URL — relay LNbits is subscribed to.
|
||||
VITE_RELAY_URL=ws://localhost:7777
|
||||
# Nostr relay WebSocket URL. Dev stack uses LNbits's bundled nostrrelay:
|
||||
# VITE_RELAY_URL=ws://localhost:5001/nostrrelay/test
|
||||
VITE_RELAY_URL=
|
||||
|
||||
# LNbits nostr-transport server pubkey (hex, 64 chars).
|
||||
# Printed by the LNbits server on startup:
|
||||
|
|
|
|||
69
apps/machine/electron/__tests__/state-store-bunker.test.ts
Normal file
69
apps/machine/electron/__tests__/state-store-bunker.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Tests for bunker-binding persistence in state-store (aiolabs/bitspire#52,
|
||||
* transport config added in #70).
|
||||
*
|
||||
* Validates the round-trip of the binding singleton, including the v11→v12
|
||||
* transport columns (relays JSON + lnbits_server_pubkey) and their absence on
|
||||
* a pre-#70 binding.
|
||||
*
|
||||
* Uses an in-memory SQLite database — fresh per test, no on-disk artifacts.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearBunkerBinding,
|
||||
closeDatabase,
|
||||
getBunkerBinding,
|
||||
initDatabase,
|
||||
saveBunkerBinding,
|
||||
type StoredBunkerBinding,
|
||||
} from '../state-store.js'
|
||||
|
||||
const BASE: StoredBunkerBinding = {
|
||||
clientSecretHex: 'aa'.repeat(32),
|
||||
spirePubkey: 'bb'.repeat(32),
|
||||
bunkerUrl: 'bunker://bb?relay=wss%3A%2F%2Fr%2F&secret=deadbeef',
|
||||
seedFingerprint: 'cc'.repeat(32),
|
||||
pairedAt: 1_780_000_000,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
initDatabase(':memory:')
|
||||
})
|
||||
afterEach(() => {
|
||||
closeDatabase()
|
||||
})
|
||||
|
||||
describe('bunker binding persistence', () => {
|
||||
it('round-trips a binding carrying transport config (#70)', () => {
|
||||
const binding: StoredBunkerBinding = {
|
||||
...BASE,
|
||||
relays: ['wss://one.relay/', 'wss://two.relay/'],
|
||||
lnbitsServerPubkey: 'dd'.repeat(32),
|
||||
}
|
||||
saveBunkerBinding(binding)
|
||||
expect(getBunkerBinding()).toEqual(binding)
|
||||
})
|
||||
|
||||
it('round-trips a pre-#70 binding (no transport config) as undefined fields', () => {
|
||||
saveBunkerBinding(BASE)
|
||||
const got = getBunkerBinding()
|
||||
expect(got).toEqual(BASE)
|
||||
expect(got?.relays).toBeUndefined()
|
||||
expect(got?.lnbitsServerPubkey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('upserts transport config in place (re-pair overwrites)', () => {
|
||||
saveBunkerBinding({ ...BASE, relays: ['wss://old/'], lnbitsServerPubkey: 'ee'.repeat(32) })
|
||||
saveBunkerBinding({ ...BASE, relays: ['wss://new/'], lnbitsServerPubkey: 'ff'.repeat(32) })
|
||||
const got = getBunkerBinding()
|
||||
expect(got?.relays).toEqual(['wss://new/'])
|
||||
expect(got?.lnbitsServerPubkey).toBe('ff'.repeat(32))
|
||||
})
|
||||
|
||||
it('returns null after clear', () => {
|
||||
saveBunkerBinding(BASE)
|
||||
clearBunkerBinding()
|
||||
expect(getBunkerBinding()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
getBootstrapPublishedAt,
|
||||
markBootstrapPublished,
|
||||
resetBootstrapGate,
|
||||
resetForRepair,
|
||||
applyOperatorCassettesConfig,
|
||||
getFeeConfig,
|
||||
getLastKnownFeeConfigCreatedAt,
|
||||
|
|
@ -278,8 +279,11 @@ ipcMain.handle('watchdog:pong', () => {
|
|||
// pragma: allowlist secret end
|
||||
ipcMain.handle('get-config', () => {
|
||||
return {
|
||||
// LNbits nostr-transport connection (public info only)
|
||||
relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777',
|
||||
// LNbits nostr-transport connection (public info only). Empty when
|
||||
// unprovisioned — the renderer then falls through to the pairing seed's
|
||||
// relay (aiolabs/bitspire#70). A non-empty default here would win via the
|
||||
// env-first precedence and override the seed.
|
||||
relayUrl: process.env.VITE_RELAY_URL || '',
|
||||
lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '',
|
||||
appId: process.env.VITE_APP_ID || '',
|
||||
|
||||
|
|
@ -352,6 +356,9 @@ ipcMain.handle('state:clear-bunker-binding', (): void => {
|
|||
ipcMain.handle('state:reset-bootstrap-gate', (): void => {
|
||||
resetBootstrapGate()
|
||||
})
|
||||
ipcMain.handle('state:reset-for-repair', (): void => {
|
||||
resetForRepair()
|
||||
})
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -17,10 +17,6 @@ export interface RuntimeConfig {
|
|||
relayUrl: string
|
||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||
lnbitsServerPubkey: string
|
||||
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
|
||||
lightningPubPubkey?: string
|
||||
lightningPubApiUrl?: string
|
||||
extensionApiUrl?: string
|
||||
appId: string
|
||||
machineModel: string
|
||||
fiatCode: string
|
||||
|
|
@ -51,6 +47,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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -118,6 +118,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
ipcRenderer.invoke('state:save-bunker-binding', binding),
|
||||
clearBunkerBinding: (): Promise<void> => ipcRenderer.invoke('state:clear-bunker-binding'),
|
||||
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,
|
||||
// then relaunch so the normal boot flow pairs it.
|
||||
|
|
@ -239,6 +240,7 @@ declare global {
|
|||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||
clearBunkerBinding: () => Promise<void>
|
||||
resetBootstrapGate: () => Promise<void>
|
||||
resetForRepair: () => Promise<void>
|
||||
saveSpireSeed: (seed: string) => Promise<void>
|
||||
relaunchApp: () => Promise<void>
|
||||
applyOperatorCassettesConfig: (
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import fs from 'node:fs'
|
|||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
const SCHEMA_VERSION = '11'
|
||||
const SCHEMA_VERSION = '12'
|
||||
|
||||
function getDbPath(): string {
|
||||
const prodDir = '/var/lib/bitspire'
|
||||
|
|
@ -121,7 +121,9 @@ export function initDatabase(dbPath?: string): void {
|
|||
spire_pubkey TEXT NOT NULL,
|
||||
bunker_url TEXT NOT NULL,
|
||||
seed_fingerprint TEXT NOT NULL,
|
||||
paired_at INTEGER NOT NULL
|
||||
paired_at INTEGER NOT NULL,
|
||||
relays TEXT,
|
||||
lnbits_server_pubkey TEXT
|
||||
);
|
||||
`)
|
||||
|
||||
|
|
@ -352,6 +354,21 @@ export function initDatabase(dbPath?: string): void {
|
|||
`)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)')
|
||||
existing.value = '11'
|
||||
}
|
||||
|
||||
if (existing && existing.value === '11') {
|
||||
// Migration v11 → v12: carry the LNbits transport config in the binding
|
||||
// (aiolabs/bitspire#70). relays (JSON array) + lnbits_server_pubkey let a
|
||||
// paired machine reach the backend from the pairing alone — no VITE_RELAY_URL
|
||||
// / VITE_LNBITS_SERVER_PUBKEY provisioning. Nullable: bindings written before
|
||||
// this (the seed didn't carry them) resume fine and fall back to env.
|
||||
db.exec(`
|
||||
ALTER TABLE bunker_binding ADD COLUMN relays TEXT;
|
||||
ALTER TABLE bunker_binding ADD COLUMN lnbits_server_pubkey TEXT;
|
||||
`)
|
||||
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('12', 'schema_version')
|
||||
console.log('[StateStore] Migrated schema v11 → v12 (bunker_binding transport config)')
|
||||
}
|
||||
|
||||
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
|
||||
|
|
@ -428,6 +445,14 @@ export interface StoredBunkerBinding {
|
|||
seedFingerprint: string
|
||||
/** Unix seconds when the pairing was redeemed. */
|
||||
pairedAt: number
|
||||
/**
|
||||
* LNbits transport relays from the pairing seed (aiolabs/bitspire#70). Lets a
|
||||
* resumed (seedless) boot reach the backend without env provisioning.
|
||||
* Undefined for bindings written before the seed carried them.
|
||||
*/
|
||||
relays?: string[]
|
||||
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
|
||||
lnbitsServerPubkey?: string
|
||||
}
|
||||
|
||||
/** Read the persisted bunker binding, or null if the ATM is unpaired. */
|
||||
|
|
@ -435,7 +460,7 @@ export function getBunkerBinding(): StoredBunkerBinding | null {
|
|||
if (!db) throw new Error('Database not initialized')
|
||||
const row = db
|
||||
.prepare(
|
||||
'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at FROM bunker_binding WHERE id = 1'
|
||||
'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey FROM bunker_binding WHERE id = 1'
|
||||
)
|
||||
.get() as
|
||||
| {
|
||||
|
|
@ -444,6 +469,8 @@ export function getBunkerBinding(): StoredBunkerBinding | null {
|
|||
bunker_url: string
|
||||
seed_fingerprint: string
|
||||
paired_at: number
|
||||
relays: string | null
|
||||
lnbits_server_pubkey: string | null
|
||||
}
|
||||
| undefined
|
||||
if (!row) return null
|
||||
|
|
@ -453,27 +480,47 @@ export function getBunkerBinding(): StoredBunkerBinding | null {
|
|||
bunkerUrl: row.bunker_url,
|
||||
seedFingerprint: row.seed_fingerprint,
|
||||
pairedAt: row.paired_at,
|
||||
relays: parseRelaysColumn(row.relays),
|
||||
lnbitsServerPubkey: row.lnbits_server_pubkey ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode the JSON-array `relays` column, tolerating null/legacy/garbage. */
|
||||
function parseRelaysColumn(value: string | null): string[] | undefined {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (Array.isArray(parsed) && parsed.every((r) => typeof r === 'string')) {
|
||||
return parsed as string[]
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Upsert the bunker binding after a successful (re-)pairing. */
|
||||
export function saveBunkerBinding(binding: StoredBunkerBinding): void {
|
||||
if (!db) throw new Error('Database not initialized')
|
||||
db.prepare(
|
||||
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at)
|
||||
VALUES (1, ?, ?, ?, ?, ?)
|
||||
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey)
|
||||
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
client_secret_hex = excluded.client_secret_hex,
|
||||
spire_pubkey = excluded.spire_pubkey,
|
||||
bunker_url = excluded.bunker_url,
|
||||
seed_fingerprint = excluded.seed_fingerprint,
|
||||
paired_at = excluded.paired_at`
|
||||
client_secret_hex = excluded.client_secret_hex,
|
||||
spire_pubkey = excluded.spire_pubkey,
|
||||
bunker_url = excluded.bunker_url,
|
||||
seed_fingerprint = excluded.seed_fingerprint,
|
||||
paired_at = excluded.paired_at,
|
||||
relays = excluded.relays,
|
||||
lnbits_server_pubkey = excluded.lnbits_server_pubkey`
|
||||
).run(
|
||||
binding.clientSecretHex,
|
||||
binding.spirePubkey,
|
||||
binding.bunkerUrl,
|
||||
binding.seedFingerprint,
|
||||
binding.pairedAt
|
||||
binding.pairedAt,
|
||||
binding.relays ? JSON.stringify(binding.relays) : null,
|
||||
binding.lnbitsServerPubkey ?? null
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -493,6 +540,32 @@ export function resetBootstrapGate(): void {
|
|||
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 = {
|
||||
positions: Record<string, { denomination: number; count: number }>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,10 +103,16 @@ onMounted(async () => {
|
|||
try {
|
||||
const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client')
|
||||
const { resolveSigner } = await import('@/services/signer-resolver')
|
||||
const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL
|
||||
// Best-effort: resolve a signer (bunker resume / pairing, or dev nsec).
|
||||
// If the ATM isn't paired yet, skip the beacon rather than fail the screen.
|
||||
const 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) {
|
||||
const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
|
||||
await client.connect()
|
||||
|
|
|
|||
|
|
@ -10,15 +10,18 @@
|
|||
* Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be
|
||||
* offered later without changing this view.
|
||||
*/
|
||||
import { onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import {
|
||||
availablePairingSources,
|
||||
ingestScannedSeed,
|
||||
parseScannedSeed,
|
||||
testRelay,
|
||||
type PairingSource,
|
||||
type RelayTestResult,
|
||||
type StopCapture,
|
||||
} from '@/services/pairing'
|
||||
|
||||
type Phase = 'probing' | 'scanning' | 'no-source' | 'pairing' | 'error'
|
||||
type Phase = 'probing' | 'scanning' | 'review' | 'no-source' | 'pairing' | 'error'
|
||||
|
||||
const phase = ref<Phase>('probing')
|
||||
const errorMessage = ref('')
|
||||
|
|
@ -28,6 +31,19 @@ const sources = shallowRef<PairingSource[]>([])
|
|||
const activeSource = shallowRef<PairingSource | 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) {
|
||||
await teardown()
|
||||
activeSource.value = source
|
||||
|
|
@ -50,18 +66,58 @@ let handling = false
|
|||
async function handleScan(raw: string) {
|
||||
if (handling) return
|
||||
handling = true
|
||||
const result = await ingestScannedSeed(raw)
|
||||
if (result.ok) {
|
||||
// saveSpireSeed succeeded; relaunch is in flight — hold a friendly screen.
|
||||
phase.value = 'pairing'
|
||||
// Validate only — don't commit yet. Show a review step with the decoded
|
||||
// relay + a "test relay" button so a well-formed but unreachable relay is
|
||||
// caught before we relaunch into a pairing crash-loop (aiolabs/bitspire#70).
|
||||
const preview = parseScannedSeed(raw)
|
||||
if (preview.ok) {
|
||||
await teardown() // camera off during review
|
||||
scannedRaw.value = raw.trim()
|
||||
previewSpire.value = preview.spirePubkey
|
||||
previewRelays.value = preview.relays
|
||||
relayTests.value = Object.fromEntries(preview.relays.map((r) => [r, { status: 'idle' }]))
|
||||
errorMessage.value = ''
|
||||
phase.value = 'review'
|
||||
return
|
||||
}
|
||||
// Reject non-seed scans (a stray QR) and resume scanning.
|
||||
console.warn('[Pairing] rejected scan:', result.reason, result.message)
|
||||
errorMessage.value =
|
||||
result.reason === 'invalid-seed'
|
||||
? 'That code is not a pairing code. Show the operator pairing QR.'
|
||||
: result.message
|
||||
// Reject non-seed / malformed scans (a stray QR, a corrupted relay) and resume.
|
||||
console.warn('[Pairing] rejected scan:', preview.reason, preview.message)
|
||||
errorMessage.value = 'That code is not a valid pairing code. Show the operator pairing QR.'
|
||||
handling = false
|
||||
if (activeSource.value) await startWith(activeSource.value)
|
||||
}
|
||||
|
||||
/** Probe every relay in the scanned seed and record reachability. */
|
||||
async function testRelays() {
|
||||
testingRelays.value = true
|
||||
await Promise.all(
|
||||
previewRelays.value.map(async (url) => {
|
||||
relayTests.value[url] = { status: 'testing' }
|
||||
const result = await testRelay(url)
|
||||
relayTests.value[url] = { status: 'done', result }
|
||||
}),
|
||||
)
|
||||
testingRelays.value = false
|
||||
}
|
||||
|
||||
/** Commit the reviewed seed: persist + relaunch into the real pairing path. */
|
||||
async function confirmPair() {
|
||||
committing.value = true
|
||||
const result = await ingestScannedSeed(scannedRaw.value)
|
||||
if (result.ok) {
|
||||
phase.value = 'pairing' // relaunch in flight
|
||||
return
|
||||
}
|
||||
committing.value = false
|
||||
errorMessage.value = result.message
|
||||
phase.value = 'error'
|
||||
}
|
||||
|
||||
/** Discard the scan and go back to scanning. */
|
||||
async function rescan() {
|
||||
scannedRaw.value = ''
|
||||
previewRelays.value = []
|
||||
relayTests.value = {}
|
||||
handling = false
|
||||
if (activeSource.value) await startWith(activeSource.value)
|
||||
}
|
||||
|
|
@ -101,7 +157,17 @@ onUnmounted(teardown)
|
|||
class="relative overflow-hidden rounded-2xl border-4 border-primary/40 bg-black"
|
||||
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 -->
|
||||
<div class="pointer-events-none absolute inset-6 rounded-xl border-2 border-white/70"></div>
|
||||
</div>
|
||||
|
|
@ -121,6 +187,67 @@ onUnmounted(teardown)
|
|||
<p class="text-base lg:text-2xl text-muted-foreground">Pairing accepted — restarting…</p>
|
||||
</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
|
||||
v-if="phase === 'no-source'"
|
||||
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
|
||||
|
|
|
|||
|
|
@ -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,10 @@ 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. 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.
|
||||
* 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 +402,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 +414,53 @@ 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)' : '',
|
||||
)
|
||||
// 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) {
|
||||
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 +469,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 +489,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
|
||||
|
|
@ -465,6 +505,46 @@ export async function initializeLightningServices(options?: {
|
|||
}
|
||||
console.log('[Lightning] LNbits wallet:', lnbitsWalletId)
|
||||
|
||||
// #70 P1: pull operator pubkey + fee config from LNbits over the authenticated
|
||||
// transport (spirekeeper#41 `get_machine_config`). A seed-only machine has no
|
||||
// VITE_OPERATOR_PUBKEYS, so without this it can't trust its fee config and sits
|
||||
// at "awaiting configuration". Only for the seed-only case — an explicit
|
||||
// VITE_OPERATOR_PUBKEYS override keeps the env/kind-30078 path untouched.
|
||||
// Soft-fail: an older spirekeeper (no RPC) or a transport error falls back to
|
||||
// whatever the operator services can pull from kind-30078.
|
||||
if (CONFIG.operatorPubkeys.length === 0) {
|
||||
try {
|
||||
const mc = await lnbits.getMachineConfig()
|
||||
if (mc.operator_pubkey) {
|
||||
CONFIG.operatorPubkeys = [mc.operator_pubkey]
|
||||
console.log('[Lightning] Operator pubkey(s):', mc.operator_pubkey, '(server-delivered, #70 P1)')
|
||||
}
|
||||
if (mc.fee_config && isElectron && window.electronAPI) {
|
||||
// Persist the server-delivered fee config so atm.ts's awaiting-fees gate
|
||||
// (getFeeConfig) clears immediately — robust to the replaceable kind-30078
|
||||
// event not being fetchable from the relay. The live kind-30078
|
||||
// subscription still handles mid-run fee updates.
|
||||
const applied = await window.electronAPI.applyFeeConfig(
|
||||
{
|
||||
cashInFeeFraction: mc.fee_config.cash_in_fee_fraction,
|
||||
cashOutFeeFraction: mc.fee_config.cash_out_fee_fraction,
|
||||
schemaVersion: mc.fee_config.schema_version,
|
||||
},
|
||||
mc.created_at,
|
||||
)
|
||||
console.log(
|
||||
'[Lightning] Server-delivered fee config:',
|
||||
applied.applied ? 'applied' : `skipped (${applied.reason})`,
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[Lightning] get_machine_config unavailable; falling back to env/kind-30078 for operator config:',
|
||||
(e as Error).message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// CLINK client — kept in tree but not actively wired into LNbits flows.
|
||||
// operatorPubkey is the operator allowlist for kind-21003 management
|
||||
// commands; it has no Lightning.Pub dependency.
|
||||
|
|
@ -472,7 +552,7 @@ export async function initializeLightningServices(options?: {
|
|||
nostrClient,
|
||||
signer,
|
||||
operatorPubkey: CONFIG.operatorPubkeys,
|
||||
relays: [CONFIG.relayUrl],
|
||||
relays,
|
||||
})
|
||||
|
||||
// Callbacks for events
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { ingestScannedSeed } from '../ingest'
|
||||
import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client'
|
||||
import { npubEncode } from 'nostr-tools/nip19'
|
||||
|
||||
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
||||
function makeSeed(json: unknown): string {
|
||||
|
|
@ -15,9 +16,9 @@ function makeSeed(json: unknown): string {
|
|||
const SPIRE_PUBKEY = 'a'.repeat(64)
|
||||
const VALID_SEED = makeSeed({
|
||||
v: 1,
|
||||
spire_npub: 'npub1example',
|
||||
spire_pubkey: SPIRE_PUBKEY,
|
||||
bunker_url: `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef`,
|
||||
spire_npub: npubEncode(SPIRE_PUBKEY),
|
||||
lnbits_npub: npubEncode('b'.repeat(64)),
|
||||
bunker_secret: 'deadbeef',
|
||||
relays: ['wss://events.relay/'],
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ import type { PairingSource } from './types'
|
|||
export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types'
|
||||
export { QrPairingSource } from './qr-source'
|
||||
export { NfcPairingSource } from './nfc-source'
|
||||
export { ingestScannedSeed } from './ingest'
|
||||
export type { IngestResult } from './ingest'
|
||||
export { ingestScannedSeed, parseScannedSeed } 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. */
|
||||
export function allPairingSources(): PairingSource[] {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,37 @@ export type IngestResult =
|
|||
| { ok: true; spirePubkey: string; fingerprint: string; relays: 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> {
|
||||
const trimmed = (raw || '').trim()
|
||||
|
||||
|
|
|
|||
69
apps/machine/src/services/pairing/relay-test.ts
Normal file
69
apps/machine/src/services/pairing/relay-test.ts
Normal 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)' })
|
||||
})
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
parseSpireSeed,
|
||||
seedFingerprint,
|
||||
type Signer,
|
||||
type SpireSeed,
|
||||
} from '@bitSpire/nostr-client'
|
||||
import type { BunkerBindingRecord } from '@/types/electron'
|
||||
|
||||
|
|
@ -50,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
|
||||
|
|
@ -64,20 +84,55 @@ 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> =>
|
||||
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) {
|
||||
const seed = parseSpireSeed(spireSeed)
|
||||
const fingerprint = seedFingerprint(spireSeed)
|
||||
let seed: SpireSeed
|
||||
let fingerprint: string
|
||||
try {
|
||||
seed = parseSpireSeed(spireSeed)
|
||||
fingerprint = seedFingerprint(spireSeed)
|
||||
} catch (err) {
|
||||
// A stored seed we can't parse — e.g. a legacy-shape seed left in .env
|
||||
// after the seed format changed (bitspire-#70). If we already hold a
|
||||
// binding it's authoritative (server-persistent), so resume from it
|
||||
// rather than bricking a paired machine on the next boot. With no
|
||||
// binding the seed is our only pairing input, so fail closed.
|
||||
if (binding) {
|
||||
console.warn(
|
||||
'[Signer] Stored spire seed is unparseable; resuming from existing binding:',
|
||||
(err as Error).message,
|
||||
)
|
||||
return { 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 resumeFromBinding({
|
||||
clientSecretHex: binding.clientSecretHex,
|
||||
spirePubkey: binding.spirePubkey,
|
||||
bunkerUrl: binding.bunkerUrl,
|
||||
})
|
||||
// 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.
|
||||
|
|
@ -89,27 +144,36 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer>
|
|||
clientSecretHex: transport.secretHex,
|
||||
})
|
||||
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({
|
||||
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 resumeFromBinding({
|
||||
clientSecretHex: binding.clientSecretHex,
|
||||
spirePubkey: binding.spirePubkey,
|
||||
bunkerUrl: binding.bunkerUrl,
|
||||
})
|
||||
return { signer: await resume(binding), transport: transportFromBinding(binding) }
|
||||
}
|
||||
|
||||
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) : ''
|
||||
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()
|
||||
|
|
|
|||
9
apps/machine/src/types/electron.d.ts
vendored
9
apps/machine/src/types/electron.d.ts
vendored
|
|
@ -6,10 +6,6 @@ export interface RuntimeConfig {
|
|||
relayUrl: string
|
||||
/** LNbits nostr-transport server pubkey (hex, 64 chars). */
|
||||
lnbitsServerPubkey: string
|
||||
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
|
||||
lightningPubPubkey?: string
|
||||
lightningPubApiUrl?: string
|
||||
extensionApiUrl?: string
|
||||
appId: string
|
||||
machineModel: string
|
||||
fiatCode: string
|
||||
|
|
@ -46,6 +42,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 {
|
||||
|
|
@ -98,6 +98,7 @@ declare global {
|
|||
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
|
||||
clearBunkerBinding: () => Promise<void>
|
||||
resetBootstrapGate: () => Promise<void>
|
||||
resetForRepair: () => Promise<void>
|
||||
saveSpireSeed: (seed: string) => Promise<void>
|
||||
relaunchApp: () => Promise<void>
|
||||
applyOperatorCassettesConfig: (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { useBranding } from '@/composables/useBranding'
|
||||
import { initialContext } from '@bitSpire/state-machine'
|
||||
|
|
@ -15,18 +14,8 @@ const atmStore = useAtmStore()
|
|||
const { logoUrl, title: brandTitle } = useBranding()
|
||||
const lndconnectUrl = import.meta.env.VITE_LNDCONNECT_URL || ''
|
||||
const showZeusQR = ref(false)
|
||||
const showLpQR = 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) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
|
|
@ -157,15 +146,6 @@ function handleCashOut() {
|
|||
>
|
||||
Zeus QR (lnd-alice)
|
||||
</Button>
|
||||
<Button
|
||||
v-if="lpNprofile"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-xs text-muted-foreground"
|
||||
@click="showLpQR = true"
|
||||
>
|
||||
Lightning.Pub nprofile
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Zeus QR fullscreen overlay -->
|
||||
|
|
@ -193,27 +173,6 @@ function handleCashOut() {
|
|||
</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) -->
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { marked } from 'marked'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
|
|
@ -23,35 +22,6 @@ import { QrCode, ExternalLink } from 'lucide-vue-next'
|
|||
const router = useRouter()
|
||||
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 {
|
||||
id: string
|
||||
title: string
|
||||
|
|
@ -74,17 +44,11 @@ const defaultPages: SupportPage[] = [
|
|||
| Blink | No | Partial | Yes | Yes | https://www.blink.sv |
|
||||
| Zeus | Yes | Yes | Yes | Yes | https://zeusln.com |
|
||||
| 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.
|
||||
|
||||
**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]`,
|
||||
**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.`,
|
||||
},
|
||||
{
|
||||
id: 'faq',
|
||||
|
|
@ -153,7 +117,6 @@ type Segment =
|
|||
| { type: 'qr'; content: string }
|
||||
| { type: 'table'; table: ParsedTable }
|
||||
| { type: 'qr-placeholder' }
|
||||
| { type: 'lp-nprofile' }
|
||||
|
||||
/** Parse markdown table into structured data */
|
||||
function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
|
||||
|
|
@ -176,17 +139,8 @@ function parseMarkdownTable(tableLines: string[]): ParsedTable | null {
|
|||
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 */
|
||||
function parseContent(md: string): Segment[] {
|
||||
md = resolvePlaceholders(md)
|
||||
const segments: Segment[] = []
|
||||
const lines = md.split('\n')
|
||||
let htmlBlock = ''
|
||||
|
|
@ -235,9 +189,6 @@ function parseContent(md: string): Segment[] {
|
|||
} else if (trimmed === '[operator-qr-placeholder]') {
|
||||
flushHtml()
|
||||
segments.push({ type: 'qr-placeholder' })
|
||||
} else if (trimmed === '[lp-nprofile]') {
|
||||
flushHtml()
|
||||
segments.push({ type: 'lp-nprofile' })
|
||||
} else {
|
||||
htmlBlock += line + '\n'
|
||||
}
|
||||
|
|
@ -375,33 +326,6 @@ onUnmounted(() => {
|
|||
</CardContent>
|
||||
</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 -->
|
||||
<div v-else-if="seg.type === 'table'" class="mb-8">
|
||||
<Table class="text-sm sm:text-base lg:text-xl w-full">
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ The `dev`-branch `flake.nix` pins the auto-upgrade source to `?ref=dev` so any A
|
|||
```nix
|
||||
system.autoUpgrade = {
|
||||
enable = true;
|
||||
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git?ref=dev#${machineModel}-installed";
|
||||
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#${machineModel}-installed";
|
||||
dates = "04:00";
|
||||
allowReboot = false;
|
||||
};
|
||||
|
|
@ -262,8 +262,8 @@ ls -la /dev/serial/by-id/
|
|||
{
|
||||
services.bitspire = {
|
||||
enable = true;
|
||||
relayUrl = "wss://relay.aiolabs.dev"; # ATM ↔ LNbits relay
|
||||
lnbitsServerPubkey = "<64-hex>"; # LNbits transport pubkey
|
||||
relayUrl = ""; # seed-provided (#70); set to PIN a relay
|
||||
lnbitsServerPubkey = ""; # seed-provided (#70); set to PIN a pubkey
|
||||
appDir = "/opt/bitspire"; # rarely overridden — defaults via flake
|
||||
dataDir = "/var/lib/bitspire"; # rarely overridden
|
||||
logLevel = "info"; # error | warn | info | debug
|
||||
|
|
|
|||
|
|
@ -20,18 +20,17 @@ in
|
|||
|
||||
relayUrl = mkOption {
|
||||
type = types.str;
|
||||
default = "wss://relay.aiolabs.dev";
|
||||
default = "";
|
||||
description = ''
|
||||
Nostr relay URL the ATM and LNbits both subscribe to.
|
||||
|
||||
On a fresh-boot disk image this value is seeded into
|
||||
`/var/lib/bitspire/.env` as `VITE_RELAY_URL=…` (see flake.nix
|
||||
`bitspire-env` activation script). The operator can override
|
||||
the seeded value at runtime by editing `.env` directly or by
|
||||
re-running `deploy/nixos/provision-atm.sh` with a different
|
||||
`RELAY_URL`. The renderer's resolution order is:
|
||||
`/var/lib/bitspire/.env` → this NixOS default → renderer
|
||||
hardcoded fallback (`ws://localhost:7777`).
|
||||
Optional override for the Nostr relay the ATM uses. Empty by
|
||||
default (aiolabs/bitspire#70): the relay comes from the pairing
|
||||
SEED, not from provisioning — a fresh machine boots blank, scans a
|
||||
spire-seed, and the seed's relay drives the connection. A non-empty
|
||||
value here is seeded into `/var/lib/bitspire/.env` as
|
||||
`VITE_RELAY_URL=…` and WINS over the seed (env-first precedence), so
|
||||
only set it to pin a machine to a specific relay. The renderer's
|
||||
resolution order is: `VITE_RELAY_URL` (this / .env) → the pairing
|
||||
seed's relay → a dev-only `ws://localhost:7777` fallback.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -39,10 +38,13 @@ in
|
|||
type = types.str;
|
||||
default = "";
|
||||
description = ''
|
||||
LNbits nostr-transport server pubkey (hex, 64 chars). Published
|
||||
by the LNbits server on startup. Required for the ATM to talk
|
||||
to its wallet. Provisioned by provision-atm.sh; can be left
|
||||
empty on disk-image builds.
|
||||
Optional override for the LNbits nostr-transport server pubkey
|
||||
(hex, 64 chars). Empty by default (aiolabs/bitspire#70): the
|
||||
pubkey comes from the pairing SEED (the seed's `lnbits_npub`), so
|
||||
a seed-paired machine needs nothing here. A non-empty value is
|
||||
seeded into `.env` as `VITE_LNBITS_SERVER_PUBKEY=…` and WINS over
|
||||
the seed (env-first precedence) — set it only to pin a machine to
|
||||
a specific server. Mirrors `relayUrl`.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -141,11 +143,14 @@ in
|
|||
"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 = ''
|
||||
# bitSpire ATM Configuration
|
||||
RELAY_URL=${cfg.relayUrl}
|
||||
LNBITS_SERVER_PUBKEY=${cfg.lnbitsServerPubkey}
|
||||
# bitSpire ATM Configuration (descriptive; not the runtime env)
|
||||
LOG_LEVEL=${cfg.logLevel}
|
||||
DATA_DIR=${cfg.dataDir}
|
||||
|
||||
|
|
|
|||
73
deploy/nixos/factory-reset-atm.sh
Executable file
73
deploy/nixos/factory-reset-atm.sh
Executable 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'"
|
||||
|
|
@ -21,18 +21,17 @@ let
|
|||
batm3 = "USD";
|
||||
}.${machineModel} or "USD";
|
||||
|
||||
# .env template — runtime secrets are provisioned later via provision-atm.sh.
|
||||
# Only non-secret defaults and display vars go here. VITE_SPIRE_SEED (the
|
||||
# NIP-46 bunker pairing seed) is written at provision time; the dev-only
|
||||
# VITE_ATM_PRIVATE_KEY fallback is omitted here on purpose.
|
||||
# Minimal .env template (aiolabs/bitspire#70 remnant hygiene). Seed ONLY
|
||||
# image-baked, non-maskable values. Relay + server pubkey come from the pairing
|
||||
# SEED, operator pubkey + fee config come from LNbits over the transport — so we
|
||||
# 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" ''
|
||||
VITE_RELAY_URL=
|
||||
VITE_LNBITS_SERVER_PUBKEY=
|
||||
VITE_SPIRE_SEED=
|
||||
VITE_APP_ID=
|
||||
VITE_OPERATOR_PUBKEYS=
|
||||
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
|
||||
VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel}
|
||||
VITE_SPIRE_SEED=
|
||||
ELECTRON_FORCE_PROD=1
|
||||
DISPLAY=:0
|
||||
'';
|
||||
|
|
|
|||
|
|
@ -4,19 +4,22 @@
|
|||
# kind-21000 NIP-44 v2 events on a relay — there is no out-of-band token,
|
||||
# the ATM's nostr private key IS the credential. # pragma: allowlist secret
|
||||
#
|
||||
# Required environment variables (or edit defaults below):
|
||||
# LNBITS_SERVER_PUBKEY Hex pubkey published by the LNbits server at startup.
|
||||
# From the LNbits compose:
|
||||
# docker logs lnbits | grep 'nostr_transport pubkey'
|
||||
# LNBITS_HTTP_URL Origin LNbits is reachable at over HTTP, used only
|
||||
# to compose the LNURL-withdraw callback URL that
|
||||
# customer wallets dereference. Default: http://10.0.2.2:5000
|
||||
# RELAY_URL Nostr relay LNbits + the bunker subscribe on.
|
||||
# Default: ws://$HOST_IP:5001/nostrrelay/test (LNbits
|
||||
# bundled nostrrelay). Override for a separate relay.
|
||||
# SPIRE_SEED The spire pairing seed (`spire-seed:v1:<base64url>`)
|
||||
# minted by spirekeeper. THIS is the production
|
||||
# identity under the NIP-46 bunker (aiolabs/bitspire#52).
|
||||
# The primary input is SPIRE_SEED — the pairing seed carries the relay, the
|
||||
# LNbits server pubkey AND the signing identity, so a seed-provisioned machine
|
||||
# needs nothing else (aiolabs/bitspire#70).
|
||||
#
|
||||
# Environment variables:
|
||||
# SPIRE_SEED RECOMMENDED. The spire pairing seed
|
||||
# (`spire-seed:v1:<base64url>`) minted by spirekeeper.
|
||||
# Carries relay + LNbits server pubkey + the production
|
||||
# identity under the NIP-46 bunker (aiolabs/bitspire#52 / #70).
|
||||
# RELAY_URL OPTIONAL override — pins VITE_RELAY_URL and WINS over the
|
||||
# seed's relay (env-first precedence). Leave unset to let the
|
||||
# seed drive it. Required only on the no-seed dev path
|
||||
# (default there: ws://$HOST_IP:5001/nostrrelay/test).
|
||||
# LNBITS_SERVER_PUBKEY OPTIONAL override (hex). Leave unset with a seed. On the
|
||||
# no-seed dev path it's scraped from
|
||||
# `docker logs lnbits | grep 'nostr_transport pubkey'`.
|
||||
# ATM_PRIVATE_KEY DEV-ONLY 32-byte hex nsec fallback, used only when
|
||||
# SPIRE_SEED is unset (no bunker). Generated if unset
|
||||
# AND no SPIRE_SEED is provided.
|
||||
|
|
@ -61,40 +64,51 @@ else
|
|||
echo "--- LAN ATM: using $HOST_IP as dev machine address ---"
|
||||
fi
|
||||
|
||||
# Step 2: Resolve the LNbits server pubkey. Prefer the env override; else
|
||||
# fall back to scraping the local docker compose stack.
|
||||
if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then
|
||||
echo ""
|
||||
echo "--- Step 1: Extracting LNbits nostr-transport pubkey from docker logs ---"
|
||||
LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \
|
||||
| grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \
|
||||
| tail -1 || true)
|
||||
if [ -z "$LNBITS_SERVER_PUBKEY" ]; then
|
||||
echo "ERROR: Could not extract LNbits pubkey. Set LNBITS_SERVER_PUBKEY explicitly"
|
||||
echo "or start the LNbits stack first (docker compose -f docker/docker-compose.dev.yml up lnbits)."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "LNbits server pubkey: ${LNBITS_SERVER_PUBKEY:0:16}..."
|
||||
# Steps 2-4: transport config (relay + LNbits server pubkey) + signing identity.
|
||||
#
|
||||
# Under aiolabs/bitspire#70 the relay + server pubkey come from the pairing SEED,
|
||||
# so a seed-provisioned machine needs NEITHER in .env. We only pin them when the
|
||||
# operator EXPLICITLY passes RELAY_URL / LNBITS_SERVER_PUBKEY (a deliberate
|
||||
# override that WINS over the seed via env-first precedence), or when there is no
|
||||
# seed (the dev-nsec fallback has nothing else to supply them, so we scrape/default).
|
||||
TRANSPORT_LINES=""
|
||||
|
||||
# Step 3: Pin LNbits HTTP origin.
|
||||
LNBITS_HTTP_URL="${LNBITS_HTTP_URL:-http://$HOST_IP:5000}"
|
||||
|
||||
# Step 4: Relay URL. Defaults to the LNbits bundled nostrrelay.
|
||||
RELAY_URL="${RELAY_URL:-ws://$HOST_IP:5001/nostrrelay/test}"
|
||||
|
||||
# Step 5: Signing identity. Prefer the spire pairing seed (bunker). Only fall
|
||||
# back to a generated dev nsec when no seed is supplied.
|
||||
if [ -n "${SPIRE_SEED:-}" ]; then
|
||||
echo ""
|
||||
echo "--- Using spire pairing seed (bunker-backed identity) ---"
|
||||
case "$SPIRE_SEED" in
|
||||
spire-seed:v1:*) : ;;
|
||||
*) echo "ERROR: SPIRE_SEED must start with 'spire-seed:v1:'"; exit 1 ;;
|
||||
esac
|
||||
echo ""
|
||||
echo "--- Spire pairing seed: relay + LNbits pubkey come from the seed ---"
|
||||
if [ -n "${RELAY_URL:-}" ]; then
|
||||
echo " (pinning VITE_RELAY_URL=$RELAY_URL — overrides the seed's relay)"
|
||||
TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL"
|
||||
fi
|
||||
if [ -n "${LNBITS_SERVER_PUBKEY:-}" ]; then
|
||||
TRANSPORT_LINES="${TRANSPORT_LINES:+$TRANSPORT_LINES
|
||||
}VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY"
|
||||
fi
|
||||
IDENTITY_LINES="# Spire pairing seed — bunker-backed identity (aiolabs/bitspire#52)
|
||||
VITE_SPIRE_SEED=$SPIRE_SEED"
|
||||
else
|
||||
# No seed → DEV-ONLY nsec fallback. Nothing else supplies the relay + pubkey,
|
||||
# so scrape/default them.
|
||||
if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then
|
||||
echo ""
|
||||
echo "--- No seed: extracting LNbits nostr-transport pubkey from docker logs ---"
|
||||
LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \
|
||||
| grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \
|
||||
| tail -1 || true)
|
||||
if [ -z "$LNBITS_SERVER_PUBKEY" ]; then
|
||||
echo "ERROR: no SPIRE_SEED, and could not extract the LNbits pubkey."
|
||||
echo "Provide a SPIRE_SEED (recommended — the seed carries relay + pubkey),"
|
||||
echo "or set LNBITS_SERVER_PUBKEY explicitly."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
RELAY_URL="${RELAY_URL:-ws://$HOST_IP:5001/nostrrelay/test}"
|
||||
TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL
|
||||
VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY"
|
||||
if [ -z "${ATM_PRIVATE_KEY:-}" ]; then
|
||||
ATM_PRIVATE_KEY=$(openssl rand -hex 32)
|
||||
echo ""
|
||||
|
|
@ -110,10 +124,10 @@ echo "--- Step 2: Writing .env to ATM ---"
|
|||
ENV_CONTENT="# bitSpire Configuration
|
||||
# Auto-generated by provision-atm.sh on $(date -Iseconds)
|
||||
|
||||
# LNbits nostr-transport connection
|
||||
VITE_RELAY_URL=$RELAY_URL
|
||||
VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY
|
||||
VITE_LNBITS_HTTP_URL=$LNBITS_HTTP_URL
|
||||
# LNbits nostr-transport. Relay + server pubkey come from the pairing seed
|
||||
# (aiolabs/bitspire#70); present below only as an explicit override or the
|
||||
# no-seed dev fallback.
|
||||
$TRANSPORT_LINES
|
||||
|
||||
$IDENTITY_LINES
|
||||
|
||||
|
|
@ -132,6 +146,6 @@ echo ""
|
|||
echo "=== ATM provisioned successfully ==="
|
||||
echo ""
|
||||
echo "Credentials written to /var/lib/bitspire/.env"
|
||||
echo "ATM service restarted. It should connect to LNbits via relay $RELAY_URL."
|
||||
echo "ATM service restarted. Relay: ${RELAY_URL:-from the pairing seed}."
|
||||
echo ""
|
||||
echo "To check status: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'"
|
||||
|
|
|
|||
35
flake.nix
35
flake.nix
|
|
@ -186,29 +186,34 @@
|
|||
allowReboot = false;
|
||||
};
|
||||
|
||||
# Env template — runtime secrets provisioned via provision-atm.sh.
|
||||
# Identity fields are intentionally empty so a fresh disk image
|
||||
# boots cleanly into the "needs provisioning" state; provision-
|
||||
# atm.sh SSHes in and overwrites with real values.
|
||||
# Minimal env template (aiolabs/bitspire#70 remnant hygiene).
|
||||
# Seed ONLY image-baked, non-maskable values. Everything else the
|
||||
# ATM needs comes from the pairing SEED (relay, lnbits_npub, bunker)
|
||||
# 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`
|
||||
# so the NixOS module's `relayUrl` option becomes the default
|
||||
# without losing the operator's ability to override via .env
|
||||
# (edit the file or re-run provision-atm.sh).
|
||||
# VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY are emitted ONLY when
|
||||
# the operator deliberately pins them via the Nix options (non-empty
|
||||
# default ""), which is an explicit override that wins over the seed.
|
||||
system.activationScripts.bitspire-env = ''
|
||||
mkdir -p /var/lib/bitspire
|
||||
if [ ! -f /var/lib/bitspire/.env ]; then
|
||||
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=
|
||||
cp ${pkgs.writeText "bitspire-env-default" (''
|
||||
VITE_LAMASSU_MACHINE_MODEL=${machineModel}
|
||||
VITE_LAMASSU_FIAT_CODE=${fiatCode}
|
||||
VITE_SPIRE_SEED=
|
||||
ELECTRON_FORCE_PROD=1
|
||||
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
|
||||
chown bitspire:bitspire /var/lib/bitspire/.env
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import type {
|
|||
CreateInvoiceBody,
|
||||
PayInvoiceBody,
|
||||
WalletInfo,
|
||||
MachineConfigResponse,
|
||||
SubscribePaymentsBody,
|
||||
SubscribeAck,
|
||||
PaymentPushCallback,
|
||||
|
|
@ -210,6 +211,17 @@ export class LnbitsClient {
|
|||
return data ?? []
|
||||
}
|
||||
|
||||
/** Pull server-delivered machine config (operator pubkey + fee config) over
|
||||
* the authenticated transport — spirekeeper's `get_machine_config` RPC
|
||||
* (bitspire#70 P1). Lets a seed-only ATM configure itself with no per-machine
|
||||
* env provisioning. Rejects (LnbitsRpcError) if the server hasn't registered
|
||||
* the RPC (older spirekeeper) — callers should soft-fall-back. */
|
||||
async getMachineConfig(): Promise<MachineConfigResponse> {
|
||||
return this.idempotent(() =>
|
||||
this.sendRpc<MachineConfigResponse>('get_machine_config', {}),
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Invoices
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -274,3 +274,30 @@ export type PaymentPushCallback = (payment: LnbitsPayment) => void
|
|||
|
||||
/** Called when the subscription has been closed (by TTL or explicit unsubscribe). */
|
||||
export type SubscriptionCloseCallback = (reason: 'ttl' | 'unsubscribed') => void
|
||||
|
||||
// ============================================================================
|
||||
// get_machine_config RPC (spirekeeper#41 / bitspire#70 P1)
|
||||
// ============================================================================
|
||||
|
||||
/** Fee-config wire shape inside `get_machine_config` — mirrors spirekeeper's
|
||||
* `FeeConfigPayload.to_wire_dict()` (snake_case). */
|
||||
export interface FeeConfigWire {
|
||||
schema_version: number
|
||||
cash_in_fee_fraction: number
|
||||
cash_out_fee_fraction: number
|
||||
components?: Record<string, number>
|
||||
}
|
||||
|
||||
/** Response of the `get_machine_config` RPC: the operator pubkey + fee config
|
||||
* (+ fiat, ids) LNbits delivers to a paired ATM over the authenticated
|
||||
* transport, so a seed-only machine needs no per-machine env provisioning.
|
||||
* `fee_config` is null until the operator has a super-config. */
|
||||
export interface MachineConfigResponse {
|
||||
operator_pubkey: string
|
||||
fee_config: FeeConfigWire | null
|
||||
fiat_code: string
|
||||
machine_npub: string
|
||||
wallet_id: string
|
||||
/** Freshness watermark (unix s) for the consumer's fee-config replay guard. */
|
||||
created_at: number
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { npubEncode } from 'nostr-tools/nip19'
|
||||
import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js'
|
||||
|
||||
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
|
||||
|
|
@ -12,41 +13,56 @@ function makeSeed(json: unknown): string {
|
|||
}
|
||||
|
||||
const SPIRE_PUBKEY = 'a'.repeat(64)
|
||||
const BUNKER_URL = `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef`
|
||||
const LNBITS_PUBKEY = 'b'.repeat(64)
|
||||
const SPIRE_NPUB = npubEncode(SPIRE_PUBKEY)
|
||||
const LNBITS_NPUB = npubEncode(LNBITS_PUBKEY)
|
||||
|
||||
const VALID = {
|
||||
v: 1,
|
||||
spire_npub: 'npub1example',
|
||||
spire_pubkey: SPIRE_PUBKEY,
|
||||
bunker_url: BUNKER_URL,
|
||||
spire_npub: SPIRE_NPUB,
|
||||
lnbits_npub: LNBITS_NPUB,
|
||||
bunker_secret: 'deadbeef',
|
||||
relays: ['wss://events.relay/'],
|
||||
}
|
||||
|
||||
describe('parseSpireSeed', () => {
|
||||
it('parses a well-formed seed (snake_case → camelCase)', () => {
|
||||
it('derives hex pubkeys from npubs and reconstructs the bunker URL', () => {
|
||||
const seed = parseSpireSeed(makeSeed(VALID))
|
||||
expect(seed).toEqual({
|
||||
v: 1,
|
||||
spirePubkey: SPIRE_PUBKEY,
|
||||
bunkerUrl: BUNKER_URL,
|
||||
lnbitsServerPubkey: LNBITS_PUBKEY,
|
||||
bunkerUrl: `bunker://${SPIRE_PUBKEY}?relay=${encodeURIComponent('wss://events.relay/')}&secret=deadbeef`,
|
||||
relays: ['wss://events.relay/'],
|
||||
})
|
||||
})
|
||||
|
||||
it('re-pads stripped base64url of any residue length', () => {
|
||||
// Vary a field so the encoded payload lands on each mod-4 residue.
|
||||
for (const suffix of ['', 'a', 'ab', 'abc']) {
|
||||
const seed = makeSeed({ ...VALID, spire_npub: `npub1${suffix}` })
|
||||
expect(() => parseSpireSeed(seed)).not.toThrow()
|
||||
}
|
||||
it('defaults the bunker relay to relays[0] when bunker_relay is absent', () => {
|
||||
const seed = parseSpireSeed(makeSeed(VALID))
|
||||
expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://events.relay/')}`)
|
||||
})
|
||||
|
||||
it('keeps bunker_url verbatim (percent-decoding is parseBunkerInput’s job)', () => {
|
||||
it('uses an explicit bunker_relay when present (distinct from event relays)', () => {
|
||||
const seed = parseSpireSeed(makeSeed({ ...VALID, bunker_relay: 'wss://bunker.relay/' }))
|
||||
expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://bunker.relay/')}`)
|
||||
// event relays are unchanged
|
||||
expect(seed.relays).toEqual(['wss://events.relay/'])
|
||||
})
|
||||
|
||||
it('percent-encodes relay + secret for parseBunkerInput to decode', () => {
|
||||
const seed = parseSpireSeed(makeSeed(VALID))
|
||||
expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F')
|
||||
expect(seed.bunkerUrl).toContain('secret=deadbeef')
|
||||
})
|
||||
|
||||
it('re-pads stripped base64url of any residue length', () => {
|
||||
// Vary the secret so the encoded payload lands on each mod-4 residue.
|
||||
for (const suffix of ['', 'a', 'ab', 'abc']) {
|
||||
const seed = makeSeed({ ...VALID, bunker_secret: `deadbeef${suffix}` })
|
||||
expect(() => parseSpireSeed(seed)).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['wrong scheme', 'spire-seed:v2:abc'],
|
||||
['not a seed', 'bunker://whatever'],
|
||||
|
|
@ -56,10 +72,18 @@ describe('parseSpireSeed', () => {
|
|||
|
||||
it.each([
|
||||
['bad version', { ...VALID, v: 2 }],
|
||||
['short pubkey', { ...VALID, spire_pubkey: 'abc' }],
|
||||
['non-bunker url', { ...VALID, bunker_url: 'https://evil/' }],
|
||||
['missing spire_npub', { ...VALID, spire_npub: undefined }],
|
||||
['non-npub spire_npub', { ...VALID, spire_npub: 'a'.repeat(64) }],
|
||||
['missing lnbits_npub', { ...VALID, lnbits_npub: undefined }],
|
||||
['non-npub lnbits_npub', { ...VALID, lnbits_npub: 'notanpub' }],
|
||||
['empty bunker_secret', { ...VALID, bunker_secret: '' }],
|
||||
['missing bunker_secret', { ...VALID, bunker_secret: undefined }],
|
||||
['empty relays', { ...VALID, relays: [] }],
|
||||
['non-string relay', { ...VALID, relays: [123] }],
|
||||
['non-ws relay (scan corruption ws://→As://)', { ...VALID, relays: ['As://events.relay/'] }],
|
||||
['non-ws relay (http)', { ...VALID, relays: ['http://events.relay/'] }],
|
||||
['empty bunker_relay', { ...VALID, bunker_relay: '' }],
|
||||
['non-ws bunker_relay', { ...VALID, bunker_relay: 'As://bunker.relay/' }],
|
||||
])('rejects %s', (_label, json) => {
|
||||
expect(() => parseSpireSeed(makeSeed(json))).toThrow()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,44 +3,70 @@
|
|||
*
|
||||
* The operator dashboard (aiolabs/spirekeeper `pairing.py`) hands each ATM a
|
||||
* one-time seed URL that encodes the bunker connection + the spire's signing
|
||||
* identity. Wire contract (model A1):
|
||||
* identity. Wire contract (model A1, minimal encoding):
|
||||
*
|
||||
* spire-seed:v1:<base64url(json, no padding)>
|
||||
* json = {
|
||||
* "v": 1,
|
||||
* "spire_npub": "npub1…", // informational, ignored here
|
||||
* "spire_pubkey": "<64-hex>", // the spire's bunker-held signing identity
|
||||
* "bunker_url": "bunker://<spire_pubkey_hex>?relay=<url>&secret=<sec>",
|
||||
* "relays": ["wss://…"] // relays for the spire's OWN events (21000/30078)
|
||||
* "spire_npub": "npub1…", // spire signing identity (bech32; hex derived)
|
||||
* "lnbits_npub": "npub1…", // LNbits nostr-transport server identity
|
||||
* "bunker_secret": "<sec>", // one-shot NIP-46 connect token
|
||||
* "relays": ["wss://…"], // relays the spire's OWN events use (21000/30078)
|
||||
* "bunker_relay": "wss://…" // OPTIONAL — NIP-46 relay; defaults to relays[0]
|
||||
* }
|
||||
*
|
||||
* - base64url is `urlsafe_b64encode(...).rstrip("=")` → re-pad to a multiple
|
||||
* of 4 before decoding.
|
||||
* - `relay` / `secret` inside `bunker_url` are percent-encoded; decoding them
|
||||
* is left to nostr-tools `parseBunkerInput` (see bunker-signer.ts), so we
|
||||
* keep `bunker_url` verbatim.
|
||||
* - `bunker_url`'s relay is the BUNKER relay; `relays[]` is where the spire
|
||||
* publishes its own events. They may differ — both must be spire-reachable.
|
||||
* Design (see aiolabs/bitspire#70): the pubkey is carried ONCE, as an npub.
|
||||
* The old shape spelled it three times (spire_npub + spire_pubkey hex + inside
|
||||
* a full bunker_url), which bloats a QR that's already hard to scan. Here:
|
||||
*
|
||||
* - `spire_pubkey` (hex) is derived from `spire_npub` (npub is ~the same length
|
||||
* as hex but carries a bech32 checksum — real error-detection for a value
|
||||
* read off a camera).
|
||||
* - `bunker_url` is RECONSTRUCTED from `spire_pubkey`, `bunker_relay` (or
|
||||
* `relays[0]`), and `bunker_secret`, then handed verbatim to nostr-tools
|
||||
* `parseBunkerInput` (see bunker-signer.ts).
|
||||
* - `lnbits_npub` gives the ATM its LNbits transport server pubkey so a paired
|
||||
* machine needs nothing else provisioned to reach the backend (#70 part 2).
|
||||
*
|
||||
* base64url is `urlsafe_b64encode(...).rstrip("=")` → re-pad to a multiple of 4
|
||||
* before decoding.
|
||||
*/
|
||||
|
||||
import { sha256 } from '@noble/hashes/sha2.js'
|
||||
import { bytesToHex } from 'nostr-tools/utils'
|
||||
import { decode as nip19Decode } from 'nostr-tools/nip19'
|
||||
|
||||
export const SPIRE_SEED_SCHEME = 'spire-seed:v1:'
|
||||
|
||||
export interface SpireSeed {
|
||||
/** Seed format version (always 1 for this scheme). */
|
||||
v: number
|
||||
/** The spire's signing identity — 64-char hex. Every event is signed as this. */
|
||||
/** The spire's signing identity — 64-char hex, derived from `spire_npub`. */
|
||||
spirePubkey: string
|
||||
/** `bunker://<pubkey>?relay=&secret=` — handed to nostr-tools parseBunkerInput. */
|
||||
/** `bunker://<pubkey>?relay=&secret=` — reconstructed, handed to parseBunkerInput. */
|
||||
bunkerUrl: string
|
||||
/** Relays where the spire publishes its own events (kind 21000 / 30078). */
|
||||
relays: string[]
|
||||
/** LNbits nostr-transport server pubkey — 64-char hex, derived from `lnbits_npub`. */
|
||||
lnbitsServerPubkey: string
|
||||
}
|
||||
|
||||
const HEX64 = /^[0-9a-f]{64}$/
|
||||
|
||||
/**
|
||||
* A relay must be a `ws://` or `wss://` URL. Unlike the npubs (bech32-checksummed,
|
||||
* so a mis-scanned character is caught), the relay strings are raw inside the
|
||||
* seed's base64 — a QR misread can silently corrupt `ws://` into e.g. `As://`
|
||||
* and the pairing then crash-loops on an unreachable relay. Reject at parse time
|
||||
* so the wizard refuses a garbled scan instead of persisting it (bitspire#70).
|
||||
*/
|
||||
const WS_URL = /^wss?:\/\/[^\s]+$/
|
||||
function assertRelayUrl(value: string, field: string): void {
|
||||
if (!WS_URL.test(value)) {
|
||||
throw new Error(`parseSpireSeed: ${field} must be a ws:// or wss:// URL (got "${value}")`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode an unpadded base64url string in both browser and Node. */
|
||||
function base64urlDecode(input: string): string {
|
||||
const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '=')
|
||||
|
|
@ -50,6 +76,23 @@ function base64urlDecode(input: string): string {
|
|||
return Buffer.from(padded, 'base64').toString('binary')
|
||||
}
|
||||
|
||||
/** Decode an `npub1…` to its 64-char hex pubkey, failing closed. */
|
||||
function hexFromNpub(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`parseSpireSeed: ${field} must be a string`)
|
||||
}
|
||||
let decoded: ReturnType<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 —
|
||||
* 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)}`)
|
||||
}
|
||||
|
||||
const spirePubkey = obj.spire_pubkey
|
||||
if (typeof spirePubkey !== 'string' || !HEX64.test(spirePubkey)) {
|
||||
throw new Error('parseSpireSeed: spire_pubkey must be 64-char hex')
|
||||
}
|
||||
const spirePubkey = hexFromNpub(obj.spire_npub, 'spire_npub')
|
||||
const lnbitsServerPubkey = hexFromNpub(obj.lnbits_npub, 'lnbits_npub')
|
||||
|
||||
const bunkerUrl = obj.bunker_url
|
||||
if (typeof bunkerUrl !== 'string' || !bunkerUrl.startsWith('bunker://')) {
|
||||
throw new Error('parseSpireSeed: bunker_url must be a bunker:// URL')
|
||||
const bunkerSecret = obj.bunker_secret
|
||||
if (typeof bunkerSecret !== 'string' || bunkerSecret.length === 0) {
|
||||
throw new Error('parseSpireSeed: bunker_secret must be a non-empty string')
|
||||
}
|
||||
|
||||
const relays = obj.relays
|
||||
if (!Array.isArray(relays) || relays.length === 0 || !relays.every((r) => typeof r === 'string')) {
|
||||
throw new Error('parseSpireSeed: relays must be a non-empty string array')
|
||||
}
|
||||
relays.forEach((r, i) => assertRelayUrl(r as string, `relays[${i}]`))
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue