Compare commits

...

17 commits

Author SHA1 Message Date
0cc48652aa feat(deploy): add factory-reset-atm.sh for a truly-fresh machine (#70)
Deterministically reproduce a brand-new machine so tests aren't masked by
leftover env/db values: stops bitspire, deletes state.db (+ WAL/SHM), truncates
.env to the minimal image-baked template (preserving model + fiat), restarts.
The ATM then boots unpaired into the wizard exactly like a fresh disk image.
Confirmation-gated (FORCE=1 to skip; ATM_USER= to override the SSH user).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
2af3e29f15 chore(machine): log operator-pubkey provenance so the config gap is loud (#70)
Relay + server pubkey already log (env)/(pairing)/(default) provenance; operator
pubkeys did not. An empty operator set silently disables the fees/operator-config
services → the machine sits at "awaiting configuration" with no signal why. Log
the resolved operator pubkey(s) and their source, and flag the empty case
explicitly (pending the #70 P1 server-delivered operator pubkey).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
4745790b40 fix(machine): re-pair wipes the prior operator's config + watermarks (#70)
A new-seed re-pair UPSERTed the bunker binding but left fee_config, cassettes,
and the created_at replay watermarks intact. The watermarks are the trap: 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, so re-pairing a long-lived install
to a fresh backend appears to pair but never picks up new config.

Add resetForRepair() (main-process state-store): in one transaction it clears
fee_config and resets both replay watermarks to 0. Wired function → IPC
(state:reset-for-repair) → preload → renderer, and called from the re-pair branch
in signer-resolver, gated on an existing binding (re-pair only; a first pair has
nothing to reset). Deliberately preserves cassettes/cashbox/transactions — those
track PHYSICAL cash that survives an operator handover; a full wipe is the
factory-reset path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
1877959ab5 chore(deploy): seed a minimal .env — stop pre-seeding maskable vars (#70)
The bitspire-env activation seeds .env only when ABSENT (never refreshes on
redeploy), and env WINS over the pairing seed — so any value written at first
boot is frozen for the disk's life and silently masks the seed's source. That's
how a dead relay.aiolabs.dev and a provisioned VITE_OPERATOR_PUBKEYS made stale
installs "work" while a fresh machine broke.

Seed ONLY image-baked, non-maskable values (model, fiat, ELECTRON_FORCE_PROD,
DISPLAY, empty VITE_SPIRE_SEED placeholder). Relay + server pubkey come from the
seed; operator pubkey + fee config come from LNbits over the transport — so those
keys are no longer pre-seeded at all. VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY
are emitted only when the operator deliberately pins them via the Nix options (an
explicit override). Also drops the inert RELAY_URL/LNBITS_SERVER_PUBKEY lines from
/etc/bitspire/config.env (never loaded — EnvironmentFile is forced to .env).

Verified: built sintra-installed .env template is 5 lines, 0 maskable vars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
5f5257daa6 fix(machine): rotate pairing camera preview 90° CCW for the Sintra mount
The Sintra's camera is physically mounted rotated, so the wizard's viewfinder
showed a sideways image — hard to aim at the spire-seed QR. Rotate the preview
90° CCW (-rotate-90). Preview-only: qr-source decodes the raw frame (CSS
transforms don't touch canvas drawImage) and QR decoding is rotation-invariant,
so scanning is unaffected. The viewfinder is a square, overflow-hidden container,
so the rotated square stays in the box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
4a45181d12 fix(machine): point DEV_DEFAULT_RELAY at the real dev relay
The last-ditch dev fallback (used only when neither env nor the pairing seed
supplies a relay) was ws://localhost:7777 — a standalone strfry we no longer
run. Align it to the dev stack's LNbits bundled nostrrelay
(ws://localhost:5001/nostrrelay/test) so the fallback points at a relay that
actually exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
ee2e71d543 refactor(machine): remove dead Lightning.Pub nprofile UI (post-3d cutover)
The LP backend was deleted on dev, so VITE_LIGHTNING_PUB_PUBKEY /
config.lightningPubPubkey are never set — the "add this ATM's node to your
wallet" nprofile QR (IdleView dev button + overlay, SupportView ShockWallet
card + deep-link) rendered empty, and the LP fields in RuntimeConfig
(lightningPubPubkey/lightningPubApiUrl/extensionApiUrl) were never populated.
Remove them. The concept has no clean LNbits analog (the ATM is a cash↔LN
gateway, not a node customers peer with) — tracked as a fresh feature request
on lnbits. ShockWallet stays listed as a downloadable wallet (plain URL).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
a8b8b707be docs: relay + LNbits pubkey are seed-provided, not required (#70)
Env table (CLAUDE.md), .env.example, and the deploy README still framed
VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY as required/provisioned; they now come
from the pairing seed and are env overrides only. Also refresh the slimmed seed
shape, the relayUrl/pubkey module examples ("" not wss://relay.aiolabs.dev), and
the stale lamassu-next autoUpgrade flake URL (→ aiolabs/bitspire).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
f003483599 fix(machine): maintenance beacon uses the pairing seed's relay (#70)
The maintenance-mode beacon resolved the relay from env only (config.relayUrl ||
VITE_RELAY_URL), so on a blank-.env seed-driven machine it was undefined and the
beacon was skipped — a paired ATM in maintenance never broadcast. It already
resolves the signer (which carries the transport); fall back to
resolved.transport.relays[0], mirroring lightning.ts's env → pairing precedence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
78804b4f89 fix(deploy): provision-atm.sh writes relay/pubkey only on explicit override (#70)
The script unconditionally wrote VITE_RELAY_URL + VITE_LNBITS_SERVER_PUBKEY (and
hard-exited if it couldn't scrape the pubkey), env-pinning every provisioned
machine and defeating the seed — the same bug as the activation default. Make it
seed-first: with a SPIRE_SEED, relay + pubkey come from the seed and are written
only when the operator explicitly passes RELAY_URL / LNBITS_SERVER_PUBKEY as a
deliberate pin. The no-seed dev-nsec path still scrapes/defaults them. Also drops
the unused VITE_LNBITS_HTTP_URL line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
e20faa61ff fix(deploy): relay + LNbits pubkey are seed-provided, not env-pinned (#70)
The bitspire-env activation seeded VITE_RELAY_URL from the relayUrl option
(default wss://relay.aiolabs.dev). Because env wins over the pairing seed, every
fresh machine pinned itself to that relay — which is dead — so a scanned seed's
relay was ignored ("No connected relays"; hit live on the aio-demo USB). Default
relayUrl to "" so both relay and server pubkey come from the seed; a non-empty
option now pins a machine (an explicit override) rather than being the default.
Descriptions updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
675b6bfb7c fix(machine): don't inject a localhost relay default in get-config
The Electron main's get-config returned relayUrl = VITE_RELAY_URL ||
'ws://localhost:7777'. On an unprovisioned (blank-.env) machine that non-empty
localhost default reached the renderer and, via the env-first precedence, won
over the pairing seed's relay — then failed strict validation as localhost.
That defeated #70's "the seed provides the relay": the Sintra paired fine but
booted with ws://localhost:7777 instead of the seed's nostrclient endpoint.

Return '' when unset so the renderer falls through to the seed's transport
relay (its own ws://localhost:7777 dev fallback only applies when neither env
nor pairing supplies one). Mirror of the renderer default fixed in e578680.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
d094cf090c feat(machine): pairing review step with a relay-reachability test
A well-formed but unreachable relay (localhost baked into a seed for a remote
machine, a wrong LAN IP, a relay that's down) parses fine and only fails later
as a NIP-46 connect crash-loop. Give the operator a way to catch it on-machine
before committing (bitspire-#70).

The wizard no longer commits immediately on a good scan: it now parses (without
persisting) and shows a review step with the decoded spire + relay(s), a "Test
relay" button (opens a WebSocket + NIP-01 REQ, reports reachable/latency or
unreachable), and Pair / Rescan. Only on "Pair" does it persist + relaunch into
the real pairing path.

- parseScannedSeed: validate-only split of ingestScannedSeed (no persist).
- testRelay: WebSocket reachability probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
1e2a653ad1 fix(nostr-client): reject non-ws(s):// relays in the spire seed
The npubs in the seed are bech32-checksummed, so a mis-scanned character is
caught — but the relay strings are raw inside the base64. A QR misread silently
turned `ws://192.168.0.32:5001/...` into `As://192.168.0.32:5001/...`, which
parsed fine and then crash-looped the machine on an unreachable NIP-46 relay.

Validate every `relays[]` entry (and `bunker_relay`) is a `ws://`/`wss://` URL
at parse time, so a garbled scan is rejected as an invalid seed instead of
persisted. Part of bitspire-#70 pairing robustness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:32:06 +02:00
e57868020b feat(machine): source LNbits transport from the pairing seed, not just env
Completes the consumer half of bitspire-#70: a paired machine gets its LNbits
transport relay(s) + server pubkey from the pairing, so a blank-.env unit reaches
the backend after scanning a seed — no VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY
provisioning.

- resolveSigner now returns { signer, transport }. transport (relays +
  lnbitsServerPubkey) comes from the seed on a fresh pair / seeded resume, and
  from the binding on a seedless resume. It's threaded out of resolveSigner
  rather than re-parsed in loadLightningConfig because the seed arrives over the
  one-shot get-atm-secrets IPC — a second consumer would break that contract.
- bunker_binding persists relays + lnbits_server_pubkey (state.db v11→v12,
  nullable so pre-#70 bindings resume and fall back to env). Mirrored into
  BunkerBindingRecord (preload + electron.d.ts).
- initializeLightningServices resolves effective transport with env-wins
  precedence (explicit env override for dev, else pairing, else a dev-only
  localhost relay), mutating CONFIG to a single source of truth and building the
  Nostr/LNbits/CLINK clients from the full relay list. Strict + required-config
  validation now run on the resolved values.

state.db round-trip test covers the new columns + their absence on a pre-#70
binding. Renderer + electron typechecks and all 38 machine tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:09:50 +02:00
549491a432 fix(machine): resume from binding when a stored spire seed won't parse
resolveSigner parses the stored VITE_SPIRE_SEED on every boot before it checks
the binding, so a machine whose .env still holds a legacy-shape seed would
throw on the new parser (bitspire-#70) and surface "ATM Unavailable" on the
next auto-pull — even though it has a perfectly good, server-persistent binding
to resume from.

Guard the parse: an unparseable stored seed with a binding present falls back
to resuming the binding (authoritative); with no binding it still fails closed,
since the seed is then the only pairing input. Also dedupes the three
resume-from-binding call sites behind a small local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:16:16 +02:00
53a0c2db51 refactor(nostr-client): slim the spire-seed to carry the pubkey once, add lnbits_npub
The v1 seed spelled the spire pubkey three times — spire_npub, spire_pubkey
(hex), and again inside a full bunker_url — which bloats a QR that's already
hard to scan off the machine's camera. Carry it once, as an npub, and derive
the rest:

- spire_pubkey (hex) ← decode(spire_npub). npub is ~the same length as hex but
  carries a bech32 checksum, so a mis-scanned character is caught instead of
  yielding a wrong-but-valid-looking key.
- bunker_url ← reconstructed from spire_pubkey + bunker_secret + bunker_relay.
- bunker_relay is OPTIONAL, defaulting to relays[0] (option 3): minimal in the
  common case where the bunker shares the event relay, explicit when it differs.
- lnbits_npub is NEW — gives a paired machine its LNbits transport server pubkey
  from the seed itself, so nothing else needs provisioning (bitspire-#70 part 2).

Kept as v: 1 (redefined in place, no compat shim): the seed is a one-shot
pairing token, no bitspire machine has shipped, and a paired machine resumes
from its stored binding, not by re-parsing the seed. Roughly a third smaller
encoded — ~180-200 fewer chars in the QR.

Lockstep: aiolabs/spirekeeper pairing.py must emit the new shape (spire_npub +
lnbits_npub + bunker_secret, drop spire_pubkey/bunker_url) before a new seed can
be minted. Consumer wiring (relays + lnbitsServerPubkey into LightningConfig)
and a resolver-resilience guard for machines holding an old-shape seed land
separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:14:59 +02:00
25 changed files with 882 additions and 322 deletions

View file

@ -82,9 +82,9 @@ Renderer reads (Electron IPC or Vite `import.meta.env`):
| Var | Required | Notes |
|---|---|---|
| `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 |

View file

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

View file

@ -0,0 +1,69 @@
/**
* Tests for bunker-binding persistence in state-store (aiolabs/bitspire#52,
* transport config added in #70).
*
* Validates the round-trip of the binding singleton, including the v11→v12
* transport columns (relays JSON + lnbits_server_pubkey) and their absence on
* a pre-#70 binding.
*
* Uses an in-memory SQLite database — fresh per test, no on-disk artifacts.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
clearBunkerBinding,
closeDatabase,
getBunkerBinding,
initDatabase,
saveBunkerBinding,
type StoredBunkerBinding,
} from '../state-store.js'
const BASE: StoredBunkerBinding = {
clientSecretHex: 'aa'.repeat(32),
spirePubkey: 'bb'.repeat(32),
bunkerUrl: 'bunker://bb?relay=wss%3A%2F%2Fr%2F&secret=deadbeef',
seedFingerprint: 'cc'.repeat(32),
pairedAt: 1_780_000_000,
}
beforeEach(() => {
initDatabase(':memory:')
})
afterEach(() => {
closeDatabase()
})
describe('bunker binding persistence', () => {
it('round-trips a binding carrying transport config (#70)', () => {
const binding: StoredBunkerBinding = {
...BASE,
relays: ['wss://one.relay/', 'wss://two.relay/'],
lnbitsServerPubkey: 'dd'.repeat(32),
}
saveBunkerBinding(binding)
expect(getBunkerBinding()).toEqual(binding)
})
it('round-trips a pre-#70 binding (no transport config) as undefined fields', () => {
saveBunkerBinding(BASE)
const got = getBunkerBinding()
expect(got).toEqual(BASE)
expect(got?.relays).toBeUndefined()
expect(got?.lnbitsServerPubkey).toBeUndefined()
})
it('upserts transport config in place (re-pair overwrites)', () => {
saveBunkerBinding({ ...BASE, relays: ['wss://old/'], lnbitsServerPubkey: 'ee'.repeat(32) })
saveBunkerBinding({ ...BASE, relays: ['wss://new/'], lnbitsServerPubkey: 'ff'.repeat(32) })
const got = getBunkerBinding()
expect(got?.relays).toEqual(['wss://new/'])
expect(got?.lnbitsServerPubkey).toBe('ff'.repeat(32))
})
it('returns null after clear', () => {
saveBunkerBinding(BASE)
clearBunkerBinding()
expect(getBunkerBinding()).toBeNull()
})
})

View file

@ -27,6 +27,7 @@ import {
getBootstrapPublishedAt,
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

View file

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

View file

@ -15,7 +15,7 @@ import fs from 'node:fs'
let db: Database.Database | null = null
const SCHEMA_VERSION = '11'
const SCHEMA_VERSION = '12'
function getDbPath(): string {
const prodDir = '/var/lib/bitspire'
@ -121,7 +121,9 @@ export function initDatabase(dbPath?: string): void {
spire_pubkey TEXT NOT NULL,
bunker_url TEXT NOT NULL,
seed_fingerprint TEXT NOT NULL,
paired_at INTEGER NOT NULL
paired_at INTEGER NOT NULL,
relays TEXT,
lnbits_server_pubkey TEXT
);
`)
@ -352,6 +354,21 @@ export function initDatabase(dbPath?: string): void {
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version')
console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)')
existing.value = '11'
}
if (existing && existing.value === '11') {
// Migration v11 → v12: carry the LNbits transport config in the binding
// (aiolabs/bitspire#70). relays (JSON array) + lnbits_server_pubkey let a
// paired machine reach the backend from the pairing alone — no VITE_RELAY_URL
// / VITE_LNBITS_SERVER_PUBKEY provisioning. Nullable: bindings written before
// this (the seed didn't carry them) resume fine and fall back to env.
db.exec(`
ALTER TABLE bunker_binding ADD COLUMN relays TEXT;
ALTER TABLE bunker_binding ADD COLUMN lnbits_server_pubkey TEXT;
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('12', 'schema_version')
console.log('[StateStore] Migrated schema v11 → v12 (bunker_binding transport config)')
}
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
@ -428,6 +445,14 @@ export interface StoredBunkerBinding {
seedFingerprint: string
/** Unix seconds when the pairing was redeemed. */
pairedAt: number
/**
* LNbits transport relays from the pairing seed (aiolabs/bitspire#70). Lets a
* resumed (seedless) boot reach the backend without env provisioning.
* Undefined for bindings written before the seed carried them.
*/
relays?: string[]
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
lnbitsServerPubkey?: string
}
/** Read the persisted bunker binding, or null if the ATM is unpaired. */
@ -435,7 +460,7 @@ export function getBunkerBinding(): StoredBunkerBinding | null {
if (!db) throw new Error('Database not initialized')
const row = db
.prepare(
'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at FROM bunker_binding WHERE id = 1'
'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey FROM bunker_binding WHERE id = 1'
)
.get() as
| {
@ -444,6 +469,8 @@ export function getBunkerBinding(): StoredBunkerBinding | null {
bunker_url: string
seed_fingerprint: string
paired_at: number
relays: string | null
lnbits_server_pubkey: string | null
}
| undefined
if (!row) return null
@ -453,27 +480,47 @@ export function getBunkerBinding(): StoredBunkerBinding | null {
bunkerUrl: row.bunker_url,
seedFingerprint: row.seed_fingerprint,
pairedAt: row.paired_at,
relays: parseRelaysColumn(row.relays),
lnbitsServerPubkey: row.lnbits_server_pubkey ?? undefined,
}
}
/** Decode the JSON-array `relays` column, tolerating null/legacy/garbage. */
function parseRelaysColumn(value: string | null): string[] | undefined {
if (!value) return undefined
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed) && parsed.every((r) => typeof r === 'string')) {
return parsed as string[]
}
} catch {
// fall through
}
return undefined
}
/** Upsert the bunker binding after a successful (re-)pairing. */
export function saveBunkerBinding(binding: StoredBunkerBinding): void {
if (!db) throw new Error('Database not initialized')
db.prepare(
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at)
VALUES (1, ?, ?, ?, ?, ?)
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey)
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
client_secret_hex = excluded.client_secret_hex,
spire_pubkey = excluded.spire_pubkey,
bunker_url = excluded.bunker_url,
seed_fingerprint = excluded.seed_fingerprint,
paired_at = excluded.paired_at`
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 }>
}

View file

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

View file

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

View file

@ -54,7 +54,10 @@ interface LightningConfig {
*/
async function loadLightningConfig(): Promise<LightningConfig> {
const defaults: LightningConfig = {
relayUrl: 'ws://localhost:7777',
// Empty when unset (not the dev relay) so initializeLightningServices can
// tell "operator gave us a relay" from "fall back to the pairing seed". See
// aiolabs/bitspire#70 and DEV_DEFAULT_RELAY.
relayUrl: '',
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
operatorPubkeys: [],
lnbitsServerPubkey: '',
@ -97,6 +100,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
@ -472,7 +512,7 @@ export async function initializeLightningServices(options?: {
nostrClient,
signer,
operatorPubkey: CONFIG.operatorPubkeys,
relays: [CONFIG.relayUrl],
relays,
})
// Callbacks for events

View file

@ -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/'],
})

View file

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

View file

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

View file

@ -0,0 +1,69 @@
/**
* Relay reachability probe for the pairing wizard (aiolabs/bitspire#70).
*
* `parseSpireSeed` catches a MALFORMED relay (e.g. a QR misread of `ws://` into
* `As://`), but a well-formed-yet-unreachable relay — `ws://localhost:…` baked
* into a seed for a remote machine, a wrong LAN IP, or a relay that's simply
* down — still parses fine and would only fail later as a NIP-46 connect
* crash-loop. This opens a WebSocket to the relay (and sends a NIP-01 REQ so a
* real relay answers) so the operator can confirm reachability on-machine,
* before committing the pairing.
*/
export interface RelayTestResult {
url: string
ok: boolean
/** Round-trip time to open (ms), when reachable. */
ms?: number
/** True when the relay answered our REQ — i.e. it's actually a nostr relay. */
answered?: boolean
error?: string
}
/** Open a WebSocket to `url` and report whether it connects within `timeoutMs`. */
export function testRelay(url: string, timeoutMs = 6000): Promise<RelayTestResult> {
return new Promise((resolve) => {
const start = Date.now()
let ws: WebSocket | null = null
let settled = false
const finish = (r: Omit<RelayTestResult, 'url'>): void => {
if (settled) return
settled = true
clearTimeout(timer)
try {
ws?.close()
} catch {
/* already closing */
}
resolve({ url, ...r })
}
const timer = setTimeout(
() => finish({ ok: false, error: `timed out after ${timeoutMs}ms` }),
timeoutMs,
)
try {
ws = new WebSocket(url)
} catch (e) {
finish({ ok: false, error: e instanceof Error ? e.message : 'invalid relay URL' })
return
}
ws.onopen = () => {
// Connected. Probe it as a nostr relay; a genuine relay replies (EOSE /
// notice). If it stays silent we still count the open as reachable.
try {
ws?.send(JSON.stringify(['REQ', 'bitspire-relay-test', { limit: 0 }]))
} catch {
/* send failed, but the socket opened → still reachable */
}
const graceMs = Math.min(600, timeoutMs)
setTimeout(() => finish({ ok: true, ms: Date.now() - start, answered: false }), graceMs)
}
ws.onmessage = () => finish({ ok: true, ms: Date.now() - start, answered: true })
ws.onerror = () =>
finish({ ok: false, error: 'connection failed (unreachable or not a relay)' })
})
}

View file

@ -26,6 +26,7 @@ import {
parseSpireSeed,
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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,18 +21,17 @@ let
batm3 = "USD";
}.${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
'';

View file

@ -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'"

View file

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

View file

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

View file

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