Compare commits

...

23 commits

Author SHA1 Message Date
fdb9a507c2 feat(machine): consume get_machine_config over the transport (#71)
Source the operator pubkey + fee config from LNbits via the get_machine_config
kind-21000 RPC (spirekeeper#41) right after list_wallets, instead of the
operator pubkey coming only from VITE_OPERATOR_PUBKEYS (env). A seed-only
machine (blank .env) had an empty operator allowlist → the fees/operator-config
services disabled themselves → permanent "awaiting configuration". Now it pulls
its config over the already-authenticated channel and configures itself with
zero per-machine provisioning — closing bitspire#70 P1.

- LnbitsClient.getMachineConfig() → sendRpc('get_machine_config') + the
  MachineConfigResponse / FeeConfigWire types.
- lightning.ts, only when VITE_OPERATOR_PUBKEYS is empty (env override still
  wins): set CONFIG.operatorPubkeys from operator_pubkey (re-enables the
  services), and persist fee_config via the existing applyFeeConfig IPC (mapping
  snake_case → camelCase) so atm.ts's awaiting-fees gate clears immediately —
  robust to the replaceable kind-30078 not being fetchable from the relay. The
  live kind-30078 subscription still handles mid-run fee updates.
- Soft-fail: older spirekeeper (no RPC) or a transport error falls back to the
  env/kind-30078 path.

lnbits + machine typecheck clean; lnbits suite 29 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:54:18 +00:00
a01e64cc70 Merge pull request 'feat: seed-driven pairing over the LNbits nostr-transport (#70)' (#73) from feat/seed-driven-pairing into dev
Reviewed-on: #73
2026-07-02 21:54:10 +00:00
936fc9fb46 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:53:51 +00:00
5cf39a05ee 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:53:51 +00:00
78592d89f7 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:53:51 +00:00
42c0d3e9ca 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:53:51 +00:00
7bc718f9e3 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:53:51 +00:00
06f73b2d76 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:53:51 +00:00
7abc2e3305 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:53:51 +00:00
e99628ef84 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:53:51 +00:00
ce87f85a73 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:53:51 +00:00
20dbc8ca80 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:53:51 +00:00
7896c122da 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:53:51 +00:00
eaa7cbe33c 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:53:51 +00:00
0bc57dc754 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:53:51 +00:00
5179a21da6 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:53:51 +00:00
883c599835 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-02 21:53:51 +00:00
786789f517 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-02 21:53:51 +00:00
98bdd92044 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-02 21:53:51 +00:00
a73f345606 Merge pull request 'deploy: bootable slim Sintra image + shared UP Board serial hardware' (#72) from deploy/sintra-boot-hardware into dev
Reviewed-on: #72
2026-07-02 21:53:28 +00:00
7e90719508 refactor(deploy): share UP Board serial hardware between installed + live ISO
The sintra live ISO (live.nix) had no serial support — ftdi_sio and the
ttyJ5/ttyJ7 udev symlinks were only in hardware/upboard.nix (installed), so
booting iso-sintra on real hardware failed on the validator + F56 dispenser
while the disk image worked. The two definitions had already drifted (live's
tejo block lacked ttyS4).

Extract the UP Board serial peripherals (usbserial/ftdi_sio/cp210x, the
ttyJ4/ttyJ5/ttyJ7 udev symlinks + permissions, console=tty0) into
hardware/upboard-serial.nix and import it from both upboard.nix (installed
tejo + sintra) and live.nix (sintra only). Single source of truth — the two
artifacts can't drift again. Named upboard-serial (not sintra-serial) since
upboard.nix serves both tejo-installed and sintra-installed.

Camera + LED/SPI rules stay inline in upboard.nix (installed-specific; the
pairing camera works via getUserMedia without the scanner symlink). Verified
by eval: live sintra now carries ftdi_sio + console=tty0 + ttyJ7; installed
sintra/tejo unchanged (serial present, camera present, no console dupe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:31:36 +02:00
c8745addbc fix(deploy): make disk-image-sintra-usb BIOS+UEFI bootable (GRUB)
The USB disk image was systemd-boot (UEFI-only) with make-disk-image's "efi"
table (pure GPT + ESP, protective MBR). The Sintra's Aaeon UP Board firmware
USB-boots in Legacy/BIOS mode — it boots the live ISO via that ISO's isolinux
(BIOS) El Torito image, not the UEFI ESP — so a dd'd systemd-boot image has no
BIOS boot code to execute and the firmware won't list it (a hand-added hybrid
MBR didn't help: nothing to run).

Switch the USB target to GRUB with BIOS + UEFI on make-disk-image's "hybrid"
table: it adds a bios_grub partition, GRUB writes its BIOS stage to the MBR AND
a removable /EFI/BOOT/BOOTX64.EFI — mirroring the live ISO's dual boot. The
Aaeon now lists it (as two "ia android" entries, BIOS + UEFI) and boots it.
Scoped to disk-image-sintra-usb only; the eMMC install keeps systemd-boot.
ESP stays partition 1 so the ESP-USB relabel step is unchanged.

Verified on hardware: booted from USB into the wizard with the full upboard.nix
hardware config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:31:36 +02:00
5a420119df perf(deploy): slim the kiosk closure (disable TTS, Qt, docs)
The disk image was ~6.2 GiB of closure, largely desktop/multimedia baggage a
single-purpose Electron kiosk never uses. Cut the clearly-unused stacks:

- services.speechd off → drops speech-dispatcher's espeak-ng + mbrola voices
  (~1 GB text-to-speech). An ATM does not talk.
- v4l-utils built withGUI=false → drops the entire Qt6 stack (~0.5 GB) that only
  backed the qv4l2 GUI; the v4l2-ctl CLI we actually use for the camera stays.
- documentation off (man/info/NixOS manual) — nobody reads them on a kiosk.

Closure 6.2 → 5.0 GiB. The remaining bulk is electron's own runtime (gtk4/
gstreamer/pipewire, unavoidable), mesa+llvm (GPU), and linux-firmware — those
need heavier / riskier work to touch. Distribute the image as .img.zst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:31:36 +02:00
30 changed files with 1071 additions and 370 deletions

View file

@ -82,9 +82,9 @@ Renderer reads (Electron IPC or Vite `import.meta.env`):
| Var | Required | Notes | | Var | Required | Notes |
|---|---|---| |---|---|---|
| `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) | | `VITE_RELAY_URL` | no (seed-provided) | Relay both ATM and LNbits subscribe to. **Comes from the pairing seed** (aiolabs/bitspire#70); set this only as an override — it WINS over the seed via env-first precedence. Dev override: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) |
| `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) | | `VITE_LNBITS_SERVER_PUBKEY` | no (seed-provided) | 64-char hex transport pubkey. **Comes from the seed's `lnbits_npub`** (#70); env override only. LNbits prints it on startup (`docker logs lnbits \| grep 'Public key (share this)'`) |
| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:<base64url>`) from spirekeeper. Carries a one-shot NIP-46 connect token + the spire signing pubkey + bunker URL. First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. Provisioning it up front is optional — an unpaired machine renders an on-screen QR-pairing wizard that scans the seed off the camera (see below). See aiolabs/bitspire#52. | | `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:<base64url>`) from spirekeeper. Carries the relay(s), the LNbits transport pubkey (`lnbits_npub`), the spire signing pubkey (`spire_npub`), and a one-shot NIP-46 connect token (#70 slimmed the shape). First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. Provisioning it up front is optional — an unpaired machine renders an on-screen QR-pairing wizard that scans the seed off the camera (see below). See aiolabs/bitspire#52. |
| `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. | | `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. |
| `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands | | `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands |

View file

@ -19,11 +19,15 @@ VITE_LAMASSU_FIAT_CODE=USD
# VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]' # VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]'
# ============================================================================= # =============================================================================
# LNbits Connection (Required) — nostr-native-transport # LNbits Connection (dev override — normally seed-provided) — nostr-native-transport
# ============================================================================= # =============================================================================
# On a real machine the pairing SEED (VITE_SPIRE_SEED) carries the relay AND the
# server pubkey (aiolabs/bitspire#70), so leave both blank there. Set them here
# only for browser dev without a seed/bunker — they WIN over the seed.
# Nostr relay WebSocket URL — relay LNbits is subscribed to. # Nostr relay WebSocket URL. Dev stack uses LNbits's bundled nostrrelay:
VITE_RELAY_URL=ws://localhost:7777 # VITE_RELAY_URL=ws://localhost:5001/nostrrelay/test
VITE_RELAY_URL=
# LNbits nostr-transport server pubkey (hex, 64 chars). # LNbits nostr-transport server pubkey (hex, 64 chars).
# Printed by the LNbits server on startup: # Printed by the LNbits server on startup:

View file

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

View file

@ -27,6 +27,7 @@ import {
getBootstrapPublishedAt, getBootstrapPublishedAt,
markBootstrapPublished, markBootstrapPublished,
resetBootstrapGate, resetBootstrapGate,
resetForRepair,
applyOperatorCassettesConfig, applyOperatorCassettesConfig,
getFeeConfig, getFeeConfig,
getLastKnownFeeConfigCreatedAt, getLastKnownFeeConfigCreatedAt,
@ -278,8 +279,11 @@ ipcMain.handle('watchdog:pong', () => {
// pragma: allowlist secret end // pragma: allowlist secret end
ipcMain.handle('get-config', () => { ipcMain.handle('get-config', () => {
return { return {
// LNbits nostr-transport connection (public info only) // LNbits nostr-transport connection (public info only). Empty when
relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777', // unprovisioned — the renderer then falls through to the pairing seed's
// relay (aiolabs/bitspire#70). A non-empty default here would win via the
// env-first precedence and override the seed.
relayUrl: process.env.VITE_RELAY_URL || '',
lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '', lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '',
appId: process.env.VITE_APP_ID || '', appId: process.env.VITE_APP_ID || '',
@ -352,6 +356,9 @@ ipcMain.handle('state:clear-bunker-binding', (): void => {
ipcMain.handle('state:reset-bootstrap-gate', (): void => { ipcMain.handle('state:reset-bootstrap-gate', (): void => {
resetBootstrapGate() resetBootstrapGate()
}) })
ipcMain.handle('state:reset-for-repair', (): void => {
resetForRepair()
})
// QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a // QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a
// spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the // spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the

View file

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

View file

@ -15,7 +15,7 @@ import fs from 'node:fs'
let db: Database.Database | null = null let db: Database.Database | null = null
const SCHEMA_VERSION = '11' const SCHEMA_VERSION = '12'
function getDbPath(): string { function getDbPath(): string {
const prodDir = '/var/lib/bitspire' const prodDir = '/var/lib/bitspire'
@ -121,7 +121,9 @@ export function initDatabase(dbPath?: string): void {
spire_pubkey TEXT NOT NULL, spire_pubkey TEXT NOT NULL,
bunker_url TEXT NOT NULL, bunker_url TEXT NOT NULL,
seed_fingerprint TEXT NOT NULL, seed_fingerprint TEXT NOT NULL,
paired_at INTEGER NOT NULL paired_at INTEGER NOT NULL,
relays TEXT,
lnbits_server_pubkey TEXT
); );
`) `)
@ -352,6 +354,21 @@ export function initDatabase(dbPath?: string): void {
`) `)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version')
console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)') console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)')
existing.value = '11'
}
if (existing && existing.value === '11') {
// Migration v11 → v12: carry the LNbits transport config in the binding
// (aiolabs/bitspire#70). relays (JSON array) + lnbits_server_pubkey let a
// paired machine reach the backend from the pairing alone — no VITE_RELAY_URL
// / VITE_LNBITS_SERVER_PUBKEY provisioning. Nullable: bindings written before
// this (the seed didn't carry them) resume fine and fall back to env.
db.exec(`
ALTER TABLE bunker_binding ADD COLUMN relays TEXT;
ALTER TABLE bunker_binding ADD COLUMN lnbits_server_pubkey TEXT;
`)
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('12', 'schema_version')
console.log('[StateStore] Migrated schema v11 → v12 (bunker_binding transport config)')
} }
// Defensive: a fresh install at SCHEMA_VERSION skips all migrations. // Defensive: a fresh install at SCHEMA_VERSION skips all migrations.
@ -428,6 +445,14 @@ export interface StoredBunkerBinding {
seedFingerprint: string seedFingerprint: string
/** Unix seconds when the pairing was redeemed. */ /** Unix seconds when the pairing was redeemed. */
pairedAt: number pairedAt: number
/**
* LNbits transport relays from the pairing seed (aiolabs/bitspire#70). Lets a
* resumed (seedless) boot reach the backend without env provisioning.
* Undefined for bindings written before the seed carried them.
*/
relays?: string[]
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
lnbitsServerPubkey?: string
} }
/** Read the persisted bunker binding, or null if the ATM is unpaired. */ /** Read the persisted bunker binding, or null if the ATM is unpaired. */
@ -435,7 +460,7 @@ export function getBunkerBinding(): StoredBunkerBinding | null {
if (!db) throw new Error('Database not initialized') if (!db) throw new Error('Database not initialized')
const row = db const row = db
.prepare( .prepare(
'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at FROM bunker_binding WHERE id = 1' 'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey FROM bunker_binding WHERE id = 1'
) )
.get() as .get() as
| { | {
@ -444,6 +469,8 @@ export function getBunkerBinding(): StoredBunkerBinding | null {
bunker_url: string bunker_url: string
seed_fingerprint: string seed_fingerprint: string
paired_at: number paired_at: number
relays: string | null
lnbits_server_pubkey: string | null
} }
| undefined | undefined
if (!row) return null if (!row) return null
@ -453,27 +480,47 @@ export function getBunkerBinding(): StoredBunkerBinding | null {
bunkerUrl: row.bunker_url, bunkerUrl: row.bunker_url,
seedFingerprint: row.seed_fingerprint, seedFingerprint: row.seed_fingerprint,
pairedAt: row.paired_at, pairedAt: row.paired_at,
relays: parseRelaysColumn(row.relays),
lnbitsServerPubkey: row.lnbits_server_pubkey ?? undefined,
} }
} }
/** Decode the JSON-array `relays` column, tolerating null/legacy/garbage. */
function parseRelaysColumn(value: string | null): string[] | undefined {
if (!value) return undefined
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed) && parsed.every((r) => typeof r === 'string')) {
return parsed as string[]
}
} catch {
// fall through
}
return undefined
}
/** Upsert the bunker binding after a successful (re-)pairing. */ /** Upsert the bunker binding after a successful (re-)pairing. */
export function saveBunkerBinding(binding: StoredBunkerBinding): void { export function saveBunkerBinding(binding: StoredBunkerBinding): void {
if (!db) throw new Error('Database not initialized') if (!db) throw new Error('Database not initialized')
db.prepare( db.prepare(
`INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at) `INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey)
VALUES (1, ?, ?, ?, ?, ?) VALUES (1, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
client_secret_hex = excluded.client_secret_hex, client_secret_hex = excluded.client_secret_hex,
spire_pubkey = excluded.spire_pubkey, spire_pubkey = excluded.spire_pubkey,
bunker_url = excluded.bunker_url, bunker_url = excluded.bunker_url,
seed_fingerprint = excluded.seed_fingerprint, seed_fingerprint = excluded.seed_fingerprint,
paired_at = excluded.paired_at` paired_at = excluded.paired_at,
relays = excluded.relays,
lnbits_server_pubkey = excluded.lnbits_server_pubkey`
).run( ).run(
binding.clientSecretHex, binding.clientSecretHex,
binding.spirePubkey, binding.spirePubkey,
binding.bunkerUrl, binding.bunkerUrl,
binding.seedFingerprint, binding.seedFingerprint,
binding.pairedAt binding.pairedAt,
binding.relays ? JSON.stringify(binding.relays) : null,
binding.lnbitsServerPubkey ?? null
) )
} }
@ -493,6 +540,32 @@ export function resetBootstrapGate(): void {
db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt') db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt')
} }
/**
* Wipe operator-scoped CONFIG/TRUST state on a re-pair to a new operator/backend,
* so stale policy from the previous pairing can't linger or silently reject the
* new operator's config.
*
* Clears the fee config and resets BOTH replay watermarks to 0. The watermark
* reset is the load-bearing part: without it, a new backend whose first config
* event has a lower `created_at` than the old operator's last event is silently
* dropped as a replay — the exact remnant trap where re-pairing a long-lived
* install to a fresh backend appears to "work" but never picks up new config.
*
* Deliberately does NOT touch cassettes / cashbox / transactions: those track
* PHYSICAL cash, which survives an operator handover. A full wipe (decommission
* or a truly-fresh test) is the factory-reset path, not this.
*/
export function resetForRepair(): void {
if (!db) throw new Error('Database not initialized')
const database = db
database.transaction(() => {
database.prepare('DELETE FROM fee_config').run()
const setWatermark = database.prepare('UPDATE meta SET value = ? WHERE key = ?')
setWatermark.run('0', 'lastKnownFeeConfigCreatedAt')
setWatermark.run('0', 'lastKnownConfigCreatedAt')
})()
}
export type OperatorCassettesPayload = { export type OperatorCassettesPayload = {
positions: Record<string, { denomination: number; count: number }> positions: Record<string, { denomination: number; count: number }>
} }

View file

@ -103,10 +103,16 @@ onMounted(async () => {
try { try {
const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client') const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client')
const { resolveSigner } = await import('@/services/signer-resolver') const { resolveSigner } = await import('@/services/signer-resolver')
const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL
// Best-effort: resolve a signer (bunker resume / pairing, or dev nsec). // Best-effort: resolve a signer (bunker resume / pairing, or dev nsec).
// If the ATM isn't paired yet, skip the beacon rather than fail the screen. // If the ATM isn't paired yet, skip the beacon rather than fail the screen.
const signer = await resolveSigner({ allowEphemeral: true }).catch(() => null) const resolved = await resolveSigner({ allowEphemeral: true }).catch(() => null)
const signer = resolved?.signer ?? null
// Same env → pairing-seed precedence as lightning.ts: on a blank-.env
// seed-driven machine the relay comes from the pairing transport, not env.
const relayUrl =
config?.relayUrl ||
import.meta.env.VITE_RELAY_URL ||
resolved?.transport?.relays?.[0]
if (signer && relayUrl) { if (signer && relayUrl) {
const client = new NostrClient({ relays: [{ url: relayUrl }], signer }) const client = new NostrClient({ relays: [{ url: relayUrl }], signer })
await client.connect() await client.connect()

View file

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

View file

@ -54,7 +54,10 @@ interface LightningConfig {
*/ */
async function loadLightningConfig(): Promise<LightningConfig> { async function loadLightningConfig(): Promise<LightningConfig> {
const defaults: LightningConfig = { const defaults: LightningConfig = {
relayUrl: 'ws://localhost:7777', // Empty when unset (not the dev relay) so initializeLightningServices can
// tell "operator gave us a relay" from "fall back to the pairing seed". See
// aiolabs/bitspire#70 and DEV_DEFAULT_RELAY.
relayUrl: '',
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
operatorPubkeys: [], operatorPubkeys: [],
lnbitsServerPubkey: '', lnbitsServerPubkey: '',
@ -97,6 +100,10 @@ async function loadLightningConfig(): Promise<LightningConfig> {
// Config is loaded async now - will be set in initializeLightningServices // Config is loaded async now - will be set in initializeLightningServices
let CONFIG: LightningConfig let CONFIG: LightningConfig
/** Dev-only relay used when neither env nor the pairing supplies one. Matches
* the dev stack — LNbits's bundled nostrrelay (no separate strfry container). */
const DEV_DEFAULT_RELAY = 'ws://localhost:5001/nostrrelay/test'
/** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime. /** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime.
* Sessions are normally cleaned up by the state machine on idle transition. * Sessions are normally cleaned up by the state machine on idle transition.
* This is a safety net in case the state machine doesn't clean up properly. */ * This is a safety net in case the state machine doesn't clean up properly. */
@ -395,9 +402,6 @@ export async function initializeLightningServices(options?: {
// Load configuration (async for Electron runtime config) // Load configuration (async for Electron runtime config)
CONFIG = await loadLightningConfig() CONFIG = await loadLightningConfig()
console.log('[Lightning] Relay URL:', CONFIG.relayUrl)
console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)')
// Resolve the signing identity BEFORE validating the LNbits transport // Resolve the signing identity BEFORE validating the LNbits transport
// config. An unpaired machine must reach the QR-pairing wizard regardless // config. An unpaired machine must reach the QR-pairing wizard regardless
// of relay/server-pubkey provisioning — pairing is what provides those — so // of relay/server-pubkey provisioning — pairing is what provides those — so
@ -410,17 +414,53 @@ export async function initializeLightningServices(options?: {
// transport key; the operator's nsecbunkerd holds the signing key); in dev // transport key; the operator's nsecbunkerd holds the signing key); in dev
// it falls back to an in-process LocalSigner. The Phase-A Signer seam means // it falls back to an in-process LocalSigner. The Phase-A Signer seam means
// nothing downstream changes. See aiolabs/bitspire#52. // nothing downstream changes. See aiolabs/bitspire#52.
const signer: Signer = await resolveSigner({ allowEphemeral: !options?.strict }) const { signer, transport } = await resolveSigner({ allowEphemeral: !options?.strict })
console.log('[Lightning] ATM pubkey:', signer.pubkey) console.log('[Lightning] ATM pubkey:', signer.pubkey)
// Strict mode: validate config is production-ready (no localhost). // Resolve the effective LNbits transport. Precedence: explicit env wins (dev
// + operator override), else the pairing (seed/binding) supplies it (#70) so
// a blank-.env paired machine reaches the backend from the seed alone, else a
// dev-only localhost fallback. CONFIG is mutated to the resolved values so
// downstream (and the exported CONFIG) see a single source of truth.
const envRelay = CONFIG.relayUrl
const envPubkey = CONFIG.lnbitsServerPubkey
const relays: string[] = envRelay
? [envRelay]
: transport && transport.relays.length > 0
? transport.relays
: [DEV_DEFAULT_RELAY]
CONFIG.relayUrl = relays[0]!
CONFIG.lnbitsServerPubkey = envPubkey || transport?.lnbitsServerPubkey || ''
console.log(
'[Lightning] Relay(s):',
relays.join(', '),
envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)',
)
console.log(
'[Lightning] LNbits server pubkey:',
CONFIG.lnbitsServerPubkey || '(not configured)',
envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : '',
)
// Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS
// (env). An empty set disables the fees/operator-config services → the machine
// sits at "awaiting configuration" — so log it loudly rather than fail silent.
// (aiolabs/bitspire#70 P1 will source this from LNbits over the transport.)
console.log(
'[Lightning] Operator pubkey(s):',
CONFIG.operatorPubkeys.length
? CONFIG.operatorPubkeys.join(', ') + ' (env)'
: '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)',
)
// Strict mode: validate the RESOLVED config is production-ready (no
// localhost). Values may come from env or the pairing seed (#70).
if (options?.strict) { if (options?.strict) {
const errors: string[] = [] const errors: string[] = []
if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) { if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) {
errors.push('VITE_RELAY_URL contains localhost') errors.push('relay resolves to localhost (VITE_RELAY_URL / seed relays)')
} }
if (!CONFIG.lnbitsServerPubkey) { if (!CONFIG.lnbitsServerPubkey) {
errors.push('VITE_LNBITS_SERVER_PUBKEY is not set') errors.push('no LNbits server pubkey (VITE_LNBITS_SERVER_PUBKEY / seed lnbits_npub)')
} }
if (errors.length > 0) { if (errors.length > 0) {
throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- ')) throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- '))
@ -429,17 +469,17 @@ export async function initializeLightningServices(options?: {
// Validate required configuration. Reached only for a paired machine (an // Validate required configuration. Reached only for a paired machine (an
// unpaired one threw NoPairingError above) — it needs the LNbits server // unpaired one threw NoPairingError above) — it needs the LNbits server
// pubkey to talk to the transport. // pubkey to talk to the transport, from either env or the pairing seed.
if (!CONFIG.lnbitsServerPubkey) { if (!CONFIG.lnbitsServerPubkey) {
throw new Error( throw new Error(
'[Lightning] VITE_LNBITS_SERVER_PUBKEY is required. ' + '[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' +
'Get it from: docker logs lnbits | grep nostr_transport pubkey', 'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).',
) )
} }
// Create Nostr client // Create Nostr client
const nostrClient = new NostrClient({ const nostrClient = new NostrClient({
relays: [{ url: CONFIG.relayUrl }], relays: relays.map((url) => ({ url })),
signer, signer,
}) })
@ -449,7 +489,7 @@ export async function initializeLightningServices(options?: {
// LNbits nostr-transport client. // LNbits nostr-transport client.
const lnbits = new LnbitsClient({ const lnbits = new LnbitsClient({
serverPubkey: CONFIG.lnbitsServerPubkey, serverPubkey: CONFIG.lnbitsServerPubkey,
relays: [CONFIG.relayUrl], relays,
}) })
lnbits.initialize(nostrClient, signer) lnbits.initialize(nostrClient, signer)
_lnbitsRef = lnbits _lnbitsRef = lnbits
@ -465,6 +505,46 @@ export async function initializeLightningServices(options?: {
} }
console.log('[Lightning] LNbits wallet:', lnbitsWalletId) 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. // CLINK client — kept in tree but not actively wired into LNbits flows.
// operatorPubkey is the operator allowlist for kind-21003 management // operatorPubkey is the operator allowlist for kind-21003 management
// commands; it has no Lightning.Pub dependency. // commands; it has no Lightning.Pub dependency.
@ -472,7 +552,7 @@ export async function initializeLightningServices(options?: {
nostrClient, nostrClient,
signer, signer,
operatorPubkey: CONFIG.operatorPubkeys, operatorPubkey: CONFIG.operatorPubkeys,
relays: [CONFIG.relayUrl], relays,
}) })
// Callbacks for events // Callbacks for events

View file

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

View file

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

View file

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

View file

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

View file

@ -26,6 +26,7 @@ import {
parseSpireSeed, parseSpireSeed,
seedFingerprint, seedFingerprint,
type Signer, type Signer,
type SpireSeed,
} from '@bitSpire/nostr-client' } from '@bitSpire/nostr-client'
import type { BunkerBindingRecord } from '@/types/electron' import type { BunkerBindingRecord } from '@/types/electron'
@ -50,6 +51,25 @@ export interface ResolveSignerOptions {
allowEphemeral: boolean allowEphemeral: boolean
} }
/** LNbits transport config carried by the pairing (aiolabs/bitspire#70). */
export interface TransportConfig {
/** LNbits transport relays (kind-21000 / 30078). */
relays: string[]
/** LNbits nostr-transport server pubkey (hex). */
lnbitsServerPubkey: string
}
export interface ResolvedSigner {
signer: Signer
/**
* Transport config sourced from the pairing — the seed on a fresh pair /
* seeded resume, the binding on a seedless resume. Null when unavailable (an
* ephemeral dev signer, or a pre-#70 binding that never stored it); the
* caller then falls back to env provisioning.
*/
transport: TransportConfig | null
}
interface PairingState { interface PairingState {
spireSeed: string spireSeed: string
binding: BunkerBindingRecord | null binding: BunkerBindingRecord | null
@ -64,20 +84,55 @@ async function loadPairingState(): Promise<PairingState> {
return { spireSeed: (import.meta.env.VITE_SPIRE_SEED as string | undefined) || '', binding: null } return { spireSeed: (import.meta.env.VITE_SPIRE_SEED as string | undefined) || '', binding: null }
} }
export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer> { export async function resolveSigner(opts: ResolveSignerOptions): Promise<ResolvedSigner> {
const { spireSeed, binding } = await loadPairingState() const { spireSeed, binding } = await loadPairingState()
const resume = (b: BunkerBindingRecord): Promise<Signer> =>
resumeFromBinding({
clientSecretHex: b.clientSecretHex,
spirePubkey: b.spirePubkey,
bunkerUrl: b.bunkerUrl,
})
// Transport config from a binding — present only when the pairing seed
// carried it (post-#70) and it was persisted. Null on pre-#70 bindings.
const transportFromBinding = (b: BunkerBindingRecord): TransportConfig | null =>
b.relays && b.relays.length > 0 && b.lnbitsServerPubkey
? { relays: b.relays, lnbitsServerPubkey: b.lnbitsServerPubkey }
: null
const transportFromSeed = (s: SpireSeed): TransportConfig => ({
relays: s.relays,
lnbitsServerPubkey: s.lnbitsServerPubkey,
})
if (spireSeed) { if (spireSeed) {
const seed = parseSpireSeed(spireSeed) let seed: SpireSeed
const fingerprint = seedFingerprint(spireSeed) let fingerprint: string
try {
seed = parseSpireSeed(spireSeed)
fingerprint = seedFingerprint(spireSeed)
} catch (err) {
// A stored seed we can't parse — e.g. a legacy-shape seed left in .env
// after the seed format changed (bitspire-#70). If we already hold a
// binding it's authoritative (server-persistent), so resume from it
// rather than bricking a paired machine on the next boot. With no
// binding the seed is our only pairing input, so fail closed.
if (binding) {
console.warn(
'[Signer] Stored spire seed is unparseable; resuming from existing binding:',
(err as Error).message,
)
return { signer: await resume(binding), transport: transportFromBinding(binding) }
}
throw err
}
if (binding && binding.seedFingerprint === fingerprint) { if (binding && binding.seedFingerprint === fingerprint) {
console.log('[Signer] Resuming bunker session for spire', seed.spirePubkey) console.log('[Signer] Resuming bunker session for spire', seed.spirePubkey)
return resumeFromBinding({ // Seed present + parsed → prefer its (fresh) transport config over the
clientSecretHex: binding.clientSecretHex, // binding's, which may predate the seed carrying transport (pre-#70).
spirePubkey: binding.spirePubkey, return { signer: await resume(binding), transport: transportFromSeed(seed) }
bunkerUrl: binding.bunkerUrl,
})
} }
// First pair or re-pair: redeem the one-shot connect secret. // First pair or re-pair: redeem the one-shot connect secret.
@ -89,27 +144,36 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer>
clientSecretHex: transport.secretHex, clientSecretHex: transport.secretHex,
}) })
if (isElectron && window.electronAPI) { if (isElectron && window.electronAPI) {
// Re-pair (a NEW seed replacing a prior binding) → wipe the previous
// operator's config/trust state (fee config + replay watermarks) so it
// can't linger or silently replay-block the new operator's config. A
// first pair (no prior binding) has nothing to reset. Cash accounting is
// preserved — see resetForRepair; a full wipe is the factory-reset path.
if (binding) {
console.log('[Signer] Re-pair (new seed fingerprint) — clearing prior operator config state')
await window.electronAPI.resetForRepair()
}
// Persist the seed's transport config alongside the binding so a later
// seedless resume still reaches the backend without env provisioning.
await window.electronAPI.saveBunkerBinding({ await window.electronAPI.saveBunkerBinding({
clientSecretHex: transport.secretHex, clientSecretHex: transport.secretHex,
spirePubkey: seed.spirePubkey, spirePubkey: seed.spirePubkey,
bunkerUrl: seed.bunkerUrl, bunkerUrl: seed.bunkerUrl,
seedFingerprint: fingerprint, seedFingerprint: fingerprint,
pairedAt: Math.floor(Date.now() / 1000), pairedAt: Math.floor(Date.now() / 1000),
relays: seed.relays,
lnbitsServerPubkey: seed.lnbitsServerPubkey,
}) })
// Re-pair → re-publish the cassette-state hello to the new operator (#56). // Re-pair → re-publish the cassette-state hello to the new operator (#56).
await window.electronAPI.resetBootstrapGate() await window.electronAPI.resetBootstrapGate()
} }
return signer return { signer, transport: transportFromSeed(seed) }
} }
// No seed in this boot but a binding survives → resume. // No seed in this boot but a binding survives → resume.
if (binding) { if (binding) {
console.log('[Signer] Resuming bunker session from stored binding (no seed this boot)') console.log('[Signer] Resuming bunker session from stored binding (no seed this boot)')
return resumeFromBinding({ return { signer: await resume(binding), transport: transportFromBinding(binding) }
clientSecretHex: binding.clientSecretHex,
spirePubkey: binding.spirePubkey,
bunkerUrl: binding.bunkerUrl,
})
} }
if (opts.allowEphemeral) { if (opts.allowEphemeral) {
@ -117,10 +181,10 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise<Signer>
const devKey = !isElectron ? (import.meta.env.VITE_ATM_PRIVATE_KEY as string | undefined) : '' const devKey = !isElectron ? (import.meta.env.VITE_ATM_PRIVATE_KEY as string | undefined) : ''
if (devKey) { if (devKey) {
console.warn('[Signer] No bunker pairing — using LocalSigner from VITE_ATM_PRIVATE_KEY (dev)') console.warn('[Signer] No bunker pairing — using LocalSigner from VITE_ATM_PRIVATE_KEY (dev)')
return new LocalSigner(loadIdentityFromHex(devKey)) return { signer: new LocalSigner(loadIdentityFromHex(devKey)), transport: null }
} }
console.warn('[Signer] No bunker pairing — generated ephemeral LocalSigner (dev only)') console.warn('[Signer] No bunker pairing — generated ephemeral LocalSigner (dev only)')
return new LocalSigner(generateIdentity()) return { signer: new LocalSigner(generateIdentity()), transport: null }
} }
throw new NoPairingError() throw new NoPairingError()

View file

@ -6,10 +6,6 @@ export interface RuntimeConfig {
relayUrl: string relayUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */ /** LNbits nostr-transport server pubkey (hex, 64 chars). */
lnbitsServerPubkey: string lnbitsServerPubkey: string
/** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
lightningPubPubkey?: string
lightningPubApiUrl?: string
extensionApiUrl?: string
appId: string appId: string
machineModel: string machineModel: string
fiatCode: string fiatCode: string
@ -46,6 +42,10 @@ export interface BunkerBindingRecord {
bunkerUrl: string bunkerUrl: string
seedFingerprint: string seedFingerprint: string
pairedAt: number pairedAt: number
/** LNbits transport relays from the seed (#70); absent on pre-#70 bindings. */
relays?: string[]
/** LNbits nostr-transport server pubkey (hex) from the seed (#70). */
lnbitsServerPubkey?: string
} }
export interface AtmSecrets { export interface AtmSecrets {
@ -98,6 +98,7 @@ declare global {
saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void> saveBunkerBinding: (binding: BunkerBindingRecord) => Promise<void>
clearBunkerBinding: () => Promise<void> clearBunkerBinding: () => Promise<void>
resetBootstrapGate: () => Promise<void> resetBootstrapGate: () => Promise<void>
resetForRepair: () => Promise<void>
saveSpireSeed: (seed: string) => Promise<void> saveSpireSeed: (seed: string) => Promise<void>
relaunchApp: () => Promise<void> relaunchApp: () => Promise<void>
applyOperatorCassettesConfig: ( applyOperatorCassettesConfig: (

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,16 @@
# System basics # System basics
system.stateVersion = "24.05"; system.stateVersion = "24.05";
# ── Image slimming (bitspire#70 sizing) ──────────────────────────────
# This is a single-purpose Electron kiosk; strip the desktop/multimedia
# baggage NixOS pulls in by default so the disk image stays lean.
# - speechd: text-to-speech (speech-dispatcher → espeak-ng → mbrola, ~1GB).
# An ATM does not talk.
# - documentation: man/info/NixOS manual — no one reads them on a kiosk.
services.speechd.enable = lib.mkForce false;
documentation.enable = false;
documentation.nixos.enable = false;
# Networking # Networking
networking = { networking = {
hostName = "bitspire"; hostName = "bitspire";
@ -121,8 +131,10 @@
# Node.js for the application # Node.js for the application
pkgs-unstable.nodejs_22 pkgs-unstable.nodejs_22
# Camera support # Camera support. v4l-utils' default build drags in the whole Qt6 stack
v4l-utils # for its qv4l2 GUI (~0.5GB) — we only ever use the v4l2-ctl CLI, so drop
# the GUI.
(v4l-utils.override { withGUI = false; })
fswebcam fswebcam
# ATM operations # ATM operations

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

@ -0,0 +1,61 @@
# UP Board serial peripherals — the validator / dispenser / printer wiring
# shared by the INSTALLED configs (hardware/upboard.nix, used by both
# tejo-installed and sintra-installed) AND the sintra live ISO (live.nix).
# Single source of truth so the two artifacts can't drift — the earlier bug
# was exactly this drift (the sintra live ISO lacked ftdi_sio + the ttyJ7
# symlink, so the F56 dispenser failed while the installed image worked).
#
# Sintra IS a UP Board, so these are the UP Board rules; ttyS1/ttyS5 cover the
# older UP Board / UP4000 (Tejo) dispenser nodes and ttyS4 covers the Sintra
# (Apollo Lake) where the F56 is on the SoC MMIO UART. Only the device that
# actually exists at runtime gets the symlink, so all three coexist safely.
#
# Serial port mapping:
# ttyJ4 = Printer (Nippon NP-2511D-2)
# ttyJ5 = Validator (iVIZION, ID003)
# ttyJ7 = Dispenser (Fujitsu F53/F56)
{ lib, ... }:
{
boot.kernelModules = [
"usbserial" # USB-to-serial adapters
"ftdi_sio" # FTDI USB serial (the iVIZION validator bridge)
"cp210x" # CP210x USB serial (alternative adapter)
];
boot.kernelParams = [
# Do NOT route the kernel console through ttyS4 on Sintra. ttyS4 is the
# SoC's MMIO 16550A (the only real UART besides the legacy ttyS0 at I/O
# 0x3f8) and is wired to the Fujitsu F56 dispenser's RS-232 header. Holding
# it as console prevents userspace opening it at 9600 baud and HAL fails
# with "Input/output error setting custom baud rate of 9600". For serial
# debug, point console at ttyS0 instead.
"console=tty0"
];
services.udev.extraRules = lib.mkAfter ''
# Generic serial port permissions (so the non-root HAL user can open them)
KERNEL=="ttyS[0-9]*", MODE="0666"
KERNEL=="ttyUSB[0-9]*", MODE="0666"
KERNEL=="ttyACM[0-9]*", MODE="0666"
# Printer (ttyJ4)
KERNELS=="1-7.2:1.0", SYMLINK+="ttyJ4"
KERNEL=="ttyUSB0", SYMLINK+="ttyJ4"
# Validator (ttyJ5)
KERNELS=="1-7.3:1.0", SYMLINK+="ttyJ5"
KERNEL=="ttyUSB1", SYMLINK+="ttyJ5"
# Dispenser (ttyJ7). ttyS1/ttyS5 = older UP Board / UP4000; ttyS4 = Sintra.
KERNEL=="ttyS1", SYMLINK+="ttyJ7"
KERNEL=="ttyS4", SYMLINK+="ttyJ7"
KERNEL=="ttyS5", SYMLINK+="ttyJ7"
# Legacy ttyAMA0 alias
SUBSYSTEM=="tty", KERNEL=="ttyS1", SYMLINK+="ttyAMA0", GROUP="dialout"
# Disable USB autosuspend (prevents serial adapters from sleeping)
ACTION=="add", SUBSYSTEM=="usb", TEST=="power/control", ATTR{power/control}="on"
'';
}

View file

@ -11,6 +11,10 @@
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
{ {
# Serial peripherals (validator/dispenser/printer modules + udev symlinks +
# console=tty0) are shared with the live ISO via ./upboard-serial.nix.
imports = [ ./upboard-serial.nix ];
boot = { boot = {
loader = { loader = {
systemd-boot.enable = true; systemd-boot.enable = true;
@ -42,21 +46,12 @@
"kvm-intel" "kvm-intel"
"i2c-dev" "i2c-dev"
"spi-dev" "spi-dev"
"usbserial" # USB-to-serial adapters # Serial modules (usbserial/ftdi_sio/cp210x) → ./upboard-serial.nix.
"ftdi_sio" # FTDI USB serial
"cp210x" # CP210x USB serial
]; ];
kernelParams = [ kernelParams = [
"i915.enable_psr=0" "i915.enable_psr=0"
# NOTE: do NOT route the kernel console through ttyS4 on Sintra. # console=tty0 (keeps ttyS4 free for the F56) → ./upboard-serial.nix.
# ttyS4 is the SoC's MMIO 16550A (the only real UART besides the
# legacy ttyS0 at I/O 0x3f8) and is wired to the Fujitsu F56
# dispenser's RS-232 header on Sintra. Holding it as console
# prevents userspace from opening it at 9600 baud and HAL fails
# with "Input/output error setting custom baud rate of 9600".
# If you want serial debug, point console at ttyS0 instead.
"console=tty0"
"quiet" "quiet"
"splash" "splash"
]; ];
@ -104,37 +99,9 @@
hybrid-sleep.enable = false; hybrid-sleep.enable = false;
}; };
# Serial port permissions + tejo-specific symlinks # Camera + LED/SPI peripherals. The serial rules (validator/dispenser/printer
# symlinks + permissions) are shared with the live ISO in ./upboard-serial.nix.
services.udev.extraRules = lib.mkAfter '' services.udev.extraRules = lib.mkAfter ''
# Generic serial port permissions
KERNEL=="ttyS[0-9]*", MODE="0666"
KERNEL=="ttyUSB[0-9]*", MODE="0666"
KERNEL=="ttyACM[0-9]*", MODE="0666"
# ── Tejo serial port symlinks ──────────────────────────────────────
# Both UP Board and UP4000 rules included (match different kernel paths)
# Printer (ttyJ4)
KERNELS=="1-7.2:1.0", SYMLINK+="ttyJ4"
KERNEL=="ttyUSB0", SYMLINK+="ttyJ4"
# Validator (ttyJ5)
KERNELS=="1-7.3:1.0", SYMLINK+="ttyJ5"
KERNEL=="ttyUSB1", SYMLINK+="ttyJ5"
# Dispenser (ttyJ7).
# ttyS1 / ttyS5 cover earlier UP Board variants where the dispenser
# lands on those kernel-enumerated serial nodes; ttyS4 covers the
# Sintra (UP Board Atom/Apollo Lake) where the dispenser is wired
# to the SoC's MMIO UART. Whichever device actually exists at
# runtime gets the ttyJ7 symlink.
KERNEL=="ttyS1", SYMLINK+="ttyJ7"
KERNEL=="ttyS4", SYMLINK+="ttyJ7"
KERNEL=="ttyS5", SYMLINK+="ttyJ7"
# Legacy ttyAMA0 alias
SUBSYSTEM=="tty", KERNEL=="ttyS1", SYMLINK+="ttyAMA0", GROUP="dialout"
# ── Camera devices ───────────────────────────────────────────────── # ── Camera devices ─────────────────────────────────────────────────
SUBSYSTEM=="video4linux", ATTR{index}=="0", KERNELS=="1-5", ATTRS{idVendor}=="0ac8", ATTRS{idProduct}=="0345", SYMLINK+="video-scan" SUBSYSTEM=="video4linux", ATTR{index}=="0", KERNELS=="1-5", ATTRS{idVendor}=="0ac8", ATTRS{idProduct}=="0345", SYMLINK+="video-scan"
SUBSYSTEM=="video4linux", ATTR{index}=="0", KERNELS=="1-2", ATTRS{idVendor}=="0ac8", ATTRS{idProduct}=="0345", SYMLINK+="video-scan" SUBSYSTEM=="video4linux", ATTR{index}=="0", KERNELS=="1-2", ATTRS{idVendor}=="0ac8", ATTRS{idProduct}=="0345", SYMLINK+="video-scan"
@ -147,8 +114,5 @@
SUBSYSTEM=="spidev", GROUP="spi", MODE="0660" SUBSYSTEM=="spidev", GROUP="spi", MODE="0660"
SUBSYSTEM=="i2c-dev", GROUP="i2c", MODE="0660" SUBSYSTEM=="i2c-dev", GROUP="i2c", MODE="0660"
SUBSYSTEM=="leds", KERNEL=="upboard:*", ACTION=="add|change", RUN+="${pkgs.findutils}/bin/find /sys$devpath -type f -exec ${pkgs.coreutils}/bin/chmod g+u {} + -exec ${pkgs.coreutils}/bin/chown :leds {} +" SUBSYSTEM=="leds", KERNEL=="upboard:*", ACTION=="add|change", RUN+="${pkgs.findutils}/bin/find /sys$devpath -type f -exec ${pkgs.coreutils}/bin/chmod g+u {} + -exec ${pkgs.coreutils}/bin/chown :leds {} +"
# Disable USB autosuspend (prevents serial adapters from sleeping)
ACTION=="add", SUBSYSTEM=="usb", TEST=="power/control", ATTR{power/control}="on"
''; '';
} }

View file

@ -21,18 +21,17 @@ let
batm3 = "USD"; batm3 = "USD";
}.${machineModel} or "USD"; }.${machineModel} or "USD";
# .env template — runtime secrets are provisioned later via provision-atm.sh. # Minimal .env template (aiolabs/bitspire#70 remnant hygiene). Seed ONLY
# Only non-secret defaults and display vars go here. VITE_SPIRE_SEED (the # image-baked, non-maskable values. Relay + server pubkey come from the pairing
# NIP-46 bunker pairing seed) is written at provision time; the dev-only # SEED, operator pubkey + fee config come from LNbits over the transport — so we
# VITE_ATM_PRIVATE_KEY fallback is omitted here on purpose. # deliberately do NOT pre-seed those keys (a present-but-empty VITE_RELAY_URL /
# VITE_LNBITS_SERVER_PUBKEY / VITE_OPERATOR_PUBKEYS would win over the seed and
# mask its source). VITE_SPIRE_SEED is written by the wizard / provision-atm.sh;
# the dev-only VITE_ATM_PRIVATE_KEY fallback is omitted on purpose.
envTemplate = pkgs.writeText "bitspire-env" '' envTemplate = pkgs.writeText "bitspire-env" ''
VITE_RELAY_URL=
VITE_LNBITS_SERVER_PUBKEY=
VITE_SPIRE_SEED=
VITE_APP_ID=
VITE_OPERATOR_PUBKEYS=
VITE_LAMASSU_MACHINE_MODEL=${machineModel} VITE_LAMASSU_MACHINE_MODEL=${machineModel}
VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel} VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel}
VITE_SPIRE_SEED=
ELECTRON_FORCE_PROD=1 ELECTRON_FORCE_PROD=1
DISPLAY=:0 DISPLAY=:0
''; '';
@ -48,7 +47,12 @@ in
# Reuse ATM systemd service module # Reuse ATM systemd service module
./bitspire-atm.nix ./bitspire-atm.nix
]; ]
# Sintra: share the UP Board serial hardware (validator/dispenser/printer
# modules + udev symlinks + console=tty0) with the installed image so the
# live ISO drives the same hardware. Safe to import here — unlike upboard.nix
# it declares no fileSystems, so there's no live-boot mount conflict.
++ lib.optionals (machineModel == "sintra") [ ./hardware/upboard-serial.nix ];
# ISO image settings # ISO image settings
image.fileName = "bitspire-${machineModel}-live.iso"; image.fileName = "bitspire-${machineModel}-live.iso";

View file

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

View file

@ -186,29 +186,34 @@
allowReboot = false; allowReboot = false;
}; };
# Env template — runtime secrets provisioned via provision-atm.sh. # Minimal env template (aiolabs/bitspire#70 remnant hygiene).
# Identity fields are intentionally empty so a fresh disk image # Seed ONLY image-baked, non-maskable values. Everything else the
# boots cleanly into the "needs provisioning" state; provision- # ATM needs comes from the pairing SEED (relay, lnbits_npub, bunker)
# atm.sh SSHes in and overwrites with real values. # or from LNbits over the transport (operator pubkey, fee config) —
# so we must NOT pre-seed those keys. A present-but-empty
# VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY / VITE_OPERATOR_PUBKEYS
# is a masking hazard: env WINS over the seed, and this activation
# only writes when .env is ABSENT, so any value written at first
# boot is frozen for the life of the disk. Leaving the keys out
# entirely lets the seed/transport be the sole source.
# #
# VITE_RELAY_URL seeds from `config.services.bitspire.relayUrl` # VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY are emitted ONLY when
# so the NixOS module's `relayUrl` option becomes the default # the operator deliberately pins them via the Nix options (non-empty
# without losing the operator's ability to override via .env # default ""), which is an explicit override that wins over the seed.
# (edit the file or re-run provision-atm.sh).
system.activationScripts.bitspire-env = '' system.activationScripts.bitspire-env = ''
mkdir -p /var/lib/bitspire mkdir -p /var/lib/bitspire
if [ ! -f /var/lib/bitspire/.env ]; then if [ ! -f /var/lib/bitspire/.env ]; then
cp ${pkgs.writeText "bitspire-env-default" '' cp ${pkgs.writeText "bitspire-env-default" (''
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
VITE_LNBITS_SERVER_PUBKEY=
VITE_SPIRE_SEED=
VITE_APP_ID=
VITE_OPERATOR_PUBKEYS=
VITE_LAMASSU_MACHINE_MODEL=${machineModel} VITE_LAMASSU_MACHINE_MODEL=${machineModel}
VITE_LAMASSU_FIAT_CODE=${fiatCode} VITE_LAMASSU_FIAT_CODE=${fiatCode}
VITE_SPIRE_SEED=
ELECTRON_FORCE_PROD=1 ELECTRON_FORCE_PROD=1
DISPLAY=:0 DISPLAY=:0
''} /var/lib/bitspire/.env '' + pkgs.lib.optionalString (config.services.bitspire.relayUrl != "") ''
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
'' + pkgs.lib.optionalString (config.services.bitspire.lnbitsServerPubkey != "") ''
VITE_LNBITS_SERVER_PUBKEY=${config.services.bitspire.lnbitsServerPubkey}
'')} /var/lib/bitspire/.env
chmod 600 /var/lib/bitspire/.env chmod 600 /var/lib/bitspire/.env
chown bitspire:bitspire /var/lib/bitspire/.env chown bitspire:bitspire /var/lib/bitspire/.env
fi fi
@ -342,6 +347,25 @@
fileSystems."/".device = lib.mkForce "/dev/disk/by-label/nixos-usb"; fileSystems."/".device = lib.mkForce "/dev/disk/by-label/nixos-usb";
fileSystems."/boot".device = lib.mkForce "/dev/disk/by-label/ESP-USB"; fileSystems."/boot".device = lib.mkForce "/dev/disk/by-label/ESP-USB";
system.autoUpgrade.enable = lib.mkForce false; system.autoUpgrade.enable = lib.mkForce false;
# The Sintra's Aaeon firmware USB-boots in Legacy/BIOS mode — it
# boots the live ISO via its isolinux (BIOS) El Torito image, not
# the UEFI ESP. systemd-boot is UEFI-only, so a dd'd systemd-boot
# image isn't recognised as bootable. Switch THIS USB image to
# GRUB with BOTH BIOS (MBR + bios_grub partition, via the "hybrid"
# table below) and UEFI (removable /EFI/BOOT/BOOTX64.EFI) — mirroring
# the live ISO's dual boot — so it boots on Legacy and UEFI alike.
# Scoped to the USB image; the eMMC install keeps systemd-boot.
boot.loader.systemd-boot.enable = lib.mkForce false;
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
boot.loader.grub = {
enable = lib.mkForce true;
efiSupport = true;
efiInstallAsRemovable = true;
# make-disk-image's build VM exposes the image as /dev/vda;
# GRUB installs its BIOS stage to that disk's MBR.
devices = lib.mkForce [ "/dev/vda" ];
};
}) })
]; ];
}; };
@ -349,7 +373,8 @@
inherit pkgs lib; inherit pkgs lib;
config = cfg.config; config = cfg.config;
format = "raw"; format = "raw";
partitionTableType = "efi"; # hybrid = GPT + bios_grub partition + ESP → BIOS + UEFI bootable.
partitionTableType = "hybrid";
diskSize = "auto"; diskSize = "auto";
label = "nixos-usb"; # ext4 root label (make-disk-image -L) label = "nixos-usb"; # ext4 root label (make-disk-image -L)
}; };

View file

@ -37,6 +37,7 @@ import type {
CreateInvoiceBody, CreateInvoiceBody,
PayInvoiceBody, PayInvoiceBody,
WalletInfo, WalletInfo,
MachineConfigResponse,
SubscribePaymentsBody, SubscribePaymentsBody,
SubscribeAck, SubscribeAck,
PaymentPushCallback, PaymentPushCallback,
@ -210,6 +211,17 @@ export class LnbitsClient {
return data ?? [] 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 // Invoices
// ============================================================================ // ============================================================================

View file

@ -274,3 +274,30 @@ export type PaymentPushCallback = (payment: LnbitsPayment) => void
/** Called when the subscription has been closed (by TTL or explicit unsubscribe). */ /** Called when the subscription has been closed (by TTL or explicit unsubscribe). */
export type SubscriptionCloseCallback = (reason: 'ttl' | 'unsubscribed') => void 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
}

View file

@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { npubEncode } from 'nostr-tools/nip19'
import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js' import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js'
/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */ /** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */
@ -12,41 +13,56 @@ function makeSeed(json: unknown): string {
} }
const SPIRE_PUBKEY = 'a'.repeat(64) const SPIRE_PUBKEY = 'a'.repeat(64)
const BUNKER_URL = `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef` const LNBITS_PUBKEY = 'b'.repeat(64)
const SPIRE_NPUB = npubEncode(SPIRE_PUBKEY)
const LNBITS_NPUB = npubEncode(LNBITS_PUBKEY)
const VALID = { const VALID = {
v: 1, v: 1,
spire_npub: 'npub1example', spire_npub: SPIRE_NPUB,
spire_pubkey: SPIRE_PUBKEY, lnbits_npub: LNBITS_NPUB,
bunker_url: BUNKER_URL, bunker_secret: 'deadbeef',
relays: ['wss://events.relay/'], relays: ['wss://events.relay/'],
} }
describe('parseSpireSeed', () => { describe('parseSpireSeed', () => {
it('parses a well-formed seed (snake_case → camelCase)', () => { it('derives hex pubkeys from npubs and reconstructs the bunker URL', () => {
const seed = parseSpireSeed(makeSeed(VALID)) const seed = parseSpireSeed(makeSeed(VALID))
expect(seed).toEqual({ expect(seed).toEqual({
v: 1, v: 1,
spirePubkey: SPIRE_PUBKEY, spirePubkey: SPIRE_PUBKEY,
bunkerUrl: BUNKER_URL, lnbitsServerPubkey: LNBITS_PUBKEY,
bunkerUrl: `bunker://${SPIRE_PUBKEY}?relay=${encodeURIComponent('wss://events.relay/')}&secret=deadbeef`,
relays: ['wss://events.relay/'], relays: ['wss://events.relay/'],
}) })
}) })
it('re-pads stripped base64url of any residue length', () => { it('defaults the bunker relay to relays[0] when bunker_relay is absent', () => {
// Vary a field so the encoded payload lands on each mod-4 residue. const seed = parseSpireSeed(makeSeed(VALID))
for (const suffix of ['', 'a', 'ab', 'abc']) { expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://events.relay/')}`)
const seed = makeSeed({ ...VALID, spire_npub: `npub1${suffix}` })
expect(() => parseSpireSeed(seed)).not.toThrow()
}
}) })
it('keeps bunker_url verbatim (percent-decoding is parseBunkerInput’s job)', () => { it('uses an explicit bunker_relay when present (distinct from event relays)', () => {
const seed = parseSpireSeed(makeSeed({ ...VALID, bunker_relay: 'wss://bunker.relay/' }))
expect(seed.bunkerUrl).toContain(`relay=${encodeURIComponent('wss://bunker.relay/')}`)
// event relays are unchanged
expect(seed.relays).toEqual(['wss://events.relay/'])
})
it('percent-encodes relay + secret for parseBunkerInput to decode', () => {
const seed = parseSpireSeed(makeSeed(VALID)) const seed = parseSpireSeed(makeSeed(VALID))
expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F') expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F')
expect(seed.bunkerUrl).toContain('secret=deadbeef') expect(seed.bunkerUrl).toContain('secret=deadbeef')
}) })
it('re-pads stripped base64url of any residue length', () => {
// Vary the secret so the encoded payload lands on each mod-4 residue.
for (const suffix of ['', 'a', 'ab', 'abc']) {
const seed = makeSeed({ ...VALID, bunker_secret: `deadbeef${suffix}` })
expect(() => parseSpireSeed(seed)).not.toThrow()
}
})
it.each([ it.each([
['wrong scheme', 'spire-seed:v2:abc'], ['wrong scheme', 'spire-seed:v2:abc'],
['not a seed', 'bunker://whatever'], ['not a seed', 'bunker://whatever'],
@ -56,10 +72,18 @@ describe('parseSpireSeed', () => {
it.each([ it.each([
['bad version', { ...VALID, v: 2 }], ['bad version', { ...VALID, v: 2 }],
['short pubkey', { ...VALID, spire_pubkey: 'abc' }], ['missing spire_npub', { ...VALID, spire_npub: undefined }],
['non-bunker url', { ...VALID, bunker_url: 'https://evil/' }], ['non-npub spire_npub', { ...VALID, spire_npub: 'a'.repeat(64) }],
['missing lnbits_npub', { ...VALID, lnbits_npub: undefined }],
['non-npub lnbits_npub', { ...VALID, lnbits_npub: 'notanpub' }],
['empty bunker_secret', { ...VALID, bunker_secret: '' }],
['missing bunker_secret', { ...VALID, bunker_secret: undefined }],
['empty relays', { ...VALID, relays: [] }], ['empty relays', { ...VALID, relays: [] }],
['non-string relay', { ...VALID, relays: [123] }], ['non-string relay', { ...VALID, relays: [123] }],
['non-ws relay (scan corruption ws://→As://)', { ...VALID, relays: ['As://events.relay/'] }],
['non-ws relay (http)', { ...VALID, relays: ['http://events.relay/'] }],
['empty bunker_relay', { ...VALID, bunker_relay: '' }],
['non-ws bunker_relay', { ...VALID, bunker_relay: 'As://bunker.relay/' }],
])('rejects %s', (_label, json) => { ])('rejects %s', (_label, json) => {
expect(() => parseSpireSeed(makeSeed(json))).toThrow() expect(() => parseSpireSeed(makeSeed(json))).toThrow()
}) })

View file

@ -3,44 +3,70 @@
* *
* The operator dashboard (aiolabs/spirekeeper `pairing.py`) hands each ATM a * The operator dashboard (aiolabs/spirekeeper `pairing.py`) hands each ATM a
* one-time seed URL that encodes the bunker connection + the spire's signing * one-time seed URL that encodes the bunker connection + the spire's signing
* identity. Wire contract (model A1): * identity. Wire contract (model A1, minimal encoding):
* *
* spire-seed:v1:<base64url(json, no padding)> * spire-seed:v1:<base64url(json, no padding)>
* json = { * json = {
* "v": 1, * "v": 1,
* "spire_npub": "npub1…", // informational, ignored here * "spire_npub": "npub1…", // spire signing identity (bech32; hex derived)
* "spire_pubkey": "<64-hex>", // the spire's bunker-held signing identity * "lnbits_npub": "npub1…", // LNbits nostr-transport server identity
* "bunker_url": "bunker://<spire_pubkey_hex>?relay=<url>&secret=<sec>", * "bunker_secret": "<sec>", // one-shot NIP-46 connect token
* "relays": ["wss://…"] // relays for the spire's OWN events (21000/30078) * "relays": ["wss://…"], // relays the spire's OWN events use (21000/30078)
* "bunker_relay": "wss://…" // OPTIONAL — NIP-46 relay; defaults to relays[0]
* } * }
* *
* - base64url is `urlsafe_b64encode(...).rstrip("=")` → re-pad to a multiple * Design (see aiolabs/bitspire#70): the pubkey is carried ONCE, as an npub.
* of 4 before decoding. * The old shape spelled it three times (spire_npub + spire_pubkey hex + inside
* - `relay` / `secret` inside `bunker_url` are percent-encoded; decoding them * a full bunker_url), which bloats a QR that's already hard to scan. Here:
* is left to nostr-tools `parseBunkerInput` (see bunker-signer.ts), so we *
* keep `bunker_url` verbatim. * - `spire_pubkey` (hex) is derived from `spire_npub` (npub is ~the same length
* - `bunker_url`'s relay is the BUNKER relay; `relays[]` is where the spire * as hex but carries a bech32 checksum — real error-detection for a value
* publishes its own events. They may differ — both must be spire-reachable. * read off a camera).
* - `bunker_url` is RECONSTRUCTED from `spire_pubkey`, `bunker_relay` (or
* `relays[0]`), and `bunker_secret`, then handed verbatim to nostr-tools
* `parseBunkerInput` (see bunker-signer.ts).
* - `lnbits_npub` gives the ATM its LNbits transport server pubkey so a paired
* machine needs nothing else provisioned to reach the backend (#70 part 2).
*
* base64url is `urlsafe_b64encode(...).rstrip("=")` → re-pad to a multiple of 4
* before decoding.
*/ */
import { sha256 } from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js'
import { bytesToHex } from 'nostr-tools/utils' import { bytesToHex } from 'nostr-tools/utils'
import { decode as nip19Decode } from 'nostr-tools/nip19'
export const SPIRE_SEED_SCHEME = 'spire-seed:v1:' export const SPIRE_SEED_SCHEME = 'spire-seed:v1:'
export interface SpireSeed { export interface SpireSeed {
/** Seed format version (always 1 for this scheme). */ /** Seed format version (always 1 for this scheme). */
v: number v: number
/** The spire's signing identity — 64-char hex. Every event is signed as this. */ /** The spire's signing identity — 64-char hex, derived from `spire_npub`. */
spirePubkey: string spirePubkey: string
/** `bunker://<pubkey>?relay=&secret=` — handed to nostr-tools parseBunkerInput. */ /** `bunker://<pubkey>?relay=&secret=` — reconstructed, handed to parseBunkerInput. */
bunkerUrl: string bunkerUrl: string
/** Relays where the spire publishes its own events (kind 21000 / 30078). */ /** Relays where the spire publishes its own events (kind 21000 / 30078). */
relays: string[] relays: string[]
/** LNbits nostr-transport server pubkey — 64-char hex, derived from `lnbits_npub`. */
lnbitsServerPubkey: string
} }
const HEX64 = /^[0-9a-f]{64}$/ const HEX64 = /^[0-9a-f]{64}$/
/**
* A relay must be a `ws://` or `wss://` URL. Unlike the npubs (bech32-checksummed,
* so a mis-scanned character is caught), the relay strings are raw inside the
* seed's base64 — a QR misread can silently corrupt `ws://` into e.g. `As://`
* and the pairing then crash-loops on an unreachable relay. Reject at parse time
* so the wizard refuses a garbled scan instead of persisting it (bitspire#70).
*/
const WS_URL = /^wss?:\/\/[^\s]+$/
function assertRelayUrl(value: string, field: string): void {
if (!WS_URL.test(value)) {
throw new Error(`parseSpireSeed: ${field} must be a ws:// or wss:// URL (got "${value}")`)
}
}
/** Decode an unpadded base64url string in both browser and Node. */ /** Decode an unpadded base64url string in both browser and Node. */
function base64urlDecode(input: string): string { function base64urlDecode(input: string): string {
const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '=') const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '=')
@ -50,6 +76,23 @@ function base64urlDecode(input: string): string {
return Buffer.from(padded, 'base64').toString('binary') return Buffer.from(padded, 'base64').toString('binary')
} }
/** Decode an `npub1…` to its 64-char hex pubkey, failing closed. */
function hexFromNpub(value: unknown, field: string): string {
if (typeof value !== 'string') {
throw new Error(`parseSpireSeed: ${field} must be a string`)
}
let decoded: ReturnType<typeof nip19Decode>
try {
decoded = nip19Decode(value)
} catch (err) {
throw new Error(`parseSpireSeed: ${field} is not a valid npub (${(err as Error).message})`)
}
if (decoded.type !== 'npub' || typeof decoded.data !== 'string' || !HEX64.test(decoded.data)) {
throw new Error(`parseSpireSeed: ${field} must be an npub`)
}
return decoded.data
}
/** /**
* Parse + validate a `spire-seed:v1:` URL. Throws on any malformation — * Parse + validate a `spire-seed:v1:` URL. Throws on any malformation —
* the seed is a trust root, so we fail closed rather than connect to a * the seed is a trust root, so we fail closed rather than connect to a
@ -77,22 +120,40 @@ export function parseSpireSeed(seedUrl: string): SpireSeed {
throw new Error(`parseSpireSeed: unsupported version ${String(obj.v)}`) throw new Error(`parseSpireSeed: unsupported version ${String(obj.v)}`)
} }
const spirePubkey = obj.spire_pubkey const spirePubkey = hexFromNpub(obj.spire_npub, 'spire_npub')
if (typeof spirePubkey !== 'string' || !HEX64.test(spirePubkey)) { const lnbitsServerPubkey = hexFromNpub(obj.lnbits_npub, 'lnbits_npub')
throw new Error('parseSpireSeed: spire_pubkey must be 64-char hex')
}
const bunkerUrl = obj.bunker_url const bunkerSecret = obj.bunker_secret
if (typeof bunkerUrl !== 'string' || !bunkerUrl.startsWith('bunker://')) { if (typeof bunkerSecret !== 'string' || bunkerSecret.length === 0) {
throw new Error('parseSpireSeed: bunker_url must be a bunker:// URL') throw new Error('parseSpireSeed: bunker_secret must be a non-empty string')
} }
const relays = obj.relays const relays = obj.relays
if (!Array.isArray(relays) || relays.length === 0 || !relays.every((r) => typeof r === 'string')) { if (!Array.isArray(relays) || relays.length === 0 || !relays.every((r) => typeof r === 'string')) {
throw new Error('parseSpireSeed: relays must be a non-empty string array') throw new Error('parseSpireSeed: relays must be a non-empty string array')
} }
relays.forEach((r, i) => assertRelayUrl(r as string, `relays[${i}]`))
return { v: 1, spirePubkey, bunkerUrl, relays: relays as string[] } // Optional bunker relay; default to the first event relay. Keeps the common
// case (bunker on the same relay) one field lighter, while still allowing a
// distinct NIP-46 relay when the operator runs one.
let bunkerRelay = relays[0] as string
if (obj.bunker_relay !== undefined) {
if (typeof obj.bunker_relay !== 'string' || obj.bunker_relay.length === 0) {
throw new Error('parseSpireSeed: bunker_relay, if present, must be a non-empty string')
}
assertRelayUrl(obj.bunker_relay, 'bunker_relay')
bunkerRelay = obj.bunker_relay
}
// Reconstruct the bunker URL nostr-tools expects. relay + secret are
// percent-encoded here; parseBunkerInput decodes them downstream.
const bunkerUrl =
`bunker://${spirePubkey}` +
`?relay=${encodeURIComponent(bunkerRelay)}` +
`&secret=${encodeURIComponent(bunkerSecret)}`
return { v: 1, spirePubkey, bunkerUrl, relays: relays as string[], lnbitsServerPubkey }
} }
/** /**