From a0c2f38ef0e9820c5d5e7b1027088420a62725f8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 12 Jun 2026 17:36:19 +0200 Subject: [PATCH 01/82] fix(machine): add 6 new themes to electron VALID_THEMES allowlist db074e2 added countrysidecastle/darkmatter/emeraldforest/lightgreen/ neobrut/starrynight to the renderer's ThemeId union and style.css but not to the electron main-process branding allowlist. branding.json 'theme' values outside the allowlist were silently dropped (theme=null), so the renderer fell through to the localStorage theme (cyberpunk). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/machine/electron/main.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 59cbcd5..2ed9b12 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -88,6 +88,12 @@ const VALID_THEMES = new Set([ 'dracula', 'nord', 'tokyo-night', + 'countrysidecastle', + 'darkmatter', + 'emeraldforest', + 'lightgreen', + 'neobrut', + 'starrynight', 'custom', ]) From 52eb37ceaf126bde963555f87ae2bb20c6921da1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 12 Jun 2026 18:09:02 +0200 Subject: [PATCH 02/82] refactor(machine): drop electron theme allowlist, defer to renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VALID_THEMES set in electron/main.ts duplicated the renderer's ThemeId list and silently coerced any unlisted branding.json theme to null — which is how darkmatter regressed to the localStorage theme after db074e2 added themes to the renderer but not this allowlist (fixed in a0c2f38). Remove the second list entirely: pass raw.theme through and let useTheme's applyBrandingTheme (themes[] + the 'custom' branch) be the single validation point. Unknown values are ignored downstream, so nothing reaches the DOM unvetted. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/machine/electron/main.ts | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 2ed9b12..fb5c84b 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -81,22 +81,6 @@ type BrandingConfig = { logoDarkDataUrl: string | null } -const VALID_THEMES = new Set([ - 'gruvbox', - 'catppuccin', - 'cyberpunk', - 'dracula', - 'nord', - 'tokyo-night', - 'countrysidecastle', - 'darkmatter', - 'emeraldforest', - 'lightgreen', - 'neobrut', - 'starrynight', - 'custom', -]) - function loadBranding(): BrandingConfig | null { const brandingDir = path.join( fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(), @@ -116,7 +100,10 @@ function loadBranding(): BrandingConfig | null { try { const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')) if (typeof raw.title === 'string') title = raw.title - if (typeof raw.theme === 'string' && VALID_THEMES.has(raw.theme)) theme = raw.theme + // No theme-name validation here: the renderer's `themes` list (plus its + // 'custom' branch) is the single source of truth. Pass the string through + // and let useTheme's applyBrandingTheme ignore anything it doesn't know. + if (typeof raw.theme === 'string') theme = raw.theme if (raw.custom_colors && typeof raw.custom_colors === 'object') { const { dark, ...flat } = raw.custom_colors as Record const colors = Object.fromEntries( From 627d5e63e5ac1b8400d6618e291becdf0251cd1f Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 14 Jun 2026 11:17:02 +0200 Subject: [PATCH 03/82] =?UTF-8?q?docs(adr):=20ADR-002=20remote=20access=20?= =?UTF-8?q?&=20fleet=20management=20=E2=80=94=20three=20planes,=20NetBird?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate payment (Nostr↔LNbits, SaaS-operator-owned), fleet control (Nostr #42, machine-operator-owned), and access/recovery (SSH) planes by trust owner. Recovery access is provisioned at install and app-independent. Adopt NetBird for the access plane (scale + fully FOSS self-hostable control plane; rejects Tailscale's closed control plane). Reject a dashboard 'revoke SaaS-operator access' toggle as a false promise — the SaaS operator controls LNbits and the default control plane, so exclusion is by ownership (operator self-hosts), not by toggle. --- .../002-remote-access-and-fleet-management.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/adr/002-remote-access-and-fleet-management.md diff --git a/docs/adr/002-remote-access-and-fleet-management.md b/docs/adr/002-remote-access-and-fleet-management.md new file mode 100644 index 0000000..4931daa --- /dev/null +++ b/docs/adr/002-remote-access-and-fleet-management.md @@ -0,0 +1,114 @@ +# ADR-002: Remote Access & Fleet Management — Three Planes, Operator-Owned Access via NetBird + +**Status:** Accepted +**Date:** 2026-06-14 +**Context:** Multi-operator bitSpire fleet — separating the payment, control, and recovery planes by who owns them. + +## Decision + +1. **Separate three planes by trust owner**, and never conflate them: + - **Payment plane** — ATM ↔ LNbits over the nostr-native-transport. Owned by the **SaaS operator**. Implies *no* machine access. + - **Fleet control plane** — routine ops/telemetry/enrollment over Nostr (see [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42)). Authorized by the **machine operator's** key. + - **Access / recovery plane** — SSH for the unanticipated and the broken. Owned by the **machine operator**. + +2. **The machine operator's own recovery access is provisioned at install and is app-independent.** Their SSH key and their VPN/NetBird enrollment are established when the machine is set up, so they can always reach a box even when the bitSpire app or OS is broken. Access for *anyone else* is runtime-granted, scoped, and revocable — never the owner's own path. + +3. **Adopt NetBird as the standard access/recovery plane**, chosen for fleet scale and a **self-hostable, fully FOSS control plane**. The platform may provide a default NetBird setup as a convenience; a machine operator who does not wish to trust whoever runs that control plane **disables it and provisions their own access plane** (self-hosted NetBird, or their own WireGuard hub). + +4. **We will NOT build a "revoke SaaS-operator access" toggle in the operator dashboard.** It is a false promise of security: the SaaS operator runs LNbits (and, in the default deployment, the NetBird control plane), so a toggle they ultimately control cannot protect a machine operator against them. The honest boundary is **exclusion-by-ownership, not exclusion-by-toggle** — an operator who wants to exclude the SaaS operator takes ownership of the access plane. + +## Context + +### The players + +A deployed bitSpire machine sits between two distinct principals: + +- **SaaS operator** — runs the LNbits instance and provides the Lightning backend as a service. +- **Machine operator** — owns the physical ATM(s) and is identified by a Nostr key (the operator pubkey in the [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) allow-list). + +These are different parties with different interests. A machine operator will want to SSH to their own machine for support and recovery, and **may or may not want to grant the SaaS operator that same access.** + +### Why the SaaS operator needs zero box access by design + +The whole nostr-native architecture (no admin tokens on the kiosk, no inbound network surface, payment over Nostr) means the SaaS operator can deliver the full service **without ever touching the machine**. So "the machine operator may refuse the SaaS operator access" is not a constraint to engineer around — it is the **default that costs nothing**. SaaS-operator box access is a *support convenience*, never a service requirement. The natural posture is therefore **default-deny for the SaaS operator**. + +### Why SSH can't be replaced by the Nostr control plane + +The Nostr control plane (#42) is a fixed menu of structured, capability-scoped commands dispatched by a handler *inside the app*. It is excellent for routine, auditable, fleet-wide ops on **healthy** machines, and strictly better than SSH for those (signed, scoped, logged, fan-out). But: + +- It can only do what a handler was written for; incidents are by definition unanticipated. +- The listener lives in the app, so it dies exactly when the app dies — the case you most need recovery for. + +SSH (arbitrary, interactive, app-independent) is therefore irreducible as the **recovery plane**. The two are complements, not substitutes. + +### Why the recovery path must be app-independent + +The whole point of a recovery path is to survive the failure of the thing it recovers. So it must not be gated by the bitSpire app, nor by a Nostr command the app dispatches. The kernel/agent that carries the tunnel and `sshd` must come up at boot independent of the app. (`allowedTCPPorts = []` already means `sshd` is unreachable except across the tunnel — the VPN handshake is the outer lock, the SSH key the inner one.) + +### Why the single shared hub had to change + +The pre-existing design used one WireGuard hub (`170.75.161.21`) run by platform infra. Whoever runs that hub has a standing network path to every enrolled box — i.e. the SaaS operator having access to machines they don't own. Multi-tenancy requires the access plane to be **per-operator or policy-isolated**, rooted in the machine operator, not the platform. + +## Options Considered + +### Access-plane mechanism + +#### Option A: Always-up minimal WireGuard hub + +**Pros:** After boot, zero userspace dependency — the kernel holds the tunnel, nothing can crash it short of a kernel/networking fault; smallest, most battle-tested trusted-code surface; simplest possible recovery floor. +**Cons:** Manual peer management; no policy/ACL/enrollment ergonomics; a single shared hub re-creates the multi-tenant trust problem (must be run per-operator to avoid it); does not scale operationally to many operators × many machines. + +#### Option B: NetBird (Selected) + +**Pros:** Policy/ACL-based, revocable, per-peer access control; enrollment + audit out of the box; **self-hostable, fully FOSS control plane** — we retain the ability to run and modify every layer; scales to the many-operators × many-machines world #42 anticipates. +**Cons:** The NetBird agent is a userspace daemon, so the recovery path depends on that daemon being up (less bulletproof than kernel-level always-up WG) — mitigated by it being independent of the bitSpire app, mature, and systemd-restarted; running a control plane is operational weight (acceptable: the platform provides a default; sovereignty-seeking operators self-host). + +#### Option C: Tailscale + +**Pros:** Best-in-class ergonomics and NAT traversal. +**Cons:** **Control plane is closed source with no FOSS alternative** (headscale only reimplements the coordination server, chasing an upstream we don't control). Fails the hard requirement that we can always self-host and modify any software we depend on. Rejected on that basis alone. + +#### Option D: On-demand tunnel toggled by the Nostr control plane + +**Pros:** No standing reachability; every access window is a signed, audited, time-boxed event. +**Cons:** If the toggle is handled by the app, it fails in the exact recovery scenario (listener died with the app). If handled by a separate daemon, it reintroduces a privileged userspace listener into the recovery path and grows, rather than shrinks, the trusted-code surface. Acceptable only as an **audited convenience layer on top of** an always-available floor (and designed to fail open), never as the load-bearing gate. Not adopted as the primary mechanism. + +### Trust model for excluding the SaaS operator + +#### Option 1: Dashboard toggle to revoke SaaS-operator access (Rejected) + +The SaaS operator controls LNbits (the machine's wallet/account is an LNbits user they can administer) and, in the default deployment, the NetBird control plane. A toggle whose enforcement they ultimately control gives the machine operator no real protection against them — it is security theater. **Rejected as a false promise.** + +#### Option 2: Exclusion by ownership (Selected) + +The only honest way for a machine operator to exclude the SaaS operator is to **own the access plane**: disable the default (platform-provided) NetBird enrollment and stand up their own — self-hosted NetBird, or their own WireGuard hub. The default deployment trusts whoever runs the control plane *and says so plainly*; operators who won't extend that trust take ownership. Control = ownership; we do not pretend otherwise. + +## Consequences + +### Positive + +- Honest trust boundaries: the SaaS operator has no standing box access by default, and the limits of platform-provided convenience are stated rather than faked. +- Scales to many operators × many machines via NetBird policy/enrollment, while preserving a self-hosting escape hatch for sovereignty. +- The recovery plane survives app and OS failure because it is provisioned at install and independent of the runtime. +- Every dependency remains FOSS and self-hostable — no closed control plane anywhere in the stack. + +### Negative + +- The NetBird agent is a standing userspace daemon; a box where *both* the app and the agent are down falls to the physical/LAN floor (same floor as any remote scheme — only pure kernel-WG narrows it, at the cost of NetBird's ergonomics). Operators who weight reliability over ergonomics can choose self-hosted plain WireGuard. +- Sovereignty for a distrusting operator costs them operational work (running their own access plane). This is inherent to "control = ownership," not incidental. +- Two enrollment surfaces at provisioning: app/payment identity (#42 seed URL) and system/access identity (this plane). They must be kept conceptually distinct. + +### Future Considerations + +- An **audited convenience layer** (Nostr `OpenAccess`/`CloseAccess` that opens a time-boxed SSH window and logs it as a signed event) may be added *on top of* the always-available floor, designed to fail open, for the routine "let me in" case. It is explicitly not the recovery gate. +- The machine operator's Nostr key can become the single root of trust across all three planes — SSH `authorized_keys` + VPN enrollment at install, `AddOperator`/`RevokeOperator` (#42) for delegation — so granting/revoking any party (including the SaaS operator) is one scoped, revocable capability model. +- `sshd` posture should be tightened to key-only for deployed boxes (password auth is currently forced on for installed configs for first-boot provisioning; scope it to the LAN/first-boot window). Tracks with [#51](https://git.atitlan.io/aiolabs/bitspire/issues/51). + +## References + +- [#41](https://git.atitlan.io/aiolabs/bitspire/issues/41) — Multi-location deployment: runtime site config (the access plane's per-machine identity is provisioned here, not baked into the closure). +- [#42](https://git.atitlan.io/aiolabs/bitspire/issues/42) — Fleet management: Nostr-native remote control & telemetry (the control plane this ADR sits beside). +- [#51](https://git.atitlan.io/aiolabs/bitspire/issues/51) — NixOS systemd hardening (sshd posture tightening). +- [#52](https://git.atitlan.io/aiolabs/bitspire/issues/52) — Sidecar bunker for the ATM key (related key-handling direction). +- `deploy/nixos/configuration.nix` — current WireGuard hub + `sshd` config (to be reworked per this decision). +- NetBird — (self-hostable, FOSS control plane). From d6b22e1156b86f723b785a8d86de943895aa962e Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 19:56:35 +0200 Subject: [PATCH 04/82] refactor(nostr): route signing + encryption through a Signer abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a Signer interface (signEvent / nip44Encrypt / nip44Decrypt + sync pubkey) with an in-process LocalSigner backed by an nsec, and route every signing/encryption call site through it. Behaviour is unchanged — LocalSigner wraps the same MachineIdentity the code used directly before. This is Phase A of the bunker migration (aiolabs/bitspire#52): it puts the seam in place so Phase B can drop in a NIP-46 BunkerSigner at the bootstrap without touching any call site. The whole chain becomes async (the bunker path is a relay round-trip; LocalSigner resolves immediately). Sites moved onto the signer: - packages/nostr-client: createSignedEvent / createAuthEvent (now async), NostrClient config (signer not identity), AUTH challenge handler. - packages/lnbits: LnbitsClient.initialize(nostr, signer); kind-21000 RPC encrypt + sign + reply-decrypt; handleReply is now async (event-id dedup still runs synchronously before the awaited decrypt, so replay safety and per-subscription hash dedup are preserved). - apps/machine: lightning.ts builds a LocalSigner and exposes it on LightningServices; operator-config / operator-fees / availability beacon / maintenance beacon / fund-atm all sign + encrypt via the signer. NIP-42 auth (kind 22242) is included — under the bunker it must be in the spire policy (aiolabs/spirekeeper#26, already merged). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/machine/electron/fund-atm.ts | 8 +- apps/machine/src/App.vue | 14 ++-- .../composables/useAvailabilityBroadcast.ts | 8 +- apps/machine/src/services/lightning.ts | 15 +++- apps/machine/src/services/operator-config.ts | 20 ++--- apps/machine/src/services/operator-fees.ts | 15 ++-- apps/machine/src/stores/atm.ts | 22 ++--- packages/lnbits/src/__tests__/client.test.ts | 63 +++++++------- packages/lnbits/src/client.ts | 58 +++++++------ .../nostr-client/src/__tests__/events.test.ts | 51 +++-------- .../nostr-client/src/__tests__/signer.test.ts | 58 +++++++++++++ packages/nostr-client/src/client.ts | 24 +++--- packages/nostr-client/src/events.ts | 84 ++++--------------- packages/nostr-client/src/index.ts | 50 +++++------ packages/nostr-client/src/signer.ts | 64 ++++++++++++++ packages/nostr-client/src/types.ts | 8 +- 16 files changed, 301 insertions(+), 261 deletions(-) create mode 100644 packages/nostr-client/src/__tests__/signer.test.ts create mode 100644 packages/nostr-client/src/signer.ts diff --git a/apps/machine/electron/fund-atm.ts b/apps/machine/electron/fund-atm.ts index ce8b1e0..bce6b4c 100644 --- a/apps/machine/electron/fund-atm.ts +++ b/apps/machine/electron/fund-atm.ts @@ -13,7 +13,7 @@ */ import { readFileSync } from 'node:fs' -import { NostrClient, loadIdentityFromHex } from '@bitSpire/nostr-client' +import { NostrClient, LocalSigner, loadIdentityFromHex } from '@bitSpire/nostr-client' import { LnbitsClient } from '@bitSpire/lnbits' // @ts-ignore — qrcode is a transitive dep (via qrcode.vue), no types needed @@ -64,11 +64,11 @@ async function main() { console.error(`Generating invoice for ${amountSats} sats...`) - const identity = loadIdentityFromHex(atmPrivateKey) + const signer = new LocalSigner(loadIdentityFromHex(atmPrivateKey)) const nostrClient = new NostrClient({ relays: [{ url: relayUrl }], - identity, + signer, }) await nostrClient.connect() @@ -76,7 +76,7 @@ async function main() { serverPubkey: lnbitsServerPubkey, relays: [relayUrl], }) - lnbits.initialize(nostrClient, identity) + lnbits.initialize(nostrClient, signer) const wallets = await lnbits.listWallets() const wallet = wallets[0] diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index c5730e3..37882ba 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -51,18 +51,18 @@ onMounted(async () => { atmStore.initError = 'maintenance' // Publish maintenance beacon — minimal Nostr connection only (no Lightning.Pub) try { - const { NostrClient, loadIdentityFromHex, createSignedEvent } = await import( + const { NostrClient, LocalSigner, loadIdentityFromHex, createSignedEvent } = await import( '@bitSpire/nostr-client' ) const secrets = isElectron ? await window.electronAPI?.getAtmSecrets() : null const privKey = secrets?.atmPrivateKey || import.meta.env.VITE_ATM_PRIVATE_KEY const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL if (privKey && relayUrl) { - const identity = loadIdentityFromHex(privKey) - const client = new NostrClient({ relays: [{ url: relayUrl }], identity }) + const signer = new LocalSigner(loadIdentityFromHex(privKey)) + const client = new NostrClient({ relays: [{ url: relayUrl }], signer }) await client.connect() - const publishBeacon = () => { - const event = createSignedEvent(identity, { + const publishBeacon = async () => { + const event = await createSignedEvent(signer, { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', 'atm-availability']], @@ -77,8 +77,8 @@ onMounted(async () => { }) client.publish(event).catch(() => {}) } - publishBeacon() - setInterval(publishBeacon, 5 * 60 * 1000) + void publishBeacon() + setInterval(() => void publishBeacon(), 5 * 60 * 1000) } } catch (e) { console.warn('[App] Failed to start maintenance beacon:', e) diff --git a/apps/machine/src/composables/useAvailabilityBroadcast.ts b/apps/machine/src/composables/useAvailabilityBroadcast.ts index b36f6f2..a4968f7 100644 --- a/apps/machine/src/composables/useAvailabilityBroadcast.ts +++ b/apps/machine/src/composables/useAvailabilityBroadcast.ts @@ -13,7 +13,7 @@ import { watch, type Ref } from 'vue' import { useDebounceFn } from '@vueuse/core' -import type { NostrClient, MachineIdentity } from '@bitSpire/nostr-client' +import type { NostrClient, Signer } from '@bitSpire/nostr-client' import { createSignedEvent } from '@bitSpire/nostr-client' type CashLevel = 'none' | 'low' | 'good' | 'full' @@ -26,7 +26,7 @@ interface AvailabilitySnapshot { interface UseAvailabilityBroadcastOptions { nostrClient: NostrClient - identity: MachineIdentity + signer: Signer /** Reactive inventory: denomination -> count */ inventory: Ref> /** Reactive Lightning.Pub balance in sats (null = unknown) */ @@ -38,7 +38,7 @@ interface UseAvailabilityBroadcastOptions { } export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) { - const { nostrClient, identity, inventory, balanceSats, fiatCode, model } = options + const { nostrClient, signer, inventory, balanceSats, fiatCode, model } = options let lastSnapshot: AvailabilitySnapshot | null = null @@ -73,7 +73,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption model, }) - const event = createSignedEvent(identity, { + const event = await createSignedEvent(signer, { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', 'atm-availability']], diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 1466e20..8154d4b 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -14,8 +14,10 @@ import { NostrClient, + LocalSigner, generateIdentity, loadIdentityFromHex, + type Signer, type MachineIdentity, } from '@bitSpire/nostr-client' import { LnbitsClient } from '@bitSpire/lnbits' @@ -234,7 +236,7 @@ interface LightningServices { nostrClient: NostrClient lightningPub: LightningBackend clink: CLINKClient - identity: MachineIdentity + signer: Signer /** Operator pubkeys (hex) authorized for kind-21003 management + operator-config events. */ operatorPubkeys: string[] atmServices: ATMServices @@ -451,10 +453,15 @@ export async function initializeLightningServices(options?: { } console.log('[Lightning] ATM pubkey:', identity.publicKey) + // Wrap the identity in a signer. Phase A always uses LocalSigner (in-process + // nsec); Phase B swaps in a BunkerSigner here without touching the call + // sites below. See aiolabs/bitspire#52. + const signer: Signer = new LocalSigner(identity) + // Create Nostr client const nostrClient = new NostrClient({ relays: [{ url: CONFIG.relayUrl }], - identity, + signer, }) await nostrClient.connect() @@ -465,7 +472,7 @@ export async function initializeLightningServices(options?: { serverPubkey: CONFIG.lnbitsServerPubkey, relays: [CONFIG.relayUrl], }) - lnbits.initialize(nostrClient, identity) + lnbits.initialize(nostrClient, signer) _lnbitsRef = lnbits console.log('[Lightning] LNbits client initialized') @@ -588,7 +595,7 @@ export async function initializeLightningServices(options?: { nostrClient, lightningPub, clink, - identity, + signer, operatorPubkeys: CONFIG.operatorPubkeys, atmServices, onOfferRequest: (callback: OfferRequestCallback) => { diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts index c9b26f8..ba9ddcb 100644 --- a/apps/machine/src/services/operator-config.ts +++ b/apps/machine/src/services/operator-config.ts @@ -23,12 +23,10 @@ */ import { - type MachineIdentity, + type Signer, type NostrClient, type Event, createSignedEvent, - decryptContentV2, - encryptContentV2, validateEvent, } from '@bitSpire/nostr-client' @@ -47,11 +45,11 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef export interface OperatorConfigServiceConfig { /** Connected NostrClient — shared with the Lightning service. */ nostrClient: NostrClient - /** ATM's nostr identity. Used to decrypt operator events + sign the bootstrap. */ - identity: MachineIdentity + /** Signer for the ATM identity. Decrypts operator events + signs the bootstrap. */ + signer: Signer /** Operator pubkeys (hex) authorized to publish cassette config. From VITE_OPERATOR_PUBKEYS. */ operatorPubkeys: string[] - /** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */ + /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */ machineId?: string } @@ -72,7 +70,7 @@ export async function startOperatorConfigService( return { stop: () => {} } } const api = window.electronAPI - const machineId = cfg.machineId ?? cfg.identity.publicKey + const machineId = cfg.machineId ?? cfg.signer.pubkey // Bootstrap hello-event on first boot (best-effort — failure leaves the // gate null so the next boot retries). @@ -88,7 +86,7 @@ export async function startOperatorConfigService( [ { kinds: [KIND_NIP78], - '#p': [cfg.identity.publicKey], + '#p': [cfg.signer.pubkey], '#d': [dTag], authors: cfg.operatorPubkeys, }, @@ -150,7 +148,7 @@ async function handleOperatorConfigEvent( // 4. Decrypt content (NIP-44 v2). let parsed: { positions: Record } try { - const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content) + const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content) parsed = JSON.parse(plaintext) as typeof parsed } catch (err) { console.error('[OperatorConfig] Decrypt/parse failed:', err) @@ -223,10 +221,10 @@ async function maybePublishBootstrap( for (const c of cassettes) { positions[String(c.position)] = { denomination: c.denomination, count: c.count } } - const ciphertext = encryptContentV2(cfg.identity, operatorPubkey, { positions }) + const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify({ positions })) const dTag = atmStateDTag(machineId) - const event = createSignedEvent(cfg.identity, { + const event = await createSignedEvent(cfg.signer, { kind: KIND_NIP78, content: ciphertext, tags: [ diff --git a/apps/machine/src/services/operator-fees.ts b/apps/machine/src/services/operator-fees.ts index 8581ce8..4e699a3 100644 --- a/apps/machine/src/services/operator-fees.ts +++ b/apps/machine/src/services/operator-fees.ts @@ -56,10 +56,9 @@ */ import { - type MachineIdentity, + type Signer, type NostrClient, type Event, - decryptContentV2, validateEvent, } from '@bitSpire/nostr-client' @@ -80,11 +79,11 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef export interface OperatorFeesServiceConfig { /** Connected NostrClient — shared with the Lightning service. */ nostrClient: NostrClient - /** ATM's nostr identity. Used to decrypt operator events. */ - identity: MachineIdentity + /** Signer for the ATM identity. Decrypts operator events. */ + signer: Signer /** Operator pubkeys (hex) authorized to publish fee config. From VITE_OPERATOR_PUBKEYS. */ operatorPubkeys: string[] - /** Machine identifier for the d-tag. Defaults to identity.publicKey when omitted. */ + /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */ machineId?: string /** * Called when a valid fee-config event is applied. Renderer should @@ -112,7 +111,7 @@ export async function startOperatorFeesService( return { stop: () => {} } } const api = window.electronAPI - const machineId = cfg.machineId ?? cfg.identity.publicKey + const machineId = cfg.machineId ?? cfg.signer.pubkey // Subscribe to operator-published fee config events. const dTag = feeConfigDTag(machineId) @@ -120,7 +119,7 @@ export async function startOperatorFeesService( [ { kinds: [KIND_NIP78], - '#p': [cfg.identity.publicKey], + '#p': [cfg.signer.pubkey], '#d': [dTag], authors: cfg.operatorPubkeys, }, @@ -189,7 +188,7 @@ async function handleFeeConfigEvent( // fields (v2 forward-compat — future promo payloads). let parsed: ParsedFeePayload try { - const plaintext = decryptContentV2(cfg.identity, event.pubkey, event.content) + const plaintext = await cfg.signer.nip44Decrypt(event.pubkey, event.content) const raw = JSON.parse(plaintext) as Record parsed = parseV1Payload(raw) } catch (err) { diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index d0e2005..efc80cf 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -676,14 +676,14 @@ export const useAtmStore = defineStore('atm', () => { initialize(servicesWithInventory) // Start broadcasting availability (Kind 30078) with 5-minute heartbeat - startAvailabilityBroadcast(services.nostrClient, services.identity, machineModel.value) + startAvailabilityBroadcast(services.nostrClient, services.signer, machineModel.value) // Start operator-config consumer (aiolabs/lamassu-next#56) — subscribes // to kind-30078 cassette config events + publishes one-shot bootstrap operatorConfigSvc?.stop() operatorConfigSvc = await startOperatorConfigService({ nostrClient: services.nostrClient, - identity: services.identity, + signer: services.signer, operatorPubkeys: services.operatorPubkeys, }) @@ -692,7 +692,7 @@ export const useAtmStore = defineStore('atm', () => { operatorFeesSvc?.stop() operatorFeesSvc = await startOperatorFeesService({ nostrClient: services.nostrClient, - identity: services.identity, + signer: services.signer, operatorPubkeys: services.operatorPubkeys, onApply: applyFeeConfig, }) @@ -989,13 +989,13 @@ export const useAtmStore = defineStore('atm', () => { }) // Start broadcasting availability (Kind 30078) - startAvailabilityBroadcast(lightning.nostrClient, lightning.identity, machineModel.value) + startAvailabilityBroadcast(lightning.nostrClient, lightning.signer, machineModel.value) // Operator-config consumer (aiolabs/lamassu-next#56) operatorConfigSvc?.stop() operatorConfigSvc = await startOperatorConfigService({ nostrClient: lightning.nostrClient, - identity: lightning.identity, + signer: lightning.signer, operatorPubkeys: lightning.operatorPubkeys, }) @@ -1003,7 +1003,7 @@ export const useAtmStore = defineStore('atm', () => { operatorFeesSvc?.stop() operatorFeesSvc = await startOperatorFeesService({ nostrClient: lightning.nostrClient, - identity: lightning.identity, + signer: lightning.signer, operatorPubkeys: lightning.operatorPubkeys, onApply: applyFeeConfig, }) @@ -1295,13 +1295,13 @@ export const useAtmStore = defineStore('atm', () => { // Real hardware connected — disable mock bill simulator debugMode.value = false // Start broadcasting availability (Kind 30078) - startAvailabilityBroadcast(lightning.nostrClient, lightning.identity, machineModel.value) + startAvailabilityBroadcast(lightning.nostrClient, lightning.signer, machineModel.value) // Operator-config consumer (aiolabs/lamassu-next#56) operatorConfigSvc?.stop() operatorConfigSvc = await startOperatorConfigService({ nostrClient: lightning.nostrClient, - identity: lightning.identity, + signer: lightning.signer, operatorPubkeys: lightning.operatorPubkeys, }) @@ -1309,7 +1309,7 @@ export const useAtmStore = defineStore('atm', () => { operatorFeesSvc?.stop() operatorFeesSvc = await startOperatorFeesService({ nostrClient: lightning.nostrClient, - identity: lightning.identity, + signer: lightning.signer, operatorPubkeys: lightning.operatorPubkeys, onApply: applyFeeConfig, }) @@ -1437,7 +1437,7 @@ export const useAtmStore = defineStore('atm', () => { /** Start broadcasting ATM availability (Kind 30078) with 5-minute heartbeat */ let stopAvailabilityBroadcast: (() => void) | null = null - async function startAvailabilityBroadcast(nostrClient: any, identity: any, model: string) { + async function startAvailabilityBroadcast(nostrClient: any, signer: any, model: string) { if (stopAvailabilityBroadcast) return // Ensure persisted inventory is loaded before first broadcast @@ -1445,7 +1445,7 @@ export const useAtmStore = defineStore('atm', () => { const { stop } = useAvailabilityBroadcast({ nostrClient, - identity, + signer, inventory: persistedInventory, balanceSats, fiatCode: fiatCode.value, diff --git a/packages/lnbits/src/__tests__/client.test.ts b/packages/lnbits/src/__tests__/client.test.ts index 187e948..903bcb7 100644 --- a/packages/lnbits/src/__tests__/client.test.ts +++ b/packages/lnbits/src/__tests__/client.test.ts @@ -17,6 +17,7 @@ import { } from 'nostr-tools' import { encryptContentV2, + LocalSigner, type MachineIdentity, type NostrClient, } from '@bitSpire/nostr-client' @@ -152,7 +153,7 @@ describe('isAuthenticServerEvent', () => { describe('LnbitsClient.handleReply wiring', () => { function makeMockNostr(): { nostr: NostrClient - triggerEvent: (ev: NostrEvent) => void + triggerEvent: (ev: NostrEvent) => Promise } { let captured: ((ev: NostrEvent) => void) | null = null const nostr = { @@ -168,9 +169,12 @@ describe('LnbitsClient.handleReply wiring', () => { } as unknown as NostrClient return { nostr, - triggerEvent: (ev) => { + // `handleReply` is async (the signer's nip44Decrypt is a promise), + // so flush microtasks + a macrotask tick before the caller asserts. + triggerEvent: async (ev) => { if (!captured) throw new Error('handleReply not wired yet') captured(ev) + await new Promise((resolve) => setTimeout(resolve, 0)) }, } } @@ -188,7 +192,7 @@ describe('LnbitsClient.handleReply wiring', () => { client: LnbitsClient serverIdentity: MachineIdentity recipientIdentity: MachineIdentity - triggerEvent: (ev: NostrEvent) => void + triggerEvent: (ev: NostrEvent) => Promise } { const serverIdentity = makeIdentity() const recipientIdentity = makeIdentity() @@ -197,11 +201,11 @@ describe('LnbitsClient.handleReply wiring', () => { serverPubkey: serverIdentity.publicKey, relays: ['ws://test/'], }) - client.initialize(nostr, recipientIdentity) + client.initialize(nostr, new LocalSigner(recipientIdentity)) return { client, serverIdentity, recipientIdentity, triggerEvent } } - it('drops a forged event without resolving any pending RPC', () => { + it('drops a forged event without resolving any pending RPC', async () => { const { client, serverIdentity, triggerEvent } = setupClient() // Pre-register a pending entry as `sendRpc` would have. @@ -233,7 +237,7 @@ describe('LnbitsClient.handleReply wiring', () => { attackerKey, ) - triggerEvent(forged) + await triggerEvent(forged) expect(resolveCalls).toBe(0) expect(rejectCalls).toBe(0) @@ -241,7 +245,7 @@ describe('LnbitsClient.handleReply wiring', () => { expect((client as any).pending.has('req-forged')).toBe(true) }) - it('processes a legitimate server-signed reply (positive sanity)', () => { + it('processes a legitimate server-signed reply (positive sanity)', async () => { const { client, serverIdentity, recipientIdentity, triggerEvent } = setupClient() @@ -277,7 +281,7 @@ describe('LnbitsClient.handleReply wiring', () => { serverIdentity.privateKey, ) - triggerEvent(reply) + await triggerEvent(reply) expect(resolved).toMatchObject({ status: 'OK', @@ -290,7 +294,7 @@ describe('LnbitsClient.handleReply wiring', () => { // fine, we only need to assert resolve fired with the right payload. }) - it('does not poison the seenEventIds cache with a forged event', () => { + it('does not poison the seenEventIds cache with a forged event', async () => { // This is the test scenario where the #49 guard's contribution // actually shows up: ev.id is the dedup key for the client-global // exact-replay cache. WITHOUT the guard, an attacker could publish @@ -320,7 +324,7 @@ describe('LnbitsClient.handleReply wiring', () => { attackerKey, ) - triggerEvent(forged) + await triggerEvent(forged) // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((client as any).seenEventIds.size).toBe(0) @@ -345,7 +349,7 @@ describe('LnbitsClient.handleReply wiring', () => { describe('LnbitsClient subscribe-payments dedup (#50)', () => { function makeMockNostr(): { nostr: NostrClient - triggerEvent: (ev: NostrEvent) => void + triggerEvent: (ev: NostrEvent) => Promise } { let captured: ((ev: NostrEvent) => void) | null = null const nostr = { @@ -361,9 +365,12 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { } as unknown as NostrClient return { nostr, - triggerEvent: (ev) => { + // `handleReply` is async (the signer's nip44Decrypt is a promise), + // so flush microtasks + a macrotask tick before the caller asserts. + triggerEvent: async (ev) => { if (!captured) throw new Error('handleReply not wired yet') captured(ev) + await new Promise((resolve) => setTimeout(resolve, 0)) }, } } @@ -436,7 +443,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { ) } - it('fires onPush once when the same event is injected twice (exact-replay dedup)', () => { + it('fires onPush once when the same event is injected twice (exact-replay dedup)', async () => { const serverIdentity = makeIdentity() const recipientIdentity = makeIdentity() const { nostr, triggerEvent } = makeMockNostr() @@ -444,7 +451,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { serverPubkey: serverIdentity.publicKey, relays: ['ws://test/'], }) - client.initialize(nostr, recipientIdentity) + client.initialize(nostr, new LocalSigner(recipientIdentity)) const { received } = preregisterSub(client, 'sub-1') @@ -455,14 +462,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { paymentHash: 'hash-aaa', }) // Same bytes both times — same ev.id, same payment_hash. - triggerEvent(ev) - triggerEvent(ev) + await triggerEvent(ev) + await triggerEvent(ev) expect(received).toHaveLength(1) expect(received[0]!.payment_hash).toBe('hash-aaa') }) - it('fires onPush once when two distinct ev.ids carry the same payment_hash', () => { + it('fires onPush once when two distinct ev.ids carry the same payment_hash', async () => { const serverIdentity = makeIdentity() const recipientIdentity = makeIdentity() const { nostr, triggerEvent } = makeMockNostr() @@ -470,7 +477,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { serverPubkey: serverIdentity.publicKey, relays: ['ws://test/'], }) - client.initialize(nostr, recipientIdentity) + client.initialize(nostr, new LocalSigner(recipientIdentity)) const { received } = preregisterSub(client, 'sub-1') @@ -493,14 +500,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { createdAt: now + 1, }) expect(ev1.id).not.toBe(ev2.id) // sanity: ev.id dedup would NOT catch this - triggerEvent(ev1) - triggerEvent(ev2) + await triggerEvent(ev1) + await triggerEvent(ev2) expect(received).toHaveLength(1) expect(received[0]!.payment_hash).toBe('hash-bbb') }) - it('fires onPush for each distinct payment_hash (negative dedup case)', () => { + it('fires onPush for each distinct payment_hash (negative dedup case)', async () => { const serverIdentity = makeIdentity() const recipientIdentity = makeIdentity() const { nostr, triggerEvent } = makeMockNostr() @@ -508,7 +515,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { serverPubkey: serverIdentity.publicKey, relays: ['ws://test/'], }) - client.initialize(nostr, recipientIdentity) + client.initialize(nostr, new LocalSigner(recipientIdentity)) const { received } = preregisterSub(client, 'sub-1') @@ -525,14 +532,14 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { paymentHash: 'hash-2', createdAt: Math.floor(Date.now() / 1000) + 2, }) - triggerEvent(ev1) - triggerEvent(ev2) + await triggerEvent(ev1) + await triggerEvent(ev2) expect(received).toHaveLength(2) expect(received.map((p) => p.payment_hash)).toEqual(['hash-1', 'hash-2']) }) - it('keeps dedup state per-subscription (one sub seeing a hash does not silence another)', () => { + it('keeps dedup state per-subscription (one sub seeing a hash does not silence another)', async () => { const serverIdentity = makeIdentity() const recipientIdentity = makeIdentity() const { nostr, triggerEvent } = makeMockNostr() @@ -540,7 +547,7 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { serverPubkey: serverIdentity.publicKey, relays: ['ws://test/'], }) - client.initialize(nostr, recipientIdentity) + client.initialize(nostr, new LocalSigner(recipientIdentity)) const a = preregisterSub(client, 'sub-A') const b = preregisterSub(client, 'sub-B') @@ -561,8 +568,8 @@ describe('LnbitsClient subscribe-payments dedup (#50)', () => { paymentHash: 'hash-shared', createdAt: Math.floor(Date.now() / 1000) + 1, }) - triggerEvent(evA) - triggerEvent(evB) + await triggerEvent(evA) + await triggerEvent(evB) // Each subscription sees its own push exactly once. expect(a.received).toHaveLength(1) diff --git a/packages/lnbits/src/client.ts b/packages/lnbits/src/client.ts index b30f469..76b796e 100644 --- a/packages/lnbits/src/client.ts +++ b/packages/lnbits/src/client.ts @@ -22,12 +22,10 @@ import { type NostrClient, - type MachineIdentity, + type Signer, type Event as NostrEvent, - encryptContentV2, - decryptContentV2, } from '@bitSpire/nostr-client' -import { finalizeEvent, verifyEvent } from 'nostr-tools' +import { verifyEvent } from 'nostr-tools' import type { LnbitsConfig, @@ -121,7 +119,7 @@ const SEEN_PAYMENT_HASHES_MAX = 500 export class LnbitsClient { private readonly config: Required private nostr: NostrClient | null = null - private identity: MachineIdentity | null = null + private signer: Signer | null = null private requestCounter = 0 private readonly pending = new Map< string, @@ -150,9 +148,9 @@ export class LnbitsClient { } } - initialize(nostr: NostrClient, identity: MachineIdentity): void { + initialize(nostr: NostrClient, signer: Signer): void { this.nostr = nostr - this.identity = identity + this.signer = signer this.startReplyListener() } @@ -240,7 +238,7 @@ export class LnbitsClient { onPush: PaymentPushCallback, onClose?: SubscriptionCloseCallback, ): Promise { - if (!this.nostr || !this.identity) { + if (!this.nostr || !this.signer) { throw new Error('LnbitsClient.subscribePayments: client not initialized') } const requestId = this.nextRequestId('sub') @@ -431,7 +429,7 @@ export class LnbitsClient { requestId?: string }, ): Promise { - if (!this.nostr || !this.identity) { + if (!this.nostr || !this.signer) { throw new Error(`LnbitsClient.${rpcName}: client not initialized`) } const requestId = args.requestId ?? this.nextRequestId(rpcName) @@ -444,10 +442,10 @@ export class LnbitsClient { if (args.query !== undefined) request.query = args.query as Record const plaintext = JSON.stringify(request) - const encrypted = encryptContentV2(this.identity, this.config.serverPubkey, plaintext) + const encrypted = await this.signer.nip44Encrypt(this.config.serverPubkey, plaintext) - // Build + sign the kind-21000 event ourselves. The server reads our - // pubkey directly off the signature, so there's no separate + // Build + sign the kind-21000 event via the signer. The server reads + // our pubkey directly off the signature, so there's no separate // authIdentifier in the envelope (unlike LightningPubClient). // // NIP-40 expiration: 5 minutes past now. Defence-in-depth at the @@ -457,18 +455,15 @@ export class LnbitsClient { // attacker can't bypass this by stripping the tag; the tag just // lets the relay short-circuit earlier. const now = Math.floor(Date.now() / 1000) - const event = finalizeEvent( - { - kind: LNBITS_KIND_RPC, - content: encrypted, - tags: [ - ['p', this.config.serverPubkey], - ['expiration', String(now + 300)], - ], - created_at: now, - }, - this.identity.privateKey, - ) + const event = await this.signer.signEvent({ + kind: LNBITS_KIND_RPC, + content: encrypted, + tags: [ + ['p', this.config.serverPubkey], + ['expiration', String(now + 300)], + ], + created_at: now, + }) // The pending entry MUST be registered before publish so we don't race // an extremely fast reply. @@ -508,8 +503,8 @@ export class LnbitsClient { * based on `request_id` and `subscription_id`. */ private startReplyListener(): void { - if (!this.nostr || !this.identity) return - const myPubkey = this.identity.publicKey + if (!this.nostr || !this.signer) return + const myPubkey = this.signer.pubkey const since = Math.floor(Date.now() / 1000) - 5 this.relaySubIdForReplies = this.nostr.subscribe( @@ -522,25 +517,28 @@ export class LnbitsClient { }, ], { - onEvent: (ev: NostrEvent) => this.handleReply(ev), + onEvent: (ev: NostrEvent) => void this.handleReply(ev), }, ) } - private handleReply(ev: NostrEvent): void { - if (!this.identity) return + private async handleReply(ev: NostrEvent): Promise { + const signer = this.signer + if (!signer) return if (!isAuthenticServerEvent(ev, this.config.serverPubkey)) return // Exact-replay dedup. Skip if we've already processed this event id. // Safe to trust `ev.id` here because `isAuthenticServerEvent` just // Schnorr-verified the event (`verifyEvent` recomputes the id and // confirms it matches the signed pubkey + body). Without that // guarantee an attacker could pre-poison this set with chosen ids. + // Runs before the async decrypt so concurrent re-deliveries of the + // same id still dedup synchronously. if (this.seenEventIds.has(ev.id)) return this.recordSeenEventId(ev.id) let plaintext: string try { - plaintext = decryptContentV2(this.identity, this.config.serverPubkey, ev.content) + plaintext = await signer.nip44Decrypt(this.config.serverPubkey, ev.content) } catch { return // not our peer or wrong key } diff --git a/packages/nostr-client/src/__tests__/events.test.ts b/packages/nostr-client/src/__tests__/events.test.ts index 7f1d2ea..f072788 100644 --- a/packages/nostr-client/src/__tests__/events.test.ts +++ b/packages/nostr-client/src/__tests__/events.test.ts @@ -1,19 +1,15 @@ import { describe, it, expect } from 'vitest' import { generateIdentity } from '../identity.js' -import { - createSignedEvent, - createMachineStatusEvent, - createAuthEvent, - validateEvent, - generateTxId, -} from '../events.js' -import { LamassuEventKind, type MachineStatus } from '../types.js' +import { LocalSigner } from '../signer.js' +import { createSignedEvent, createAuthEvent, validateEvent, generateTxId } from '../events.js' +import { LamassuEventKind } from '../types.js' describe('events', () => { describe('createSignedEvent', () => { - it('should create a properly signed event', () => { + it('should create a properly signed event via the signer', async () => { const identity = generateIdentity() - const event = createSignedEvent(identity, { + const signer = new LocalSigner(identity) + const event = await createSignedEvent(signer, { kind: 1, content: 'test', tags: [], @@ -25,48 +21,23 @@ describe('events', () => { expect(event.content).toBe('test') expect(event.id).toMatch(/^[0-9a-f]{64}$/) expect(event.sig).toMatch(/^[0-9a-f]{128}$/) - }) - }) - - describe('createMachineStatusEvent', () => { - it('should create encrypted status event', () => { - const machine = generateIdentity() - const operator = generateIdentity() - - const status: MachineStatus = { - online: true, - lastTransaction: Date.now(), - cashLevels: { - validator: 1000, - dispenser: [{ denomination: 20, count: 100, capacity: 500 }], - }, - errors: [], - version: '1.0.0', - } - - const event = createMachineStatusEvent(machine, operator.publicKey, status) - - expect(event.kind).toBe(LamassuEventKind.MachineStatus) - expect(event.pubkey).toBe(machine.publicKey) - expect(event.tags).toContainEqual(['d', 'status']) - expect(event.tags).toContainEqual(['p', operator.publicKey]) - // Content should be encrypted (not readable JSON) - expect(() => JSON.parse(event.content)).toThrow() + expect(validateEvent(event)).toBe(true) }) }) describe('createAuthEvent', () => { - it('should create NIP-42 auth event', () => { - const identity = generateIdentity() + it('should create a signed NIP-42 auth event (kind 22242)', async () => { + const signer = new LocalSigner(generateIdentity()) const relayUrl = 'wss://relay.test.com' const challenge = 'random-challenge-string' - const event = createAuthEvent(identity, relayUrl, challenge) + const event = await createAuthEvent(signer, relayUrl, challenge) expect(event.kind).toBe(LamassuEventKind.Auth) expect(event.content).toBe('') expect(event.tags).toContainEqual(['relay', relayUrl]) expect(event.tags).toContainEqual(['challenge', challenge]) + expect(event.pubkey).toBe(signer.pubkey) }) }) diff --git a/packages/nostr-client/src/__tests__/signer.test.ts b/packages/nostr-client/src/__tests__/signer.test.ts new file mode 100644 index 0000000..120346f --- /dev/null +++ b/packages/nostr-client/src/__tests__/signer.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest' +import { finalizeEvent, verifyEvent } from 'nostr-tools' +import { generateIdentity } from '../identity.js' +import { LocalSigner } from '../signer.js' +import { encryptContentV2, decryptContentV2 } from '../encryption.js' + +describe('LocalSigner', () => { + it('exposes the identity pubkey synchronously', () => { + const identity = generateIdentity() + const signer = new LocalSigner(identity) + expect(signer.pubkey).toBe(identity.publicKey) + }) + + it('signEvent produces a valid signature equivalent to finalizeEvent', async () => { + const identity = generateIdentity() + const signer = new LocalSigner(identity) + const template = { + kind: 21000, + content: 'rpc', + tags: [['p', identity.publicKey]], + created_at: 1_700_000_000, + } + + const signed = await signer.signEvent(template) + const reference = finalizeEvent(template, identity.privateKey) + + expect(verifyEvent(signed)).toBe(true) + expect(signed.pubkey).toBe(identity.publicKey) + // Same template + same key ⇒ same id (id is deterministic over content). + expect(signed.id).toBe(reference.id) + }) + + it('nip44Encrypt round-trips with the counterparty signer', async () => { + const alice = generateIdentity() + const bob = generateIdentity() + const aliceSigner = new LocalSigner(alice) + const bobSigner = new LocalSigner(bob) + + const ciphertext = await aliceSigner.nip44Encrypt(bob.publicKey, 'secret') + const plaintext = await bobSigner.nip44Decrypt(alice.publicKey, ciphertext) + + expect(plaintext).toBe('secret') + }) + + it('nip44 output interops with the standalone encryptContentV2 helper', async () => { + const alice = generateIdentity() + const bob = generateIdentity() + const aliceSigner = new LocalSigner(alice) + + const viaSigner = await aliceSigner.nip44Encrypt(bob.publicKey, 'hello') + // The helper and the signer share NIP-44 v2 conversation-key derivation, + // so each can decrypt the other's ciphertext. + expect(decryptContentV2(bob, alice.publicKey, viaSigner)).toBe('hello') + + const viaHelper = encryptContentV2(alice, bob.publicKey, 'hello') + expect(await aliceSigner.nip44Decrypt(bob.publicKey, viaHelper)).toBe('hello') + }) +}) diff --git a/packages/nostr-client/src/client.ts b/packages/nostr-client/src/client.ts index 0d35b4d..e9067be 100644 --- a/packages/nostr-client/src/client.ts +++ b/packages/nostr-client/src/client.ts @@ -7,14 +7,7 @@ * - Automatic reconnection */ -import { - type Event, - type Filter, - type VerifiedEvent, - Relay, - SimplePool, - verifyEvent, -} from 'nostr-tools' +import { type Event, type Filter, Relay, SimplePool, verifyEvent, nip19 } from 'nostr-tools' import { createAuthEvent } from './events.js' import type { NostrClientConfig, @@ -156,10 +149,15 @@ export class NostrClient { // We need to extract the challenge and create our auth response const challenge = evt.tags?.find((t): t is [string, string] => t[0] === 'challenge')?.[1] ?? '' - const authEvent = createAuthEvent(this.config.identity, connection.config.url, challenge) - // Verify the event to get a VerifiedEvent type + const authEvent = await createAuthEvent( + this.config.signer, + connection.config.url, + challenge + ) + // The signer returns a fully-signed event; re-verify defensively + // (a remote bunker could in principle return a malformed reply). if (verifyEvent(authEvent)) { - return authEvent as VerifiedEvent + return authEvent } throw new Error('Failed to create valid auth event') }) @@ -393,13 +391,13 @@ export class NostrClient { * Get the machine's public key */ get publicKey(): string { - return this.config.identity.publicKey + return this.config.signer.pubkey } /** * Get the machine's npub */ get npub(): string { - return this.config.identity.npub + return nip19.npubEncode(this.config.signer.pubkey) } } diff --git a/packages/nostr-client/src/events.ts b/packages/nostr-client/src/events.ts index 7b04d2f..82cc604 100644 --- a/packages/nostr-client/src/events.ts +++ b/packages/nostr-client/src/events.ts @@ -2,87 +2,33 @@ * Event creation utilities for Lamassu ATM */ -import { type Event, type UnsignedEvent, finalizeEvent, getEventHash } from 'nostr-tools' -import { encryptContent } from './encryption.js' -import { - type MachineIdentity, - type MachineStatus, - type TransactionRecord, - LamassuEventKind, -} from './types.js' +import { type Event, type EventTemplate, type VerifiedEvent, getEventHash } from 'nostr-tools' +import type { Signer } from './signer.js' +import { LamassuEventKind } from './types.js' /** - * Create a signed event - */ -export function createSignedEvent( - identity: MachineIdentity, - event: Omit -): Event { - const unsigned: UnsignedEvent = { - ...event, - pubkey: identity.publicKey, - } - - return finalizeEvent(unsigned, identity.privateKey) -} - -/** - * Create a machine status event (Kind 30078) + * Sign an event template with the given signer. * - * This is a replaceable event that represents the current machine state. - * Content is encrypted with NIP-44 for the operator. + * Thin async wrapper over `Signer.signEvent` — the signer sets `pubkey`, + * `id` and `sig`. With a `BunkerSigner` this is a relay round-trip. */ -export function createMachineStatusEvent( - identity: MachineIdentity, - operatorPubkey: string, - status: MachineStatus -): Event { - const encryptedContent = encryptContent(identity, operatorPubkey, status) - - return createSignedEvent(identity, { - kind: LamassuEventKind.MachineStatus, - content: encryptedContent, - tags: [ - ['d', 'status'], - ['p', operatorPubkey], - ], - created_at: Math.floor(Date.now() / 1000), - }) +export function createSignedEvent(signer: Signer, template: EventTemplate): Promise { + return signer.signEvent(template) } /** - * Create a transaction record event (Kind 30079) + * Create a NIP-42 auth event for relay authentication. * - * Replaceable event for each transaction, identified by txid. - * Content is encrypted with NIP-44 for the operator. - */ -export function createTransactionEvent( - identity: MachineIdentity, - operatorPubkey: string, - transaction: TransactionRecord -): Event { - const encryptedContent = encryptContent(identity, operatorPubkey, transaction) - - return createSignedEvent(identity, { - kind: LamassuEventKind.TransactionRecord, - content: encryptedContent, - tags: [ - ['d', `tx:${transaction.txid}`], - ['p', operatorPubkey], - ], - created_at: Math.floor(Date.now() / 1000), - }) -} - -/** - * Create a NIP-42 auth event for relay authentication + * Signed as the spire identity (kind 22242). Under the bunker this kind + * must be present in the signer policy (`SPIRE_POLICY_RULES`) or the sign + * request is rejected — see aiolabs/spirekeeper#26. */ export function createAuthEvent( - identity: MachineIdentity, + signer: Signer, relayUrl: string, challenge: string -): Event { - return createSignedEvent(identity, { +): Promise { + return signer.signEvent({ kind: LamassuEventKind.Auth, content: '', tags: [ diff --git a/packages/nostr-client/src/index.ts b/packages/nostr-client/src/index.ts index 417f666..eb16f5f 100644 --- a/packages/nostr-client/src/index.ts +++ b/packages/nostr-client/src/index.ts @@ -15,30 +15,32 @@ * import { * NostrClient, * generateIdentity, - * createMachineStatusEvent + * LocalSigner, + * createSignedEvent * } from '@bitSpire/nostr-client' * - * // Create or load identity - * const identity = generateIdentity() + * // Create or load identity, wrap it in a signer + * const signer = new LocalSigner(generateIdentity()) * * // Create client * const client = new NostrClient({ * relays: [ * { url: 'wss://relay.youratm.company', requiresAuth: true } * ], - * identity + * signer * }) * * // Connect * await client.connect() * - * // Publish machine status - * const statusEvent = createMachineStatusEvent( - * identity, - * operatorPubkey, - * { online: true, ... } - * ) - * await client.publish(statusEvent) + * // Sign + publish an event + * const event = await createSignedEvent(signer, { + * kind: 30078, + * created_at: Math.floor(Date.now() / 1000), + * tags: [['d', 'status']], + * content: '...' + * }) + * await client.publish(event) * ``` */ @@ -55,25 +57,15 @@ export { bytesToHex, } from './identity.js' -// Event creation -export { - createSignedEvent, - createMachineStatusEvent, - createTransactionEvent, - createAuthEvent, - validateEvent, - generateTxId, -} from './events.js' +// Signing abstraction +export { LocalSigner } from './signer.js' +export type { Signer } from './signer.js' -// Encryption -export { - encryptContent, - decryptContent, - decryptJSON, - // NIP-44 v2 (standard, for CLINK protocol) - encryptContentV2, - decryptContentV2, -} from './encryption.js' +// Event creation +export { createSignedEvent, createAuthEvent, validateEvent, generateTxId } from './events.js' + +// Encryption — NIP-44 v2 (used by the dormant CLINK client + tests) +export { encryptContentV2, decryptContentV2 } from './encryption.js' // Types export type { diff --git a/packages/nostr-client/src/signer.ts b/packages/nostr-client/src/signer.ts new file mode 100644 index 0000000..3d3571b --- /dev/null +++ b/packages/nostr-client/src/signer.ts @@ -0,0 +1,64 @@ +/** + * Signing + NIP-44 abstraction. + * + * Decouples every signing / encryption call site from the concrete key + * material. Two implementations: + * + * - `LocalSigner` holds an nsec in-process. Used for dev / ephemeral + * identities and as the transitional fallback when no bunker pairing + * exists. The underlying crypto is synchronous. + * - `BunkerSigner` (Phase B, aiolabs/bitspire#52) routes to a remote + * NIP-46 nsecbunkerd so no operator key ever lives on the ATM. + * + * `pubkey` is the *signing* identity and is always known synchronously — + * from the local nsec, or from the spire seed before the bunker connects — + * so subscription filters and `p` tags need no refactor when the backing + * implementation changes. + * + * All methods are async: the bunker path is a relay round-trip. The local + * path satisfies the contract with immediately-resolved promises so call + * sites are bunker-ready without further change. + */ + +import { type EventTemplate, type VerifiedEvent, finalizeEvent, nip44 } from 'nostr-tools' +import type { MachineIdentity } from './types.js' + +export interface Signer { + /** Hex pubkey of the signing identity. */ + readonly pubkey: string + /** Sign an unsigned event template, returning a fully-signed event. */ + signEvent(template: EventTemplate): Promise + /** NIP-44 v2 encrypt `plaintext` for `peerPubkey`. */ + nip44Encrypt(peerPubkey: string, plaintext: string): Promise + /** NIP-44 v2 decrypt `ciphertext` from `peerPubkey`. */ + nip44Decrypt(peerPubkey: string, ciphertext: string): Promise +} + +/** + * In-process signer backed by a local nsec. The crypto is synchronous; + * the async surface is satisfied by immediately-resolved promises so call + * sites are identical whether the signer is local or a remote bunker. + */ +export class LocalSigner implements Signer { + readonly pubkey: string + readonly #privateKey: Uint8Array + + constructor(identity: MachineIdentity) { + this.pubkey = identity.publicKey + this.#privateKey = identity.privateKey + } + + signEvent(template: EventTemplate): Promise { + return Promise.resolve(finalizeEvent(template, this.#privateKey)) + } + + nip44Encrypt(peerPubkey: string, plaintext: string): Promise { + const conversationKey = nip44.v2.utils.getConversationKey(this.#privateKey, peerPubkey) + return Promise.resolve(nip44.v2.encrypt(plaintext, conversationKey)) + } + + nip44Decrypt(peerPubkey: string, ciphertext: string): Promise { + const conversationKey = nip44.v2.utils.getConversationKey(this.#privateKey, peerPubkey) + return Promise.resolve(nip44.v2.decrypt(ciphertext, conversationKey)) + } +} diff --git a/packages/nostr-client/src/types.ts b/packages/nostr-client/src/types.ts index 068a9e2..43b44bc 100644 --- a/packages/nostr-client/src/types.ts +++ b/packages/nostr-client/src/types.ts @@ -2,7 +2,8 @@ * Nostr client type definitions for Lamassu ATM */ -import type { Event, UnsignedEvent } from 'nostr-tools' +import type { Event } from 'nostr-tools' +import type { Signer } from './signer.js' /** Connection states for relay */ export type ConnectionState = @@ -25,6 +26,7 @@ export interface RelayConfig { /** Machine identity configuration */ export interface MachineIdentity { + // pragma: allowlist secret /** Private key in hex format */ privateKey: Uint8Array /** Public key in hex format */ @@ -37,8 +39,8 @@ export interface MachineIdentity { export interface NostrClientConfig { /** Relays to connect to */ relays: RelayConfig[] - /** Machine identity (keypair) */ - identity: MachineIdentity + /** Signer for the machine identity (local nsec or remote bunker) */ + signer: Signer /** Connection timeout in ms (default: 10000) */ connectionTimeout?: number /** Reconnect automatically on disconnect */ From 787de5bff1f1c7a41d714098f7fe57ac8c5ba596 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 19:57:02 +0200 Subject: [PATCH 05/82] refactor(nostr-client): retire dead NIP-44 v1 / Lightning.Pub path Drop encryptContent / decryptContent / decryptJSON and the hand-rolled XChaCha20 + v1 conversation-key machinery they depended on (~230 lines). The only callers were createMachineStatusEvent / createTransactionEvent, which had no callers in apps/ and were removed in the Signer migration. This closes the open question carried in aiolabs/bitspire#52: every live encryption path is NIP-44 v2, and the nsecbunkerd signer is v2-only, so there is nothing to keep v1 for. encryptContentV2 / decryptContentV2 stay as the v2 helpers used by the dormant CLINK client + tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/encryption.test.ts | 31 +- packages/nostr-client/src/encryption.ts | 271 +----------------- 2 files changed, 17 insertions(+), 285 deletions(-) diff --git a/packages/nostr-client/src/__tests__/encryption.test.ts b/packages/nostr-client/src/__tests__/encryption.test.ts index 7595332..d684841 100644 --- a/packages/nostr-client/src/__tests__/encryption.test.ts +++ b/packages/nostr-client/src/__tests__/encryption.test.ts @@ -1,46 +1,33 @@ import { describe, it, expect } from 'vitest' import { generateIdentity } from '../identity.js' -import { encryptContent, decryptContent, decryptJSON } from '../encryption.js' +import { encryptContentV2, decryptContentV2 } from '../encryption.js' -describe('encryption', () => { - describe('encryptContent / decryptContent', () => { +describe('encryption (NIP-44 v2)', () => { + describe('encryptContentV2 / decryptContentV2', () => { it('should encrypt and decrypt string content', () => { const sender = generateIdentity() const recipient = generateIdentity() const message = 'Hello, Nostr!' - const encrypted = encryptContent(sender, recipient.publicKey, message) + const encrypted = encryptContentV2(sender, recipient.publicKey, message) expect(encrypted).not.toBe(message) expect(typeof encrypted).toBe('string') - const decrypted = decryptContent(recipient, sender.publicKey, encrypted) + const decrypted = decryptContentV2(recipient, sender.publicKey, encrypted) expect(decrypted).toBe(message) }) - it('should encrypt and decrypt object content', () => { + it('should encrypt and decrypt object content (serialized to JSON)', () => { const sender = generateIdentity() const recipient = generateIdentity() - const data = { amount: 1000, currency: 'USD', timestamp: Date.now() } + const data = { amount: 1000, currency: 'USD', timestamp: 1_700_000_000 } - const encrypted = encryptContent(sender, recipient.publicKey, data) - const decrypted = decryptContent(recipient, sender.publicKey, encrypted) + const encrypted = encryptContentV2(sender, recipient.publicKey, data) + const decrypted = decryptContentV2(recipient, sender.publicKey, encrypted) expect(JSON.parse(decrypted)).toEqual(data) }) }) - - describe('decryptJSON', () => { - it('should decrypt and parse JSON directly', () => { - const sender = generateIdentity() - const recipient = generateIdentity() - const data = { test: true, nested: { value: 42 } } - - const encrypted = encryptContent(sender, recipient.publicKey, data) - const decrypted = decryptJSON(recipient, sender.publicKey, encrypted) - - expect(decrypted).toEqual(data) - }) - }) }) diff --git a/packages/nostr-client/src/encryption.ts b/packages/nostr-client/src/encryption.ts index 8d6a1d3..7b0980f 100644 --- a/packages/nostr-client/src/encryption.ts +++ b/packages/nostr-client/src/encryption.ts @@ -1,274 +1,19 @@ /** - * NIP-44 Encryption utilities + * NIP-44 v2 encryption helpers. * - * Supports both: - * - v1: Lightning.Pub's custom format (xchacha20, used for kind 21000) - * - v2: Standard NIP-44 v2 (used for other kinds) + * Thin wrappers over nostr-tools `nip44.v2`, used for operator-directed + * kind-30078 content and by the dormant CLINK client. Kind-21000 RPC and + * the availability/cassette paths route through the `Signer` abstraction + * (`signer.ts`) instead. * - * NOTE: Lightning.Pub currently only supports NIP-44 v1 for kind 21000 RPC. - * A contribution to support v2 would be welcome: - * https://github.com/shocknet/Lightning.Pub + * The legacy NIP-44 v1 / Lightning.Pub XChaCha20 format was retired with + * the LNbits migration (aiolabs/bitspire#52): the nsecbunkerd signer is + * NIP-44 v2 only and nothing live used v1. */ import { nip44 } from 'nostr-tools' -import { bytesToHex, hexToBytes } from 'nostr-tools/utils' -import { secp256k1 } from '@noble/curves/secp256k1.js' -import { sha256 } from '@noble/hashes/sha2.js' import type { MachineIdentity } from './types.js' -const V1_ENCRYPTION_VERSION = 1 - -// Base64 utilities that work in both browser and Node -function base64Encode(bytes: Uint8Array): string { - if (typeof btoa !== 'undefined') { - let binary = '' - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]!) - } - return btoa(binary) - } - return Buffer.from(bytes).toString('base64') -} - -function base64Decode(str: string): Uint8Array { - if (typeof atob !== 'undefined') { - const binary = atob(str) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return bytes - } - return new Uint8Array(Buffer.from(str, 'base64')) -} - -// Crypto random bytes -function getRandomBytes(length: number): Uint8Array { - if (typeof crypto !== 'undefined' && crypto.getRandomValues) { - return crypto.getRandomValues(new Uint8Array(length)) - } - // Node.js fallback - const { randomBytes } = require('crypto') as typeof import('crypto') - return new Uint8Array(randomBytes(length)) -} - -// XChaCha20 implementation -function rotl(a: number, b: number): number { - return ((a << b) | (a >>> (32 - b))) >>> 0 -} - -function quarterRound(state: Uint32Array, a: number, b: number, c: number, d: number): void { - state[a] = (state[a]! + state[b]!) >>> 0 - state[d] = rotl(state[d]! ^ state[a]!, 16) - state[c] = (state[c]! + state[d]!) >>> 0 - state[b] = rotl(state[b]! ^ state[c]!, 12) - state[a] = (state[a]! + state[b]!) >>> 0 - state[d] = rotl(state[d]! ^ state[a]!, 8) - state[c] = (state[c]! + state[d]!) >>> 0 - state[b] = rotl(state[b]! ^ state[c]!, 7) -} - -function chacha20Block(key: Uint8Array, nonce: Uint8Array, counter: number): Uint8Array { - const state = new Uint32Array(16) - const keyBuf = new ArrayBuffer(32) - new Uint8Array(keyBuf).set(key) - const nonceBuf = new ArrayBuffer(12) - new Uint8Array(nonceBuf).set(nonce) - const view = new DataView(keyBuf) - const nonceView = new DataView(nonceBuf) - - // "expand 32-byte k" - state[0] = 0x61707865 - state[1] = 0x3320646e - state[2] = 0x79622d32 - state[3] = 0x6b206574 - - for (let i = 0; i < 8; i++) { - state[4 + i] = view.getUint32(i * 4, true) - } - - state[12] = counter >>> 0 - for (let i = 0; i < 3; i++) { - state[13 + i] = nonceView.getUint32(i * 4, true) - } - - const working = new Uint32Array(state) - - for (let i = 0; i < 10; i++) { - quarterRound(working, 0, 4, 8, 12) - quarterRound(working, 1, 5, 9, 13) - quarterRound(working, 2, 6, 10, 14) - quarterRound(working, 3, 7, 11, 15) - quarterRound(working, 0, 5, 10, 15) - quarterRound(working, 1, 6, 11, 12) - quarterRound(working, 2, 7, 8, 13) - quarterRound(working, 3, 4, 9, 14) - } - - const output = new Uint8Array(64) - const outView = new DataView(output.buffer) - for (let i = 0; i < 16; i++) { - outView.setUint32(i * 4, (working[i]! + state[i]!) >>> 0, true) - } - - return output -} - -function hchacha20(key: Uint8Array, nonce: Uint8Array): Uint8Array { - const state = new Uint32Array(16) - const keyBuf = new ArrayBuffer(32) - new Uint8Array(keyBuf).set(key) - const nonceBuf = new ArrayBuffer(16) - new Uint8Array(nonceBuf).set(nonce) - const keyView = new DataView(keyBuf) - const nonceView = new DataView(nonceBuf) - - state[0] = 0x61707865 - state[1] = 0x3320646e - state[2] = 0x79622d32 - state[3] = 0x6b206574 - - for (let i = 0; i < 8; i++) { - state[4 + i] = keyView.getUint32(i * 4, true) - } - - for (let i = 0; i < 4; i++) { - state[12 + i] = nonceView.getUint32(i * 4, true) - } - - for (let i = 0; i < 10; i++) { - quarterRound(state, 0, 4, 8, 12) - quarterRound(state, 1, 5, 9, 13) - quarterRound(state, 2, 6, 10, 14) - quarterRound(state, 3, 7, 11, 15) - quarterRound(state, 0, 5, 10, 15) - quarterRound(state, 1, 6, 11, 12) - quarterRound(state, 2, 7, 8, 13) - quarterRound(state, 3, 4, 9, 14) - } - - const result = new Uint8Array(32) - const resultView = new DataView(result.buffer) - resultView.setUint32(0, state[0]!, true) - resultView.setUint32(4, state[1]!, true) - resultView.setUint32(8, state[2]!, true) - resultView.setUint32(12, state[3]!, true) - resultView.setUint32(16, state[12]!, true) - resultView.setUint32(20, state[13]!, true) - resultView.setUint32(24, state[14]!, true) - resultView.setUint32(28, state[15]!, true) - - return result -} - -function xchacha20Encrypt(key: Uint8Array, nonce: Uint8Array, data: Uint8Array): Uint8Array { - const subkey = hchacha20(key, nonce.subarray(0, 16)) - const chacha20Nonce = new Uint8Array(12) - chacha20Nonce.set(nonce.subarray(16, 24), 4) - - const result = new Uint8Array(data.length) - let counter = 0 - - for (let offset = 0; offset < data.length; offset += 64) { - const block = chacha20Block(subkey, chacha20Nonce, counter++) - const remaining = Math.min(64, data.length - offset) - for (let i = 0; i < remaining; i++) { - result[offset + i] = data[offset + i]! ^ block[i]! - } - } - - return result -} - -/** - * Get shared secret for v1 encryption (Lightning.Pub format) - * - * NIP-44 v1 key derivation: - * sha256(secp256k1.getSharedSecret(privKey, "02" + pubKey).slice(1, 33)) - * - * This differs from v2 which uses HKDF instead of plain SHA-256. - */ -function getConversationKeyV1(privateKey: Uint8Array, publicKey: string): Uint8Array { - // Compute ECDH shared point with compressed pubkey (02 prefix for even y) - const compressedPubkey = hexToBytes('02' + publicKey) - const sharedPoint = secp256k1.getSharedSecret(privateKey, compressedPubkey) - // Take x-coordinate only (skip the 0x04 prefix byte) and hash with SHA-256 - return sha256(sharedPoint.slice(1, 33)) -} - -/** - * Encrypt content using v1 format (Lightning.Pub's format for kind 21000) - */ -export function encryptV1(content: string, sharedSecret: Uint8Array): string { - const nonce = getRandomBytes(24) - const plaintext = new TextEncoder().encode(content) - const ciphertext = xchacha20Encrypt(sharedSecret, nonce, plaintext) - - const payload = new Uint8Array(1 + nonce.length + ciphertext.length) - payload[0] = V1_ENCRYPTION_VERSION - payload.set(nonce, 1) - payload.set(ciphertext, 25) - - return base64Encode(payload) -} - -/** - * Decrypt content using v1 format (Lightning.Pub's format) - */ -export function decryptV1(content: string, sharedSecret: Uint8Array): string { - const buf = base64Decode(content) - - if (buf[0] !== V1_ENCRYPTION_VERSION) { - throw new Error('Encryption version unsupported') - } - - const nonce = buf.subarray(1, 25) - const ciphertext = buf.subarray(25) - const plaintext = xchacha20Encrypt(sharedSecret, nonce, ciphertext) // XChaCha20 is symmetric - - return new TextDecoder().decode(plaintext) -} - -/** - * Encrypt content for Lightning.Pub RPC (kind 21000) - * Uses v1 format that Lightning.Pub expects - */ -export function encryptContent( - identity: MachineIdentity, - recipientPubkey: string, - content: unknown -): string { - const plaintext = typeof content === 'string' ? content : JSON.stringify(content) - const sharedSecret = getConversationKeyV1(identity.privateKey, recipientPubkey) - return encryptV1(plaintext, sharedSecret) -} - -/** - * Decrypt content from Lightning.Pub RPC (kind 21000) - * Uses v1 format - */ -export function decryptContent( - identity: MachineIdentity, - senderPubkey: string, - ciphertext: string -): string { - const sharedSecret = getConversationKeyV1(identity.privateKey, senderPubkey) - return decryptV1(ciphertext, sharedSecret) -} - -/** - * Decrypt and parse JSON content - */ -export function decryptJSON( - identity: MachineIdentity, - senderPubkey: string, - ciphertext: string -): T { - const plaintext = decryptContent(identity, senderPubkey, ciphertext) - return JSON.parse(plaintext) as T -} - -// Also export v2 functions for other use cases (non-RPC encrypted messages) export const encryptContentV2 = ( identity: MachineIdentity, recipientPubkey: string, From 9c9009af315a92def8f305346ea76384bc3aec1b Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 23:24:22 +0200 Subject: [PATCH 06/82] feat(nostr-client): NIP-46 bunker signer + spire pairing seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B of aiolabs/bitspire#52 — the consumer surface for routing signing to the operator's nsecbunkerd (model A1: the ATM holds only its own NIP-46 transport key; the signing identity lives in the bunker). - seed.ts: parseSpireSeed for the `spire-seed:v1:` contract from spirekeeper pairing.py — re-pads stripped base64url, validates {v, spire_pubkey, bunker_url, relays}, leaves percent-decoding of the bunker URL to parseBunkerInput. seedFingerprint() detects a re-pair. - bunker-signer.ts: BunkerSigner implements Signer by delegating sign_event / nip44_* to nostr-tools' nip46 over the bunker relay. pubkey is the spire identity, known synchronously from the seed. connectNewSeed redeems the one-shot connect secret; resumeFromBinding reuses the persisted transport key WITHOUT re-redeeming (the binding is server-persistent). Per-RPC timeout + typed BunkerRejectedError / BunkerTimeoutError so callers can distinguish revoked-binding (re-pair) from a transient outage. Unit-tested against a fake inner client (delegation, sync pubkey, timeout, error mapping) + seed round-trip/validation fixtures. Live-relay wiring is Phase C; live bunker integration is Phase F. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/bunker-signer.test.ts | 89 +++++++++ .../nostr-client/src/__tests__/seed.test.ts | 76 ++++++++ packages/nostr-client/src/bunker-signer.ts | 174 ++++++++++++++++++ packages/nostr-client/src/index.ts | 13 ++ packages/nostr-client/src/seed.ts | 105 +++++++++++ 5 files changed, 457 insertions(+) create mode 100644 packages/nostr-client/src/__tests__/bunker-signer.test.ts create mode 100644 packages/nostr-client/src/__tests__/seed.test.ts create mode 100644 packages/nostr-client/src/bunker-signer.ts create mode 100644 packages/nostr-client/src/seed.ts diff --git a/packages/nostr-client/src/__tests__/bunker-signer.test.ts b/packages/nostr-client/src/__tests__/bunker-signer.test.ts new file mode 100644 index 0000000..c72dc4e --- /dev/null +++ b/packages/nostr-client/src/__tests__/bunker-signer.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from 'vitest' +import type { EventTemplate, VerifiedEvent } from 'nostr-tools' +import { + BunkerSigner, + BunkerRejectedError, + BunkerTimeoutError, + generateClientTransportKey, + connectNewSeed, + resumeFromBinding, + type Nip46Inner, +} from '../bunker-signer.js' + +const SPIRE_PUBKEY = 'b'.repeat(64) + +function fakeInner(overrides: Partial = {}): Nip46Inner { + return { + connect: vi.fn(async () => {}), + signEvent: vi.fn(async (t: EventTemplate) => ({ ...t, id: 'id', sig: 'sig', pubkey: SPIRE_PUBKEY }) as unknown as VerifiedEvent), + nip44Encrypt: vi.fn(async (_pk: string, pt: string) => `enc(${pt})`), + nip44Decrypt: vi.fn(async (_pk: string, ct: string) => ct.replace(/^enc\((.*)\)$/, '$1')), + ...overrides, + } +} + +describe('BunkerSigner', () => { + it('exposes the spire pubkey synchronously', () => { + const signer = new BunkerSigner(SPIRE_PUBKEY, fakeInner()) + expect(signer.pubkey).toBe(SPIRE_PUBKEY) + }) + + it('delegates sign / encrypt / decrypt to the inner client', async () => { + const inner = fakeInner() + const signer = new BunkerSigner(SPIRE_PUBKEY, inner) + + const tmpl: EventTemplate = { kind: 21000, tags: [], content: 'x', created_at: 1 } + await signer.signEvent(tmpl) + expect(inner.signEvent).toHaveBeenCalledWith(tmpl) + + expect(await signer.nip44Encrypt('peer', 'hi')).toBe('enc(hi)') + expect(inner.nip44Encrypt).toHaveBeenCalledWith('peer', 'hi') + + expect(await signer.nip44Decrypt('peer', 'enc(hi)')).toBe('hi') + }) + + it('maps an inner rejection to BunkerRejectedError (revoked / off-policy)', async () => { + const inner = fakeInner({ + signEvent: vi.fn(async () => { + throw new Error('not authorized to sign kind 9999') + }), + }) + const signer = new BunkerSigner(SPIRE_PUBKEY, inner) + await expect(signer.signEvent({ kind: 9999, tags: [], content: '', created_at: 1 })).rejects.toBeInstanceOf( + BunkerRejectedError + ) + }) + + it('times out a non-responding bunker with BunkerTimeoutError', async () => { + vi.useFakeTimers() + const inner = fakeInner({ signEvent: vi.fn(() => new Promise(() => {})) }) + const signer = new BunkerSigner(SPIRE_PUBKEY, inner, { timeoutMs: 50 }) + + const p = signer.signEvent({ kind: 21000, tags: [], content: '', created_at: 1 }) + const assertion = expect(p).rejects.toBeInstanceOf(BunkerTimeoutError) + await vi.advanceTimersByTimeAsync(60) + await assertion + vi.useRealTimers() + }) +}) + +describe('transport key + factory guards', () => { + it('generates a hex transport keypair', () => { + const key = generateClientTransportKey() + expect(key.secretHex).toMatch(/^[0-9a-f]{64}$/) + expect(key.publicHex).toMatch(/^[0-9a-f]{64}$/) + expect(key.secretHex).not.toBe(key.publicHex) + }) + + it('connectNewSeed rejects an unparseable bunker_url', async () => { + await expect( + connectNewSeed({ spirePubkey: SPIRE_PUBKEY, bunkerUrl: 'not-a-bunker-url', clientSecretHex: 'a'.repeat(64) }) + ).rejects.toThrow(/unparseable bunker_url/) + }) + + it('resumeFromBinding rejects an unparseable bunker_url', async () => { + await expect( + resumeFromBinding({ spirePubkey: SPIRE_PUBKEY, bunkerUrl: 'not-a-bunker-url', clientSecretHex: 'a'.repeat(64) }) + ).rejects.toThrow(/unparseable bunker_url/) + }) +}) diff --git a/packages/nostr-client/src/__tests__/seed.test.ts b/packages/nostr-client/src/__tests__/seed.test.ts new file mode 100644 index 0000000..0deef80 --- /dev/null +++ b/packages/nostr-client/src/__tests__/seed.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest' +import { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from '../seed.js' + +/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */ +function makeSeed(json: unknown): string { + const b64 = Buffer.from(JSON.stringify(json), 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + return SPIRE_SEED_SCHEME + b64 +} + +const SPIRE_PUBKEY = 'a'.repeat(64) +const BUNKER_URL = `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef` + +const VALID = { + v: 1, + spire_npub: 'npub1example', + spire_pubkey: SPIRE_PUBKEY, + bunker_url: BUNKER_URL, + relays: ['wss://events.relay/'], +} + +describe('parseSpireSeed', () => { + it('parses a well-formed seed (snake_case → camelCase)', () => { + const seed = parseSpireSeed(makeSeed(VALID)) + expect(seed).toEqual({ + v: 1, + spirePubkey: SPIRE_PUBKEY, + bunkerUrl: BUNKER_URL, + relays: ['wss://events.relay/'], + }) + }) + + it('re-pads stripped base64url of any residue length', () => { + // Vary a field so the encoded payload lands on each mod-4 residue. + for (const suffix of ['', 'a', 'ab', 'abc']) { + const seed = makeSeed({ ...VALID, spire_npub: `npub1${suffix}` }) + expect(() => parseSpireSeed(seed)).not.toThrow() + } + }) + + it('keeps bunker_url verbatim (percent-decoding is parseBunkerInput’s job)', () => { + const seed = parseSpireSeed(makeSeed(VALID)) + expect(seed.bunkerUrl).toContain('relay=wss%3A%2F%2F') + expect(seed.bunkerUrl).toContain('secret=deadbeef') + }) + + it.each([ + ['wrong scheme', 'spire-seed:v2:abc'], + ['not a seed', 'bunker://whatever'], + ])('rejects %s', (_label, url) => { + expect(() => parseSpireSeed(url)).toThrow() + }) + + it.each([ + ['bad version', { ...VALID, v: 2 }], + ['short pubkey', { ...VALID, spire_pubkey: 'abc' }], + ['non-bunker url', { ...VALID, bunker_url: 'https://evil/' }], + ['empty relays', { ...VALID, relays: [] }], + ['non-string relay', { ...VALID, relays: [123] }], + ])('rejects %s', (_label, json) => { + expect(() => parseSpireSeed(makeSeed(json))).toThrow() + }) +}) + +describe('seedFingerprint', () => { + it('is stable for the same seed and differs across seeds', () => { + const a = makeSeed(VALID) + const b = makeSeed({ ...VALID, relays: ['wss://other.relay/'] }) + expect(seedFingerprint(a)).toBe(seedFingerprint(a)) + expect(seedFingerprint(a)).not.toBe(seedFingerprint(b)) + expect(seedFingerprint(a)).toMatch(/^[0-9a-f]{64}$/) + }) +}) diff --git a/packages/nostr-client/src/bunker-signer.ts b/packages/nostr-client/src/bunker-signer.ts new file mode 100644 index 0000000..ed80f02 --- /dev/null +++ b/packages/nostr-client/src/bunker-signer.ts @@ -0,0 +1,174 @@ +/** + * NIP-46 (nsecbunkerd) signer. + * + * Implements the `Signer` contract by delegating sign / nip44 to a remote + * bunker over NIP-46, so no operator key lives on the ATM. The ATM holds + * only its own *transport* keypair (`client_nsec`); the signing identity + * (`spire_pubkey`) is held by the operator's nsecbunkerd. See + * aiolabs/bitspire#52 (model A1) and lnbits `nip46_bunker_client.py`. + * + * Two lifecycle entry points: + * - `connectNewSeed` — first pairing: generate a transport key, redeem the + * one-shot connect secret, bind `client_pubkey → spire_key` on the bunker. + * - `resumeFromBinding` — restart: reuse the persisted transport key. The + * binding is server-persistent, so we do NOT re-redeem (the secret is + * spent); we just re-open the relay subscription. + * + * `pubkey` is the spire identity, known synchronously from the seed/binding, + * so subscription filters and `p` tags work before any round-trip. + */ + +import { BunkerSigner as Nip46BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46' +import { generateSecretKey, getPublicKey } from 'nostr-tools' +import { bytesToHex, hexToBytes } from 'nostr-tools/utils' +import type { EventTemplate, VerifiedEvent } from 'nostr-tools' +import type { Signer } from './signer.js' + +/** Default per-RPC timeout. nostr-tools' nip46 sendRequest has none — a dead + * bunker would hang forever — so we race every call against this. */ +const DEFAULT_BUNKER_TIMEOUT_MS = 10_000 + +/** + * Raised when the bunker actively rejects a request (e.g. the operator + * revoked the spire's binding, or a kind/method is outside the policy). + * Callers should treat this as "unpaired" and surface a re-pair prompt. + */ +export class BunkerRejectedError extends Error { + constructor(message: string) { + super(message) + this.name = 'BunkerRejectedError' + } +} + +/** Raised when the bunker does not answer within the timeout (transient). */ +export class BunkerTimeoutError extends Error { + constructor(message: string) { + super(message) + this.name = 'BunkerTimeoutError' + } +} + +/** The subset of nostr-tools' nip46 BunkerSigner this wrapper drives. */ +export interface Nip46Inner { + connect(): Promise + signEvent(event: EventTemplate): Promise + nip44Encrypt(thirdPartyPubkey: string, plaintext: string): Promise + nip44Decrypt(thirdPartyPubkey: string, ciphertext: string): Promise +} + +export interface BunkerSignerOptions { + /** Per-RPC timeout in ms (default 10000). */ + timeoutMs?: number +} + +export class BunkerSigner implements Signer { + readonly pubkey: string + readonly #inner: Nip46Inner + readonly #timeoutMs: number + + constructor(spirePubkey: string, inner: Nip46Inner, opts: BunkerSignerOptions = {}) { + this.pubkey = spirePubkey + this.#inner = inner + this.#timeoutMs = opts.timeoutMs ?? DEFAULT_BUNKER_TIMEOUT_MS + } + + signEvent(template: EventTemplate): Promise { + return this.#call('sign_event', () => this.#inner.signEvent(template)) + } + + nip44Encrypt(peerPubkey: string, plaintext: string): Promise { + return this.#call('nip44_encrypt', () => this.#inner.nip44Encrypt(peerPubkey, plaintext)) + } + + nip44Decrypt(peerPubkey: string, ciphertext: string): Promise { + return this.#call('nip44_decrypt', () => this.#inner.nip44Decrypt(peerPubkey, ciphertext)) + } + + /** + * Wrap a bunker RPC with a timeout and normalize failures. nostr-tools' + * nip46 rejects with the bunker's `error` string (a rejection) — mapped to + * `BunkerRejectedError`; a non-response surfaces as `BunkerTimeoutError`. + */ + async #call(label: string, fn: () => Promise): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new BunkerTimeoutError(`bunker ${label}: no response in ${this.#timeoutMs}ms`)), + this.#timeoutMs + ) + }) + try { + return await Promise.race([fn(), timeout]) + } catch (err) { + if (err instanceof BunkerTimeoutError) throw err + throw new BunkerRejectedError(`bunker ${label}: ${(err as Error).message ?? String(err)}`) + } finally { + if (timer) clearTimeout(timer) + } + } +} + +/** A freshly-generated NIP-46 transport keypair (the ATM's `client_nsec`). */ +export interface ClientTransportKey { + /** 64-char hex secret key — persist this to state.db. */ + secretHex: string + /** 64-char hex public key — what the bunker binds to the spire identity. */ + publicHex: string +} + +/** Generate the ATM's own NIP-46 transport keypair. */ +export function generateClientTransportKey(): ClientTransportKey { + const sk = generateSecretKey() + return { secretHex: bytesToHex(sk), publicHex: getPublicKey(sk) } +} + +/** Persisted bunker binding — everything needed to resume without re-pairing. */ +export interface BunkerBinding { + /** Hex transport secret key (`client_nsec`). */ + clientSecretHex: string + /** The spire's signing pubkey (hex). */ + spirePubkey: string + /** `bunker://…` URL, re-parsed into a pointer on resume. */ + bunkerUrl: string +} + +/** + * First pairing: build a transport-keyed bunker signer, redeem the one-shot + * connect secret, and bind `client_pubkey → spire_key`. The returned signer + * is live; the caller persists `clientSecretHex` so a restart can resume. + * + * `bunkerUrl` is the seed's `bunker_url`; `spirePubkey` is the seed's + * `spire_pubkey`. + */ +export async function connectNewSeed( + args: { spirePubkey: string; bunkerUrl: string; clientSecretHex: string }, + opts: BunkerSignerOptions = {} +): Promise { + const pointer = await parseBunkerInput(args.bunkerUrl) + if (!pointer) { + throw new Error(`connectNewSeed: unparseable bunker_url`) + } + const inner = Nip46BunkerSigner.fromBunker(hexToBytes(args.clientSecretHex), pointer) + await inner.connect() // redeems the one-shot secret; eager-binds on the bunker + return new BunkerSigner(args.spirePubkey, inner, opts) +} + +/** + * Restart: reuse the persisted transport key. The bunker binding is + * server-persistent, so we do NOT call `connect()` (the secret is spent); + * `fromBunker` opens the relay subscription and `sign_event` works against + * the existing binding. + */ +export async function resumeFromBinding( + binding: BunkerBinding, + opts: BunkerSignerOptions = {} +): Promise { + const pointer = await parseBunkerInput(binding.bunkerUrl) + if (!pointer) { + throw new Error(`resumeFromBinding: unparseable bunker_url`) + } + // The connect secret is already spent; drop it so nothing re-redeems. + pointer.secret = null + const inner = Nip46BunkerSigner.fromBunker(hexToBytes(binding.clientSecretHex), pointer) + return new BunkerSigner(binding.spirePubkey, inner, opts) +} diff --git a/packages/nostr-client/src/index.ts b/packages/nostr-client/src/index.ts index eb16f5f..bb821b4 100644 --- a/packages/nostr-client/src/index.ts +++ b/packages/nostr-client/src/index.ts @@ -61,6 +61,19 @@ export { export { LocalSigner } from './signer.js' export type { Signer } from './signer.js' +// NIP-46 bunker signer + pairing seed (aiolabs/bitspire#52) +export { + BunkerSigner, + BunkerRejectedError, + BunkerTimeoutError, + generateClientTransportKey, + connectNewSeed, + resumeFromBinding, +} from './bunker-signer.js' +export type { BunkerBinding, BunkerSignerOptions, ClientTransportKey } from './bunker-signer.js' +export { parseSpireSeed, seedFingerprint, SPIRE_SEED_SCHEME } from './seed.js' +export type { SpireSeed } from './seed.js' + // Event creation export { createSignedEvent, createAuthEvent, validateEvent, generateTxId } from './events.js' diff --git a/packages/nostr-client/src/seed.ts b/packages/nostr-client/src/seed.ts new file mode 100644 index 0000000..e2ab902 --- /dev/null +++ b/packages/nostr-client/src/seed.ts @@ -0,0 +1,105 @@ +/** + * Spire pairing seed-URL parser. + * + * The operator dashboard (aiolabs/spirekeeper `pairing.py`) hands each ATM a + * one-time seed URL that encodes the bunker connection + the spire's signing + * identity. Wire contract (model A1): + * + * spire-seed:v1: + * json = { + * "v": 1, + * "spire_npub": "npub1…", // informational, ignored here + * "spire_pubkey": "<64-hex>", // the spire's bunker-held signing identity + * "bunker_url": "bunker://?relay=&secret=", + * "relays": ["wss://…"] // relays for the spire's OWN events (21000/30078) + * } + * + * - base64url is `urlsafe_b64encode(...).rstrip("=")` → re-pad to a multiple + * of 4 before decoding. + * - `relay` / `secret` inside `bunker_url` are percent-encoded; decoding them + * is left to nostr-tools `parseBunkerInput` (see bunker-signer.ts), so we + * keep `bunker_url` verbatim. + * - `bunker_url`'s relay is the BUNKER relay; `relays[]` is where the spire + * publishes its own events. They may differ — both must be spire-reachable. + */ + +import { sha256 } from '@noble/hashes/sha2.js' +import { bytesToHex } from 'nostr-tools/utils' + +export const SPIRE_SEED_SCHEME = 'spire-seed:v1:' + +export interface SpireSeed { + /** Seed format version (always 1 for this scheme). */ + v: number + /** The spire's signing identity — 64-char hex. Every event is signed as this. */ + spirePubkey: string + /** `bunker://?relay=&secret=` — handed to nostr-tools parseBunkerInput. */ + bunkerUrl: string + /** Relays where the spire publishes its own events (kind 21000 / 30078). */ + relays: string[] +} + +const HEX64 = /^[0-9a-f]{64}$/ + +/** Decode an unpadded base64url string in both browser and Node. */ +function base64urlDecode(input: string): string { + const padded = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(input.length / 4) * 4, '=') + if (typeof atob !== 'undefined') { + return atob(padded) + } + return Buffer.from(padded, 'base64').toString('binary') +} + +/** + * 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 + * half-understood bunker. + */ +export function parseSpireSeed(seedUrl: string): SpireSeed { + if (typeof seedUrl !== 'string' || !seedUrl.startsWith(SPIRE_SEED_SCHEME)) { + throw new Error(`parseSpireSeed: not a ${SPIRE_SEED_SCHEME} URL`) + } + + const payload = seedUrl.slice(SPIRE_SEED_SCHEME.length) + let raw: unknown + try { + raw = JSON.parse(base64urlDecode(payload)) + } catch (err) { + throw new Error(`parseSpireSeed: undecodable payload (${(err as Error).message})`) + } + + if (!raw || typeof raw !== 'object') { + throw new Error('parseSpireSeed: payload is not an object') + } + const obj = raw as Record + + if (obj.v !== 1) { + throw new Error(`parseSpireSeed: unsupported version ${String(obj.v)}`) + } + + const spirePubkey = obj.spire_pubkey + if (typeof spirePubkey !== 'string' || !HEX64.test(spirePubkey)) { + throw new Error('parseSpireSeed: spire_pubkey must be 64-char hex') + } + + const bunkerUrl = obj.bunker_url + if (typeof bunkerUrl !== 'string' || !bunkerUrl.startsWith('bunker://')) { + throw new Error('parseSpireSeed: bunker_url must be a bunker:// URL') + } + + const relays = obj.relays + if (!Array.isArray(relays) || relays.length === 0 || !relays.every((r) => typeof r === 'string')) { + throw new Error('parseSpireSeed: relays must be a non-empty string array') + } + + return { v: 1, spirePubkey, bunkerUrl, relays: relays as string[] } +} + +/** + * Stable fingerprint of a seed URL, used to detect a re-pair (operator/relay + * change). A different seed ⇒ a different fingerprint ⇒ the ATM re-binds and + * resets its bootstrap gate (aiolabs/bitspire#56). + */ +export function seedFingerprint(seedUrl: string): string { + return bytesToHex(sha256(new TextEncoder().encode(seedUrl))) +} From 2b8e951de55172597bf7b587f9fa1b2c483d687f Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 18 Jun 2026 23:24:32 +0200 Subject: [PATCH 07/82] feat(machine): persist NIP-46 bunker binding (state.db schema v11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a bunker_binding singleton table + get/save/clearBunkerBinding accessors holding the ATM's own NIP-46 transport key (client_nsec), the spire signing pubkey, the bunker URL, and the seed fingerprint. Persisted so a restart resumes the bunker session without re-redeeming the one-shot connect secret; a changed fingerprint signals a re-pair. The v10→v11 migration is idempotent (CREATE TABLE IF NOT EXISTS), and the v9→v10 block now advances existing.value so a v9 install chains straight through to v11 in one boot (matching the v6→v8 blocks). Phase B of aiolabs/bitspire#52. The IPC bridge + bootstrap resolution that consume these accessors land in Phase C. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/machine/electron/state-store.ts | 104 ++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 1 deletion(-) diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index fcf83e5..8705aec 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -15,7 +15,7 @@ import fs from 'node:fs' let db: Database.Database | null = null -const SCHEMA_VERSION = '10' +const SCHEMA_VERSION = '11' function getDbPath(): string { const prodDir = '/var/lib/bitspire' @@ -114,6 +114,15 @@ export function initDatabase(dbPath?: string): void { event_created_at INTEGER NOT NULL, applied_at INTEGER NOT NULL ); + + CREATE TABLE IF NOT EXISTS bunker_binding ( + id INTEGER PRIMARY KEY CHECK (id = 1), + client_secret_hex TEXT NOT NULL, + spire_pubkey TEXT NOT NULL, + bunker_url TEXT NOT NULL, + seed_fingerprint TEXT NOT NULL, + paired_at INTEGER NOT NULL + ); `) // Seed meta + cashbox if first run, or run migrations @@ -320,6 +329,29 @@ export function initDatabase(dbPath?: string): void { ) db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('10', 'schema_version') console.log('[StateStore] Migrated schema v9 → v10 (added fee_config + watermark)') + existing.value = '10' + } + + if (existing && existing.value === '10') { + // Migration v10 → v11: NIP-46 bunker binding (aiolabs/bitspire#52). + // - bunker_binding singleton — the ATM's own NIP-46 transport key + // (client_nsec) plus the spire signing identity, bunker URL, and a + // fingerprint of the seed it was paired from. Persisted so a restart + // resumes the bunker session without re-redeeming the one-shot connect + // secret. A new/changed seed_fingerprint signals a re-pair (which also + // resets bootstrapPublishedAt — see lightning.ts / bitspire#56). + db.exec(` + CREATE TABLE IF NOT EXISTS bunker_binding ( + id INTEGER PRIMARY KEY CHECK (id = 1), + client_secret_hex TEXT NOT NULL, + spire_pubkey TEXT NOT NULL, + bunker_url TEXT NOT NULL, + seed_fingerprint TEXT NOT NULL, + paired_at INTEGER NOT NULL + ); + `) + db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('11', 'schema_version') + console.log('[StateStore] Migrated schema v10 → v11 (added bunker_binding)') } // Defensive: a fresh install at SCHEMA_VERSION skips all migrations. @@ -381,6 +413,76 @@ export function markBootstrapPublished(unixTimestamp: number): void { ) } +// --------------------------------------------------------------------------- +// Bunker binding — NIP-46 transport key + spire identity (aiolabs/bitspire#52) +// --------------------------------------------------------------------------- + +export interface StoredBunkerBinding { + /** The ATM's own NIP-46 transport secret key (`client_nsec`), hex. */ + clientSecretHex: string + /** The spire's signing pubkey (hex) — the identity events are signed as. */ + spirePubkey: string + /** `bunker://…` URL, re-parsed into a pointer on resume. */ + bunkerUrl: string + /** Fingerprint of the seed this binding was paired from (re-pair detection). */ + seedFingerprint: string + /** Unix seconds when the pairing was redeemed. */ + pairedAt: number +} + +/** Read the persisted bunker binding, or null if the ATM is unpaired. */ +export function getBunkerBinding(): StoredBunkerBinding | null { + if (!db) throw new Error('Database not initialized') + const row = db + .prepare( + 'SELECT client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at FROM bunker_binding WHERE id = 1' + ) + .get() as + | { + client_secret_hex: string + spire_pubkey: string + bunker_url: string + seed_fingerprint: string + paired_at: number + } + | undefined + if (!row) return null + return { + clientSecretHex: row.client_secret_hex, + spirePubkey: row.spire_pubkey, + bunkerUrl: row.bunker_url, + seedFingerprint: row.seed_fingerprint, + pairedAt: row.paired_at, + } +} + +/** Upsert the bunker binding after a successful (re-)pairing. */ +export function saveBunkerBinding(binding: StoredBunkerBinding): void { + if (!db) throw new Error('Database not initialized') + db.prepare( + `INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at) + VALUES (1, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + client_secret_hex = excluded.client_secret_hex, + spire_pubkey = excluded.spire_pubkey, + bunker_url = excluded.bunker_url, + seed_fingerprint = excluded.seed_fingerprint, + paired_at = excluded.paired_at` + ).run( + binding.clientSecretHex, + binding.spirePubkey, + binding.bunkerUrl, + binding.seedFingerprint, + binding.pairedAt + ) +} + +/** Drop the bunker binding (e.g. after an operator revoke → force re-pair). */ +export function clearBunkerBinding(): void { + if (!db) throw new Error('Database not initialized') + db.prepare('DELETE FROM bunker_binding WHERE id = 1').run() +} + export type OperatorCassettesPayload = { positions: Record } From 40239aa0758c8441e37d422ef73b922148e30d12 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 00:15:17 +0200 Subject: [PATCH 08/82] refactor(clink): route CLINK signing + encryption through the Signer Swap CLINKClient's MachineIdentity for the Signer abstraction: sign_event / nip44 now go through the signer (async), so the spire identity can live in a NIP-46 bunker. The kind-21003 management path (operator-driven manual dispense, the one live CLINK path on dev) decrypts as the spire via the bunker; the dormant offer/debit paths are migrated too so they're bunker-ready when CLINK is re-implemented for the upcoming ndebit/k1 spec (shocknet/CLINK#7, #8). Part of Phase C, aiolabs/bitspire#52. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/clink/src/client.ts | 91 ++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 50 deletions(-) diff --git a/packages/clink/src/client.ts b/packages/clink/src/client.ts index 333461f..e2b2e88 100644 --- a/packages/clink/src/client.ts +++ b/packages/clink/src/client.ts @@ -10,34 +10,30 @@ * Uses NIP-44v2 encryption for all messages. */ -import type { Event, UnsignedEvent } from 'nostr-tools' -import { finalizeEvent } from 'nostr-tools' -import type { MachineIdentity, NostrClient } from '@bitSpire/nostr-client' -import { encryptContentV2, decryptContentV2 } from '@bitSpire/nostr-client' +import type { Event, EventTemplate } from 'nostr-tools' +import type { NostrClient, Signer } from '@bitSpire/nostr-client' /** CLINK protocol version tag (mandatory per CLINK spec) */ const CLINK_VERSION_TAG: [string, string] = ['clink_version', '1'] /** - * Encrypt content using NIP-44 v2 (required for CLINK events) + * Encrypt content using NIP-44 v2 (required for CLINK events). + * Routes through the Signer so the spire identity can live in a bunker. */ -function encryptCLINK( - identity: MachineIdentity, - recipientPubkey: string, - content: unknown -): string { - return encryptContentV2(identity, recipientPubkey, content) +function encryptCLINK(signer: Signer, recipientPubkey: string, content: unknown): Promise { + const plaintext = typeof content === 'string' ? content : JSON.stringify(content) + return signer.nip44Encrypt(recipientPubkey, plaintext) } /** - * Decrypt and parse JSON content using NIP-44 v2 + * Decrypt and parse JSON content using NIP-44 v2. */ -function decryptCLINKJSON( - identity: MachineIdentity, +async function decryptCLINKJSON( + signer: Signer, senderPubkey: string, ciphertext: string -): T { - const plaintext = decryptContentV2(identity, senderPubkey, ciphertext) +): Promise { + const plaintext = await signer.nip44Decrypt(senderPubkey, ciphertext) return JSON.parse(plaintext) as T } import { @@ -67,8 +63,8 @@ import { encodeNoffer, decodeNoffer } from './noffer.js' export interface CLINKClientOptions { /** Nostr client for communication */ nostrClient: NostrClient - /** Machine identity */ - identity: MachineIdentity + /** Signer for the spire identity (local nsec or remote bunker) */ + signer: Signer /** Operator pubkey(s) for management commands */ operatorPubkey: string | string[] /** Relays to use for offers */ @@ -102,7 +98,7 @@ export type ManagementHandler = ( */ export class CLINKClient { private nostrClient: NostrClient - private identity: MachineIdentity + private signer: Signer private operatorPubkeys: string[] private relays: string[] private generateInvoice?: GenerateInvoice @@ -120,7 +116,7 @@ export class CLINKClient { constructor(options: CLINKClientOptions) { this.nostrClient = options.nostrClient - this.identity = options.identity + this.signer = options.signer this.operatorPubkeys = Array.isArray(options.operatorPubkey) ? options.operatorPubkey : [options.operatorPubkey] @@ -144,7 +140,7 @@ export class CLINKClient { currency?: string }): string { const offer: CLINKOffer = { - pubkey: this.identity.publicKey, + pubkey: this.signer.pubkey, relays: this.relays, priceType: options.priceType, offerId: options.offerId, @@ -193,7 +189,7 @@ export class CLINKClient { [ { kinds: [CLINKEventKind.Offer, CLINKEventKind.Debit, CLINKEventKind.Manage], - '#p': [this.identity.publicKey], + '#p': [this.signer.pubkey], }, ], { @@ -234,9 +230,9 @@ export class CLINKClient { expires_in_seconds: options?.expiresInSeconds, } - const content = encryptCLINK(this.identity, offer.pubkey, request) + const content = await encryptCLINK(this.signer, offer.pubkey, request) - const event = this.createSignedEvent({ + const event = await this.createSignedEvent({ kind: CLINKEventKind.Offer, content, tags: [['p', offer.pubkey], CLINK_VERSION_TAG], @@ -269,9 +265,9 @@ export class CLINKClient { description: options?.description, } - const content = encryptCLINK(this.identity, targetPubkey, request) + const content = await encryptCLINK(this.signer, targetPubkey, request) - const event = this.createSignedEvent({ + const event = await this.createSignedEvent({ kind: CLINKEventKind.Debit, content, tags: [['p', targetPubkey], CLINK_VERSION_TAG], @@ -303,9 +299,9 @@ export class CLINKClient { description: options?.description, } - const content = encryptCLINK(this.identity, targetPubkey, request) + const content = await encryptCLINK(this.signer, targetPubkey, request) - const event = this.createSignedEvent({ + const event = await this.createSignedEvent({ kind: CLINKEventKind.Debit, content, tags: [['p', targetPubkey], CLINK_VERSION_TAG], @@ -324,9 +320,9 @@ export class CLINKClient { targetPubkey: string, request: ManagementRequest ): Promise { - const content = encryptCLINK(this.identity, targetPubkey, request) + const content = await encryptCLINK(this.signer, targetPubkey, request) - const event = this.createSignedEvent({ + const event = await this.createSignedEvent({ kind: CLINKEventKind.Manage, content, tags: [['p', targetPubkey], CLINK_VERSION_TAG], @@ -394,15 +390,15 @@ export class CLINKClient { return } - const request = decryptCLINKJSON(this.identity, event.pubkey, event.content) + const request = await decryptCLINKJSON(this.signer, event.pubkey, event.content) const response = await this.offerHandler(request, event.pubkey) if (!response) return // Send encrypted response with clink_version tag - const content = encryptCLINK(this.identity, event.pubkey, response) + const content = await encryptCLINK(this.signer, event.pubkey, response) - const responseEvent = this.createSignedEvent({ + const responseEvent = await this.createSignedEvent({ kind: CLINKEventKind.Offer, content, tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG], @@ -425,14 +421,14 @@ export class CLINKClient { return } - const request = decryptCLINKJSON(this.identity, event.pubkey, event.content) + const request = await decryptCLINKJSON(this.signer, event.pubkey, event.content) const response = await this.debitHandler(request, event.pubkey) // Send encrypted response with clink_version tag - const content = encryptCLINK(this.identity, event.pubkey, response) + const content = await encryptCLINK(this.signer, event.pubkey, response) - const responseEvent = this.createSignedEvent({ + const responseEvent = await this.createSignedEvent({ kind: CLINKEventKind.Debit, content, tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG], @@ -491,15 +487,15 @@ export class CLINKClient { if (first) this.processedManageEvents.delete(first) } - const request = decryptCLINKJSON(this.identity, event.pubkey, event.content) + const request = await decryptCLINKJSON(this.signer, event.pubkey, event.content) const response = await this.managementHandler(request, event.pubkey) if (!response) return // Send encrypted response with clink_version tag - const content = encryptCLINK(this.identity, event.pubkey, response) + const content = await encryptCLINK(this.signer, event.pubkey, response) - const responseEvent = this.createSignedEvent({ + const responseEvent = await this.createSignedEvent({ kind: CLINKEventKind.Manage, content, tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG], @@ -524,7 +520,7 @@ export class CLINKClient { { kinds: [kind], authors: [fromPubkey], - '#p': [this.identity.publicKey], + '#p': [this.signer.pubkey], '#e': [requestEventId], since: Math.floor(Date.now() / 1000) - 5, }, @@ -540,12 +536,7 @@ export class CLINKClient { clearTimeout(timeout) this.nostrClient.unsubscribe(subId) - try { - const response = decryptCLINKJSON(this.identity, fromPubkey, event.content) - resolve(response) - } catch (e) { - reject(e) - } + decryptCLINKJSON(this.signer, fromPubkey, event.content).then(resolve).catch(reject) }, } ) @@ -553,11 +544,11 @@ export class CLINKClient { } /** - * Create a signed event + * Create a signed event via the signer (sets pubkey/id/sig). Async because + * a BunkerSigner is a relay round-trip. */ - private createSignedEvent(event: Omit): Event { - // finalizeEvent derives pubkey from the secret key - return finalizeEvent(event, this.identity.privateKey) + private createSignedEvent(template: EventTemplate): Promise { + return this.signer.signEvent(template) } } From 209e4c3e20e1fa1f8eaf64be90db742fc1294388 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 00:15:31 +0200 Subject: [PATCH 09/82] feat(machine): seed + bunker-binding IPC bridge get-atm-secrets now returns { spireSeed, bunkerBinding } instead of the raw nsec (one-shot semantics kept). Adds IPC handlers + preload bindings for saveBunkerBinding / clearBunkerBinding / resetBootstrapGate so the renderer can persist a pairing and re-arm the cassette-state hello on re-pair (#56). resetBootstrapGate added to state-store. Types mirrored in electron.d.ts. Part of Phase C, aiolabs/bitspire#52. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/machine/electron/main.ts | 26 ++++++++++++++++++++++++-- apps/machine/electron/preload.ts | 28 +++++++++++++++++++++++++--- apps/machine/electron/state-store.ts | 10 ++++++++++ apps/machine/src/types/electron.d.ts | 19 ++++++++++++++++--- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index fb5c84b..c35382d 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -26,14 +26,19 @@ import { getLastKnownConfigCreatedAt, getBootstrapPublishedAt, markBootstrapPublished, + resetBootstrapGate, applyOperatorCassettesConfig, getFeeConfig, getLastKnownFeeConfigCreatedAt, applyFeeConfig, + getBunkerBinding, + saveBunkerBinding, + clearBunkerBinding, type OperatorCassettesPayload, type FeeConfigPayload, type FeeConfigRow, type ApplyResult, + type StoredBunkerBinding, } from './state-store.js' import { initializeHal, type HalInstance } from './hal-service.js' @@ -323,14 +328,31 @@ let secretsConsumed = false ipcMain.handle('get-atm-secrets', () => { if (secretsConsumed) { console.warn('[Electron] SECURITY: get-atm-secrets called after secrets already consumed') - return { atmPrivateKey: '' } + return { spireSeed: '', bunkerBinding: null } } secretsConsumed = true + // The spire pairing seed (one-shot connect token inside) + the persisted + // bunker binding (transport key). The renderer resolves these into a + // BunkerSigner; see services/signer-resolver.ts (aiolabs/bitspire#52). return { - atmPrivateKey: process.env.VITE_ATM_PRIVATE_KEY || '', + spireSeed: process.env.VITE_SPIRE_SEED || '', + bunkerBinding: getBunkerBinding(), } }) +// Bunker binding persistence — the renderer writes the binding after a +// successful pairing (connectNewSeed), and resets the bootstrap gate so the +// new operator receives the spire's hello-event (aiolabs/bitspire#52 / #56). +ipcMain.handle('state:save-bunker-binding', (_event, binding: StoredBunkerBinding): void => { + saveBunkerBinding(binding) +}) +ipcMain.handle('state:clear-bunker-binding', (): void => { + clearBunkerBinding() +}) +ipcMain.handle('state:reset-bootstrap-gate', (): void => { + resetBootstrapGate() +}) + // State persistence IPC handlers ipcMain.handle('state:load-cassettes', () => loadCassettes()) ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 836b98d..f439de9 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -42,13 +42,25 @@ export interface BrandingConfig { logoDarkDataUrl: string | null } +/** + * Persisted NIP-46 bunker binding (mirror of state-store's StoredBunkerBinding). + */ +export interface BunkerBindingRecord { + clientSecretHex: string + spirePubkey: string + bunkerUrl: string + seedFingerprint: string + pairedAt: number +} + /** * ATM secrets — returned once by getAtmSecrets(), then empty on subsequent calls. + * The spire pairing seed (carries the one-shot connect token) plus the persisted + * bunker binding; the renderer resolves these into a signer. */ export interface AtmSecrets { - atmPrivateKey: string - /** Legacy LP admin token — retained until 3d removes the LP backend. */ - adminToken?: string + spireSeed: string + bunkerBinding: BunkerBindingRecord | null } // Expose protected methods to renderer @@ -100,6 +112,13 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('state:get-bootstrap-published-at'), markBootstrapPublished: (unixTimestamp: number): Promise => ipcRenderer.invoke('state:mark-bootstrap-published', unixTimestamp), + + // Bunker binding persistence (aiolabs/bitspire#52) + saveBunkerBinding: (binding: BunkerBindingRecord): Promise => + ipcRenderer.invoke('state:save-bunker-binding', binding), + clearBunkerBinding: (): Promise => ipcRenderer.invoke('state:clear-bunker-binding'), + resetBootstrapGate: (): Promise => ipcRenderer.invoke('state:reset-bootstrap-gate'), + applyOperatorCassettesConfig: ( payload: { positions: Record @@ -212,6 +231,9 @@ declare global { getLastKnownConfigCreatedAt: () => Promise getBootstrapPublishedAt: () => Promise markBootstrapPublished: (unixTimestamp: number) => Promise + saveBunkerBinding: (binding: BunkerBindingRecord) => Promise + clearBunkerBinding: () => Promise + resetBootstrapGate: () => Promise applyOperatorCassettesConfig: ( payload: { positions: Record }, eventCreatedAt: number diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index 8705aec..d9282ac 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -483,6 +483,16 @@ export function clearBunkerBinding(): void { db.prepare('DELETE FROM bunker_binding WHERE id = 1').run() } +/** + * Reset the bootstrap-publish gate so the ATM re-publishes its + * `bitspire-cassettes-state` hello-event. Called on a re-pair (new seed) so + * the new operator receives the spire's current state (aiolabs/bitspire#56). + */ +export function resetBootstrapGate(): void { + if (!db) throw new Error('Database not initialized') + db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('', 'bootstrapPublishedAt') +} + export type OperatorCassettesPayload = { positions: Record } diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 7830360..17fe9af 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -39,10 +39,20 @@ export interface BrandingConfig { logoDarkDataUrl: string | null } +/** Persisted NIP-46 bunker binding (mirror of state-store's StoredBunkerBinding). */ +export interface BunkerBindingRecord { + clientSecretHex: string + spirePubkey: string + bunkerUrl: string + seedFingerprint: string + pairedAt: number +} + export interface AtmSecrets { - atmPrivateKey: string - /** Legacy LP admin token — retained until 3d removes the LP backend. */ - adminToken?: string + /** Spire pairing seed URL (`spire-seed:v1:…`); carries the one-shot connect token. */ + spireSeed: string + /** Persisted bunker binding, or null when the ATM is unpaired. */ + bunkerBinding: BunkerBindingRecord | null } declare global { @@ -85,6 +95,9 @@ declare global { getLastKnownConfigCreatedAt: () => Promise getBootstrapPublishedAt: () => Promise markBootstrapPublished: (unixTimestamp: number) => Promise + saveBunkerBinding: (binding: BunkerBindingRecord) => Promise + clearBunkerBinding: () => Promise + resetBootstrapGate: () => Promise applyOperatorCassettesConfig: ( payload: { positions: Record }, eventCreatedAt: number From 82a9e79d0ec5d2ebba33d345cfb610e3ea531207 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 00:15:45 +0200 Subject: [PATCH 10/82] feat(machine): resolve signer from spire seed / bunker binding at bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New signer-resolver.ts turns the ATM's pairing state into a Signer: - seed present, fingerprint differs from stored binding → pair: generate a transport key, redeem the one-shot connect secret, persist the binding, reset the bootstrap gate (re-publish hello to the new operator, #56); - seed matches binding, or binding-only → resume (no re-redeem); - neither → ephemeral LocalSigner (dev) or throw (strict/prod). lightning.ts drops the atmPrivateKey plumbing and calls resolveSigner; the Phase-A Signer seam means nothing downstream changes. App.vue's maintenance beacon resolves the same way (best-effort, skips if unpaired). Part of Phase C, aiolabs/bitspire#52. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/machine/src/App.vue | 13 +-- apps/machine/src/services/lightning.ts | 56 +++------ apps/machine/src/services/signer-resolver.ts | 116 +++++++++++++++++++ 3 files changed, 138 insertions(+), 47 deletions(-) create mode 100644 apps/machine/src/services/signer-resolver.ts diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index 37882ba..652f708 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -51,14 +51,13 @@ onMounted(async () => { atmStore.initError = 'maintenance' // Publish maintenance beacon — minimal Nostr connection only (no Lightning.Pub) try { - const { NostrClient, LocalSigner, loadIdentityFromHex, createSignedEvent } = await import( - '@bitSpire/nostr-client' - ) - const secrets = isElectron ? await window.electronAPI?.getAtmSecrets() : null - const privKey = secrets?.atmPrivateKey || import.meta.env.VITE_ATM_PRIVATE_KEY + const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client') + const { resolveSigner } = await import('@/services/signer-resolver') const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL - if (privKey && relayUrl) { - const signer = new LocalSigner(loadIdentityFromHex(privKey)) + // Best-effort: resolve a signer (bunker resume / pairing, or dev nsec). + // If the ATM isn't paired yet, skip the beacon rather than fail the screen. + const signer = await resolveSigner({ allowEphemeral: true }).catch(() => null) + if (signer && relayUrl) { const client = new NostrClient({ relays: [{ url: relayUrl }], signer }) await client.connect() const publishBeacon = async () => { diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 8154d4b..e975277 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -12,14 +12,8 @@ * the customer's invoice. */ -import { - NostrClient, - LocalSigner, - generateIdentity, - loadIdentityFromHex, - type Signer, - type MachineIdentity, -} from '@bitSpire/nostr-client' +import { NostrClient, type Signer } from '@bitSpire/nostr-client' +import { resolveSigner } from './signer-resolver.js' import { LnbitsClient } from '@bitSpire/lnbits' import { CLINKClient } from '@bitSpire/clink' import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink' @@ -39,14 +33,12 @@ const isElectron = typeof window !== 'undefined' && window.electronAPI !== undef * * Environment variables: * - VITE_RELAY_URL: Nostr relay WebSocket URL - * - VITE_LIGHTNING_PUB_PUBKEY: Lightning.Pub's Nostr pubkey (hex or npub) - * - VITE_LIGHTNING_PUB_API_URL: Lightning.Pub HTTP API URL - * - VITE_ATM_PRIVATE_KEY: ATM's Nostr private key (hex or nsec) // pragma: allowlist secret - * - VITE_ADMIN_TOKEN: Lightning.Pub admin token (dev only) + * - VITE_LNBITS_SERVER_PUBKEY: LNbits nostr-transport server pubkey (hex) + * - VITE_SPIRE_SEED: spire pairing seed (NIP-46 bunker); see signer-resolver.ts + * - VITE_OPERATOR_PUBKEYS: comma-separated operator pubkeys (hex) */ interface LightningConfig { relayUrl: string - atmPrivateKey: string appId: string operatorPubkeys: string[] /** LNbits nostr-transport server pubkey (hex, 64 chars). */ @@ -63,7 +55,6 @@ interface LightningConfig { async function loadLightningConfig(): Promise { const defaults: LightningConfig = { relayUrl: 'ws://localhost:7777', - atmPrivateKey: '', appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID operatorPubkeys: [], lnbitsServerPubkey: '', @@ -72,10 +63,8 @@ async function loadLightningConfig(): Promise { if (isElectron && window.electronAPI) { try { const rc = await window.electronAPI.getConfig() - const sec = await window.electronAPI.getAtmSecrets() return { relayUrl: rc.relayUrl || defaults.relayUrl, - atmPrivateKey: sec.atmPrivateKey || defaults.atmPrivateKey, appId: rc.appId || defaults.appId, operatorPubkeys: rc.operatorPubkeys ? rc.operatorPubkeys @@ -92,7 +81,6 @@ async function loadLightningConfig(): Promise { return { relayUrl: import.meta.env.VITE_RELAY_URL || defaults.relayUrl, - atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey, appId: import.meta.env.VITE_APP_ID || defaults.appId, lnbitsServerPubkey: (import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) || @@ -416,15 +404,14 @@ export async function initializeLightningServices(options?: { console.log('[Lightning] Relay URL:', CONFIG.relayUrl) console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)') - // Strict mode: validate config is production-ready (no localhost, no ephemeral identity) + // Strict mode: validate config is production-ready (no localhost). The + // signing-identity check (a bunker pairing must exist) is enforced by + // resolveSigner below via allowEphemeral=false. if (options?.strict) { const errors: string[] = [] if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) { errors.push('VITE_RELAY_URL contains localhost') } - if (!CONFIG.atmPrivateKey) { - errors.push('VITE_ATM_PRIVATE_KEY is not set (ephemeral identity not allowed in production)') - } if (!CONFIG.lnbitsServerPubkey) { errors.push('VITE_LNBITS_SERVER_PUBKEY is not set') } @@ -441,22 +428,13 @@ export async function initializeLightningServices(options?: { ) } - // Load or generate ATM identity - let identity: MachineIdentity - if (CONFIG.atmPrivateKey) { - identity = loadIdentityFromHex(CONFIG.atmPrivateKey) - console.log('[Lightning] Loaded ATM identity from config') - } else { - identity = generateIdentity() - console.warn('[Lightning] No VITE_ATM_PRIVATE_KEY configured - generated ephemeral identity') - console.warn('[Lightning] Set VITE_ATM_PRIVATE_KEY for persistent identity across restarts') - } - console.log('[Lightning] ATM pubkey:', identity.publicKey) - - // Wrap the identity in a signer. Phase A always uses LocalSigner (in-process - // nsec); Phase B swaps in a BunkerSigner here without touching the call - // sites below. See aiolabs/bitspire#52. - const signer: Signer = new LocalSigner(identity) + // Resolve the signing identity. In production this is a BunkerSigner over + // NIP-46 (the ATM holds only a transport key; the operator's nsecbunkerd + // holds the signing key); in dev it falls back to an in-process LocalSigner. + // The Phase-A Signer seam means nothing downstream changes. See + // aiolabs/bitspire#52. + const signer: Signer = await resolveSigner({ allowEphemeral: !options?.strict }) + console.log('[Lightning] ATM pubkey:', signer.pubkey) // Create Nostr client const nostrClient = new NostrClient({ @@ -491,7 +469,7 @@ export async function initializeLightningServices(options?: { // commands; it has no Lightning.Pub dependency. const clink = new CLINKClient({ nostrClient, - identity, + signer, operatorPubkey: CONFIG.operatorPubkeys, relays: [CONFIG.relayUrl], }) @@ -581,7 +559,6 @@ export async function initializeLightningServices(options?: { } const atmServices = createATMServices( - identity, (preimage) => { if (paymentReceivedCallback) { paymentReceivedCallback(preimage) @@ -624,7 +601,6 @@ export async function initializeLightningServices(options?: { * Create ATMServices implementation using the LNbits nostr-transport. */ function createATMServices( - _identity: MachineIdentity, onPaymentSuccess: (preimage: string) => void, lnbits: LnbitsClient, lnbitsWalletId: string, diff --git a/apps/machine/src/services/signer-resolver.ts b/apps/machine/src/services/signer-resolver.ts new file mode 100644 index 0000000..9bd0d5b --- /dev/null +++ b/apps/machine/src/services/signer-resolver.ts @@ -0,0 +1,116 @@ +/** + * Signer resolution — turns the ATM's pairing state into a live `Signer`. + * + * Three outcomes, in priority order (aiolabs/bitspire#52, model A1): + * 1. A seed is present whose fingerprint differs from the stored binding + * (first pair or re-pair) → generate a fresh NIP-46 transport key, redeem + * the one-shot connect secret, persist the binding, and reset the + * bootstrap gate so the (possibly new) operator gets a hello-event (#56). + * 2. A seed is present matching the stored binding, OR no seed but a stored + * binding exists → resume the bunker session with the persisted transport + * key (no re-redeem — the binding is server-persistent). + * 3. Neither → ephemeral LocalSigner, dev only. In strict (production) mode + * this throws instead: no pairing means no signing identity. + * + * Runs in the renderer (where the relay I/O lives); state.db reads/writes go + * through the one-shot get-atm-secrets channel + the binding IPC handlers. + */ + +import { + LocalSigner, + connectNewSeed, + resumeFromBinding, + generateClientTransportKey, + generateIdentity, + loadIdentityFromHex, + parseSpireSeed, + seedFingerprint, + type Signer, +} from '@bitSpire/nostr-client' +import type { BunkerBindingRecord } from '@/types/electron' + +const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined + +export interface ResolveSignerOptions { + /** Allow an ephemeral LocalSigner when no seed/binding exists (dev only). */ + allowEphemeral: boolean +} + +interface PairingState { + spireSeed: string + binding: BunkerBindingRecord | null +} + +/** Gather the seed + persisted binding from Electron, or env in browser dev. */ +async function loadPairingState(): Promise { + if (isElectron && window.electronAPI) { + const secrets = await window.electronAPI.getAtmSecrets() + return { spireSeed: secrets.spireSeed || '', binding: secrets.bunkerBinding ?? null } + } + return { spireSeed: (import.meta.env.VITE_SPIRE_SEED as string | undefined) || '', binding: null } +} + +export async function resolveSigner(opts: ResolveSignerOptions): Promise { + const { spireSeed, binding } = await loadPairingState() + + if (spireSeed) { + const seed = parseSpireSeed(spireSeed) + const fingerprint = seedFingerprint(spireSeed) + + if (binding && binding.seedFingerprint === fingerprint) { + console.log('[Signer] Resuming bunker session for spire', seed.spirePubkey) + return resumeFromBinding({ + clientSecretHex: binding.clientSecretHex, + spirePubkey: binding.spirePubkey, + bunkerUrl: binding.bunkerUrl, + }) + } + + // First pair or re-pair: redeem the one-shot connect secret. + console.log('[Signer] Pairing to bunker for spire', seed.spirePubkey) + const transport = generateClientTransportKey() + const signer = await connectNewSeed({ + spirePubkey: seed.spirePubkey, + bunkerUrl: seed.bunkerUrl, + clientSecretHex: transport.secretHex, + }) + if (isElectron && window.electronAPI) { + await window.electronAPI.saveBunkerBinding({ + clientSecretHex: transport.secretHex, + spirePubkey: seed.spirePubkey, + bunkerUrl: seed.bunkerUrl, + seedFingerprint: fingerprint, + pairedAt: Math.floor(Date.now() / 1000), + }) + // Re-pair → re-publish the cassette-state hello to the new operator (#56). + await window.electronAPI.resetBootstrapGate() + } + return signer + } + + // No seed in this boot but a binding survives → resume. + if (binding) { + console.log('[Signer] Resuming bunker session from stored binding (no seed this boot)') + return resumeFromBinding({ + clientSecretHex: binding.clientSecretHex, + spirePubkey: binding.spirePubkey, + bunkerUrl: binding.bunkerUrl, + }) + } + + if (opts.allowEphemeral) { + // Dev-only: a hex key gives a stable dev identity; otherwise ephemeral. + const devKey = !isElectron ? (import.meta.env.VITE_ATM_PRIVATE_KEY as string | undefined) : '' + if (devKey) { + console.warn('[Signer] No bunker pairing — using LocalSigner from VITE_ATM_PRIVATE_KEY (dev)') + return new LocalSigner(loadIdentityFromHex(devKey)) + } + console.warn('[Signer] No bunker pairing — generated ephemeral LocalSigner (dev only)') + return new LocalSigner(generateIdentity()) + } + + throw new Error( + '[Signer] No spire seed and no bunker binding — cannot resolve a signing identity (strict mode). ' + + 'Set VITE_SPIRE_SEED or pair the ATM.' + ) +} From 0391dbaeb029fad707d2632cfb26b15c32623ddf Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 00:15:59 +0200 Subject: [PATCH 11/82] chore(machine): fund-atm resumes from binding; VITE_SPIRE_SEED docs/env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fund-atm resolves its signer by resuming the bunker binding from state.db (the connect token is already spent by the main app, so it can't re-pair); falls back to a dev nsec via VITE_ATM_PRIVATE_KEY. better-sqlite3 marked external in the esbuild bundle. .env.example + CLAUDE.md document VITE_SPIRE_SEED as the prod identity, VITE_ATM_PRIVATE_KEY as dev-only. (fund-atm is slated for deprecation in favour of the operator funding the wallet directly via the LNbits UI — kept working for now.) Part of Phase C, aiolabs/bitspire#52. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 5 +++-- apps/machine/.env.example | 19 +++++++++++------ apps/machine/electron/fund-atm.ts | 34 +++++++++++++++++++++++++++---- apps/machine/package.json | 2 +- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 20ba0df..4f86331 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,8 @@ Renderer reads (Electron IPC or Vite `import.meta.env`): |---|---|---| | `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) | | `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) | -| `VITE_ATM_PRIVATE_KEY` | yes (prod) | 64-char hex. The ATM's nostr identity. Generates ephemeral on first boot if unset (dev only) | +| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:`) 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. See aiolabs/bitspire#52. | +| `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. | | `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands | The LP-era vars (`VITE_LIGHTNING_PUB_PUBKEY`, `VITE_LIGHTNING_PUB_API_URL`, `VITE_EXTENSION_API_URL`, `VITE_ADMIN_TOKEN`) are gone from the dev branch's `.env.example` and `LightningConfig` interface. @@ -188,7 +189,7 @@ UP Board enumerates its eMMC controller via ACPI, not PCI. `upboard.nix` force-l ## Security priorities -1. **Private keys** — Never log nsec. The ATM's `VITE_ATM_PRIVATE_KEY` lives in `/var/lib/bitspire/.env` with mode 0600, owned by `bitspire:bitspire`. +1. **Private keys** — Never log nsec. In production the ATM holds no signing nsec: `VITE_SPIRE_SEED` (in `/var/lib/bitspire/.env`, mode 0600) carries a one-shot connect token, and the ATM's own NIP-46 *transport* key (`client_secret_hex`) lives in `state.db` (`bunker_binding`). The operator's signing key stays in the bunker. The legacy `VITE_ATM_PRIVATE_KEY` is a dev-only fallback. 2. **Payments** — Validate the bolt11 amount on cash-out before exposing the QR. Decode `payment_hash` from the bolt11 (cheap, avoids a roundtrip) and use it as the `subscribe_payments` filter. 3. **Replay** — LNURL-withdraw links use `uses:1` and are deleted on session abort. 4. **Encryption** — All RPC content is NIP-44 v2. NIP-04 is forbidden. diff --git a/apps/machine/.env.example b/apps/machine/.env.example index 73b6281..e691e46 100644 --- a/apps/machine/.env.example +++ b/apps/machine/.env.example @@ -36,16 +36,23 @@ VITE_LNBITS_SERVER_PUBKEY= # aiolabs/withdraw#1 / commit e9d911e.) # ============================================================================= -# ATM Identity +# ATM Identity — spire pairing seed (NIP-46 bunker; aiolabs/bitspire#52) # ============================================================================= +# The spire pairing seed produced by the operator dashboard (spirekeeper): +# spire-seed:v1: +# It carries a one-shot NIP-46 connect token + the spire's signing pubkey + +# the bunker URL. On first boot the ATM redeems the token, generates its own +# transport key, and persists the binding to state.db; thereafter it resumes +# from the binding (the seed can stay set — it's matched by fingerprint). +# A changed seed re-pairs (and re-publishes the cassette-state hello). +VITE_SPIRE_SEED= + # pragma: allowlist secret -# ATM's Nostr private key (hex format, 64 characters). This signing -# key IS the credential — LNbits derives the account from it on first -# contact (issue aiolabs/lnbits#9 alignment). +# DEV ONLY fallback — a raw Nostr private key (hex, 64 chars) for running +# without a bunker. Ignored when VITE_SPIRE_SEED or a stored binding exists. # Generate with: openssl rand -hex 32 -# If not set, generates ephemeral identity on each restart (dev only). -VITE_ATM_PRIVATE_KEY= +# VITE_ATM_PRIVATE_KEY= # ============================================================================= # Operator Identity diff --git a/apps/machine/electron/fund-atm.ts b/apps/machine/electron/fund-atm.ts index bce6b4c..cd0f901 100644 --- a/apps/machine/electron/fund-atm.ts +++ b/apps/machine/electron/fund-atm.ts @@ -13,8 +13,15 @@ */ import { readFileSync } from 'node:fs' -import { NostrClient, LocalSigner, loadIdentityFromHex } from '@bitSpire/nostr-client' +import { + NostrClient, + LocalSigner, + loadIdentityFromHex, + resumeFromBinding, + type Signer, +} from '@bitSpire/nostr-client' import { LnbitsClient } from '@bitSpire/lnbits' +import { initDatabase, getBunkerBinding } from './state-store.js' // @ts-ignore — qrcode is a transitive dep (via qrcode.vue), no types needed import QRCode from 'qrcode' @@ -56,15 +63,34 @@ async function main() { const lnbitsServerPubkey = env['VITE_LNBITS_SERVER_PUBKEY'] const atmPrivateKey = env['VITE_ATM_PRIVATE_KEY'] - if (!relayUrl || !lnbitsServerPubkey || !atmPrivateKey) { + if (!relayUrl || !lnbitsServerPubkey) { console.error('Missing required config in', envPath) - console.error('Need: VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY, VITE_ATM_PRIVATE_KEY') + console.error('Need: VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY') process.exit(1) } console.error(`Generating invoice for ${amountSats} sats...`) - const signer = new LocalSigner(loadIdentityFromHex(atmPrivateKey)) + // Resolve the signer. Prod: resume the bunker binding from state.db (the + // ATM's transport key — the connect token was already redeemed by the main + // app, so we can't re-pair here). Dev: a local nsec via VITE_ATM_PRIVATE_KEY. + let signer: Signer + if (atmPrivateKey) { + signer = new LocalSigner(loadIdentityFromHex(atmPrivateKey)) + } else { + initDatabase() + const binding = getBunkerBinding() + if (!binding) { + console.error('ATM is not paired (no bunker binding in state.db) and no') + console.error('VITE_ATM_PRIVATE_KEY set. Pair the ATM via the main app first.') + process.exit(1) + } + signer = await resumeFromBinding({ + clientSecretHex: binding.clientSecretHex, + spirePubkey: binding.spirePubkey, + bunkerUrl: binding.bunkerUrl, + }) + } const nostrClient = new NostrClient({ relays: [{ url: relayUrl }], diff --git a/apps/machine/package.json b/apps/machine/package.json index dfc3336..844211c 100644 --- a/apps/machine/package.json +++ b/apps/machine/package.json @@ -14,7 +14,7 @@ "dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"", "dev:vite": "vite", "electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js", - "build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --outfile=dist-electron/fund-atm.bundle.cjs", + "build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --external:better-sqlite3 --outfile=dist-electron/fund-atm.bundle.cjs", "build:electron": "pnpm build && electron-builder", "preview": "vite preview", "typecheck": "vue-tsc --noEmit", From 09ed5e95deb94fea224d88e4a1ffebee2c4af42f Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 23:19:58 +0200 Subject: [PATCH 12/82] docs(nostr-client): TTL expiry is now a post-bind deauth cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nsecbunkerd#27 enforces token lifecycle at sign time (Option D): an expired token (`expiresAt`) now stops signing post-bind, not just at connect — reversing the earlier #24 "TTL is connect-window-only" note. A lapsed TTL now surfaces as the same BunkerRejectedError as a revoke, so the Phase D re-pair handling covers both. Docstring corrected to say so. refs nsecbunkerd#27/#24/#25, aiolabs/bitspire#52 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/nostr-client/src/bunker-signer.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/nostr-client/src/bunker-signer.ts b/packages/nostr-client/src/bunker-signer.ts index ed80f02..642b570 100644 --- a/packages/nostr-client/src/bunker-signer.ts +++ b/packages/nostr-client/src/bunker-signer.ts @@ -29,9 +29,11 @@ import type { Signer } from './signer.js' const DEFAULT_BUNKER_TIMEOUT_MS = 10_000 /** - * Raised when the bunker actively rejects a request (e.g. the operator - * revoked the spire's binding, or a kind/method is outside the policy). - * Callers should treat this as "unpaired" and surface a re-pair prompt. + * Raised when the bunker actively rejects a request. Post-bind causes + * (nsecbunkerd#27, sign-time lifecycle enforcement): the operator revoked the + * binding (`KeyUser`/`Token.revokedAt`), the token's TTL (`expiresAt`) lapsed, + * or the requested kind/method is outside the policy. Callers should treat + * this as "unpaired" and surface a re-pair prompt. */ export class BunkerRejectedError extends Error { constructor(message: string) { From b0ac34ee01a70fc10311c3d21db3c2d2f5e62b49 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 23:29:50 +0200 Subject: [PATCH 13/82] feat(lnbits): typed nostr-transport error codes + retry policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the error-handling layer agreed in the 2026-05-26 cross-session handshake (aiolabs/bitspire#52). LnbitsClient now rejects ERROR responses with a typed LnbitsRpcError carrying the machine-readable code + its retry disposition, so callers (and the state machine, Phase D.3) branch on disposition rather than string-matching the human-readable message. - error-codes.ts: LnbitsErrorCode (14 codes, signer/transport/app classes) mirroring the lnbits canonical enum; retryPolicyFor() classifier; LnbitsRpcError.fromResponse(). - error_code is optional-additive on the wire: an absent or unknown code maps to internal_error (retry-once), so this is safe to land before lnbits emits codes — no string-matching, no special parser paths. - invoice_already_paid is flagged terminal-idempotent (isIdempotentSuccess) for the cash-out resume-after-reboot case. Part of Phase D, aiolabs/bitspire#52. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lnbits/src/__tests__/error-codes.test.ts | 79 ++++++++++ packages/lnbits/src/client.ts | 3 +- packages/lnbits/src/error-codes.ts | 143 ++++++++++++++++++ packages/lnbits/src/index.ts | 7 + packages/lnbits/src/types.ts | 5 + 5 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 packages/lnbits/src/__tests__/error-codes.test.ts create mode 100644 packages/lnbits/src/error-codes.ts diff --git a/packages/lnbits/src/__tests__/error-codes.test.ts b/packages/lnbits/src/__tests__/error-codes.test.ts new file mode 100644 index 0000000..7420810 --- /dev/null +++ b/packages/lnbits/src/__tests__/error-codes.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { + LnbitsErrorCode, + LnbitsRpcError, + parseErrorCode, + retryPolicyFor, +} from '../error-codes.js' + +describe('parseErrorCode', () => { + it('recognizes every canonical code', () => { + for (const code of Object.values(LnbitsErrorCode)) { + expect(parseErrorCode(code)).toBe(code) + } + }) + + it('returns null for unknown / absent codes', () => { + expect(parseErrorCode('made_up_code')).toBeNull() + expect(parseErrorCode(undefined)).toBeNull() + expect(parseErrorCode(null)).toBeNull() + expect(parseErrorCode('')).toBeNull() + }) +}) + +describe('retryPolicyFor', () => { + it('classifies the signer + transport + app codes as agreed', () => { + expect(retryPolicyFor(LnbitsErrorCode.OperatorSignerUnavailable)).toBe('retry-backoff') + expect(retryPolicyFor(LnbitsErrorCode.OperatorSignerRejected)).toBe('terminal') + expect(retryPolicyFor(LnbitsErrorCode.RateLimited)).toBe('retry-long-backoff') + expect(retryPolicyFor(LnbitsErrorCode.InternalError)).toBe('retry-once') + expect(retryPolicyFor(LnbitsErrorCode.InvoiceAlreadyPaid)).toBe('terminal-idempotent') + expect(retryPolicyFor(LnbitsErrorCode.InsufficientBalance)).toBe('terminal') + }) + + it('has a policy for every code (exhaustive map)', () => { + for (const code of Object.values(LnbitsErrorCode)) { + expect(retryPolicyFor(code)).toBeTruthy() + } + }) +}) + +describe('LnbitsRpcError.fromResponse', () => { + it('maps a known error_code through', () => { + const err = LnbitsRpcError.fromResponse('pay_invoice', { + request_id: 'pay-1', + error_code: 'insufficient_balance', + error: 'not enough sats', + }) + expect(err).toBeInstanceOf(LnbitsRpcError) + expect(err.code).toBe(LnbitsErrorCode.InsufficientBalance) + expect(err.rpcName).toBe('pay_invoice') + expect(err.requestId).toBe('pay-1') + expect(err.message).toBe('not enough sats') + expect(err.retryPolicy).toBe('terminal') + expect(err.isRetryable).toBe(false) + }) + + it('treats an ABSENT error_code as internal_error (retry-once)', () => { + const err = LnbitsRpcError.fromResponse('get_wallet', { request_id: 'w-1' }) + expect(err.code).toBe(LnbitsErrorCode.InternalError) + expect(err.retryPolicy).toBe('retry-once') + expect(err.isRetryable).toBe(true) + }) + + it('treats an UNKNOWN error_code as internal_error', () => { + const err = LnbitsRpcError.fromResponse('get_wallet', { + request_id: 'w-2', + error_code: 'brand_new_code_we_dont_know', + }) + expect(err.code).toBe(LnbitsErrorCode.InternalError) + }) + + it('flags invoice_already_paid as idempotent-success', () => { + const err = LnbitsRpcError.fromResponse('pay_invoice', { + request_id: 'p-1', + error_code: 'invoice_already_paid', + }) + expect(err.isIdempotentSuccess).toBe(true) + }) +}) diff --git a/packages/lnbits/src/client.ts b/packages/lnbits/src/client.ts index 76b796e..946df5d 100644 --- a/packages/lnbits/src/client.ts +++ b/packages/lnbits/src/client.ts @@ -26,6 +26,7 @@ import { type Event as NostrEvent, } from '@bitSpire/nostr-client' import { verifyEvent } from 'nostr-tools' +import { LnbitsRpcError } from './error-codes.js' import type { LnbitsConfig, @@ -478,7 +479,7 @@ export class LnbitsClient { clearTimeout(timer) this.pending.delete(requestId) if (response.status === 'ERROR') { - reject(new Error(response.error ?? `${rpcName}: server returned ERROR`)) + reject(LnbitsRpcError.fromResponse(rpcName, response)) return } resolve(response.data as T) diff --git a/packages/lnbits/src/error-codes.ts b/packages/lnbits/src/error-codes.ts new file mode 100644 index 0000000..1944664 --- /dev/null +++ b/packages/lnbits/src/error-codes.ts @@ -0,0 +1,143 @@ +/** + * LNbits nostr-transport error taxonomy. + * + * Machine-readable discriminators for kind-21000 ERROR responses. Mirror of + * the lnbits canonical enum (`core/services/nostr_transport/error_codes.py`) + * and the vocabulary table in `docs/devs/nostr-transport.md`. Drift detection + * = diff this enum against that table. Agreed in the 2026-05-26 cross-session + * handshake on aiolabs/bitspire#52. + * + * Wire shape (additive to the existing envelope): + * { "status": "ERROR", "request_id": "...", "error_code": "...", "error": "..." } + * + * `error_code` is optional-additive for one lnbits release, then required. + * An ABSENT `error_code` is treated as `internal_error` (retry-once) — we do + * not string-match the human-readable `error`. So un-migrated handlers get a + * safe retry-then-surface default with no special parser paths. + */ + +export enum LnbitsErrorCode { + // signer class — the operator's signer (bunker) on the LNbits side + OperatorSignerUnavailable = 'operator_signer_unavailable', + OperatorSignerRejected = 'operator_signer_rejected', + OperatorSignerUnconfigured = 'operator_signer_unconfigured', + // transport class + Unauthorized = 'unauthorized', + RateLimited = 'rate_limited', + UnknownMethod = 'unknown_method', + InvalidParams = 'invalid_params', + InternalError = 'internal_error', + // app class + WalletNotFound = 'wallet_not_found', + InsufficientBalance = 'insufficient_balance', + InvoiceAlreadyPaid = 'invoice_already_paid', + InvoiceExpired = 'invoice_expired', + PaymentFailed = 'payment_failed', + AccountNotFound = 'account_not_found', +} + +/** + * Retry disposition for an error code: + * - `retry-backoff` — transient; retry with short exponential backoff. + * - `retry-long-backoff` — rate-limited; retry with a longer backoff. + * - `retry-once` — retry exactly once, then surface (the `internal_error` default). + * - `terminal` — do not retry; surface to the user. + * - `terminal-idempotent` — terminal, but the operation already took effect + * (e.g. `invoice_already_paid` — a cash-out watcher treats it as settled). + */ +export type RetryPolicy = + | 'retry-backoff' + | 'retry-long-backoff' + | 'retry-once' + | 'terminal' + | 'terminal-idempotent' + +const RETRY_POLICIES: Record = { + [LnbitsErrorCode.OperatorSignerUnavailable]: 'retry-backoff', + [LnbitsErrorCode.OperatorSignerRejected]: 'terminal', + [LnbitsErrorCode.OperatorSignerUnconfigured]: 'terminal', + [LnbitsErrorCode.Unauthorized]: 'terminal', + [LnbitsErrorCode.RateLimited]: 'retry-long-backoff', + [LnbitsErrorCode.UnknownMethod]: 'terminal', + [LnbitsErrorCode.InvalidParams]: 'terminal', + [LnbitsErrorCode.InternalError]: 'retry-once', + [LnbitsErrorCode.WalletNotFound]: 'terminal', + [LnbitsErrorCode.InsufficientBalance]: 'terminal', + [LnbitsErrorCode.InvoiceAlreadyPaid]: 'terminal-idempotent', + [LnbitsErrorCode.InvoiceExpired]: 'terminal', + // payment_failed is terminal-with-detail: the sub-reason rides in `error`. + [LnbitsErrorCode.PaymentFailed]: 'terminal', + [LnbitsErrorCode.AccountNotFound]: 'terminal', +} + +const RETRYABLE: ReadonlySet = new Set([ + 'retry-backoff', + 'retry-long-backoff', + 'retry-once', +]) + +/** Parse a wire string into a known code, or null if unrecognized. */ +export function parseErrorCode(raw: string | undefined | null): LnbitsErrorCode | null { + if (!raw) return null + return (Object.values(LnbitsErrorCode) as string[]).includes(raw) + ? (raw as LnbitsErrorCode) + : null +} + +/** Retry disposition for a code. */ +export function retryPolicyFor(code: LnbitsErrorCode): RetryPolicy { + return RETRY_POLICIES[code] +} + +/** + * Typed error thrown by `LnbitsClient` on an ERROR response. Carries the + * machine-readable `code` + its `retryPolicy` so callers (and the state + * machine) branch on disposition rather than string-matching `message`. + */ +export class LnbitsRpcError extends Error { + readonly code: LnbitsErrorCode + readonly rpcName: string + readonly requestId: string + readonly retryPolicy: RetryPolicy + + constructor(args: { + code: LnbitsErrorCode + rpcName: string + requestId: string + message?: string + }) { + super(args.message ?? `${args.rpcName}: ${args.code}`) + this.name = 'LnbitsRpcError' + this.code = args.code + this.rpcName = args.rpcName + this.requestId = args.requestId + this.retryPolicy = retryPolicyFor(args.code) + } + + /** + * Build from a wire ERROR response. An absent/unknown `error_code` maps to + * `internal_error` (retry-once) per the deprecation-window contract. + */ + static fromResponse( + rpcName: string, + response: { request_id: string; error_code?: string | null; error?: string } + ): LnbitsRpcError { + const code = parseErrorCode(response.error_code) ?? LnbitsErrorCode.InternalError + return new LnbitsRpcError({ + code, + rpcName, + requestId: response.request_id, + message: response.error ?? `${rpcName}: server returned ERROR (${code})`, + }) + } + + /** True when the disposition permits a retry (any backoff/once policy). */ + get isRetryable(): boolean { + return RETRYABLE.has(this.retryPolicy) + } + + /** True when the operation already took effect despite the error. */ + get isIdempotentSuccess(): boolean { + return this.retryPolicy === 'terminal-idempotent' + } +} diff --git a/packages/lnbits/src/index.ts b/packages/lnbits/src/index.ts index 34463dc..10e0f05 100644 --- a/packages/lnbits/src/index.ts +++ b/packages/lnbits/src/index.ts @@ -49,6 +49,13 @@ */ export { LnbitsClient } from './client.js' +export { + LnbitsErrorCode, + LnbitsRpcError, + parseErrorCode, + retryPolicyFor, +} from './error-codes.js' +export type { RetryPolicy } from './error-codes.js' export type { LnbitsConfig, LnbitsRpcRequest, diff --git a/packages/lnbits/src/types.ts b/packages/lnbits/src/types.ts index 6372182..c040b3c 100644 --- a/packages/lnbits/src/types.ts +++ b/packages/lnbits/src/types.ts @@ -39,7 +39,12 @@ export interface LnbitsRpcResponse { /** Non-null on subscription push events. Null on regular acks. */ subscription_id?: string | null data?: T + /** Human-readable error detail (ERROR status only). */ error?: string + /** Machine-readable error discriminator (ERROR status). Optional-additive + * for one lnbits release, then required; absent → internal_error. See + * error-codes.ts (aiolabs/bitspire#52). */ + error_code?: string } // ============================================================================ From 78d54cdc94a7539eadafc831c7af365d408c0bfe Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 19 Jun 2026 23:34:07 +0200 Subject: [PATCH 14/82] feat(machine): re-pair UX on bunker deauth at boot A revoked / TTL-expired / off-policy bunker binding now surfaces a dedicated "Pairing Required" screen instead of a raw error, and a signer/relay timeout shows "Signer Unreachable" (transient). Shared classifyInitError() maps the typed BunkerRejectedError / BunkerTimeoutError (by name, so it survives bundle boundaries) to maintenance-screen sentinels, used at every store init catch + the App.vue fallback. App.vue's nested-ternary screen copy refactored to a keyed map (cleaner, and the new screens drop in). Scope: boot-time detection (covers the dominant restart-after-revoke case). Mid-session re-pair detection (flipping the screen when a sign fails during a live flow) is a deliberate follow-up. Part of Phase D, aiolabs/bitspire#52. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/machine/src/App.vue | 65 +++++++++++++------ .../src/services/__tests__/init-error.test.ts | 30 +++++++++ apps/machine/src/services/init-error.ts | 17 +++++ apps/machine/src/stores/atm.ts | 7 +- 4 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 apps/machine/src/services/__tests__/init-error.test.ts create mode 100644 apps/machine/src/services/init-error.ts diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index 652f708..48cfc8a 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -4,6 +4,7 @@ import { useRoute } from 'vue-router' import { useAtmStore } from '@/stores/atm' import { useTheme } from '@/composables/useTheme' import { setBranding } from '@/composables/useBranding' +import { classifyInitError } from '@/services/init-error' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Sun, Moon } from 'lucide-vue-next' @@ -20,6 +21,46 @@ function formatSats(sats: number): string { return sats.toLocaleString() } +/** + * Maintenance-screen copy keyed by the `initError` sentinel. Falls back to a + * generic out-of-service message (the raw error text shows under debug only). + */ +const MAINTENANCE_SCREENS: Record = { + maintenance: { + title: 'Under Service', + message: 'This machine is currently being serviced. We will be back shortly.', + }, + 'awaiting-fees': { + title: 'Awaiting Configuration', + message: + 'Awaiting fee configuration from operator. Contact operator to publish initial fee config.', + }, + unpaired: { + title: 'Pairing Required', + message: + 'This machine needs to be re-paired by the operator before it can accept transactions.', + }, + 'signer-unreachable': { + title: 'Signer Unreachable', + message: 'Cannot reach the signing service right now. This usually resolves shortly.', + }, +} + +const GENERIC_SCREEN = { + title: 'ATM Unavailable', + message: + 'This machine is temporarily out of service. Please try again later or use another machine.', +} + +const maintenanceScreen = computed(() => + atmStore.initError ? (MAINTENANCE_SCREENS[atmStore.initError] ?? GENERIC_SCREEN) : GENERIC_SCREEN +) + +/** True when the screen is a known sentinel (hide the raw debug error line). */ +const isKnownMaintenanceScreen = computed( + () => !!atmStore.initError && atmStore.initError in MAINTENANCE_SCREENS +) + const formattedBtcPrice = computed(() => { if (atmStore.btcPrice === null) return null const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}` @@ -96,7 +137,7 @@ onMounted(async () => { atmStore.startPricePolling() } catch (error) { console.error('[App] Initialization failed:', error) - atmStore.initError = error instanceof Error ? error.message : 'Initialization failed' + atmStore.initError = classifyInitError(error) } }) @@ -141,29 +182,13 @@ function toggleLiveServices() {

- {{ - atmStore.initError === 'maintenance' - ? 'Under Service' - : atmStore.initError === 'awaiting-fees' - ? 'Awaiting Configuration' - : 'ATM Unavailable' - }} + {{ maintenanceScreen.title }}

- {{ - atmStore.initError === 'maintenance' - ? 'This machine is currently being serviced. We will be back shortly.' - : atmStore.initError === 'awaiting-fees' - ? 'Awaiting fee configuration from operator. Contact operator to publish initial fee config.' - : 'This machine is temporarily out of service. Please try again later or use another machine.' - }} + {{ maintenanceScreen.message }}

{{ atmStore.initError }} diff --git a/apps/machine/src/services/__tests__/init-error.test.ts b/apps/machine/src/services/__tests__/init-error.test.ts new file mode 100644 index 0000000..fc4215b --- /dev/null +++ b/apps/machine/src/services/__tests__/init-error.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { BunkerRejectedError, BunkerTimeoutError } from '@bitSpire/nostr-client' +import { classifyInitError } from '../init-error.js' + +describe('classifyInitError', () => { + it('maps a bunker rejection (revoke / TTL / off-policy) to "unpaired"', () => { + expect(classifyInitError(new BunkerRejectedError('revoked'))).toBe('unpaired') + }) + + it('maps a bunker timeout to "signer-unreachable"', () => { + expect(classifyInitError(new BunkerTimeoutError('no response'))).toBe('signer-unreachable') + }) + + it('classifies by error name across bundle boundaries (no instanceof)', () => { + // A structurally-equivalent error from a different module copy still maps. + const lookalike = Object.assign(new Error('x'), { name: 'BunkerRejectedError' }) + expect(classifyInitError(lookalike)).toBe('unpaired') + }) + + it('surfaces a generic error message unchanged', () => { + expect(classifyInitError(new Error('relay down'))).toBe('relay down') + }) + + it('uses the fallback for non-Error throws', () => { + expect(classifyInitError('boom', 'Lightning initialization failed')).toBe( + 'Lightning initialization failed' + ) + expect(classifyInitError(undefined)).toBe('Initialization failed') + }) +}) diff --git a/apps/machine/src/services/init-error.ts b/apps/machine/src/services/init-error.ts new file mode 100644 index 0000000..f9b0c84 --- /dev/null +++ b/apps/machine/src/services/init-error.ts @@ -0,0 +1,17 @@ +/** + * Classify an initialization failure into a maintenance-screen sentinel + * (see App.vue's MAINTENANCE_SCREENS). + * + * Bunker failures (aiolabs/bitspire#52) get dedicated screens: + * - `BunkerRejectedError` (revoked / TTL-expired / off-policy binding) → + * `unpaired` — the operator must re-pair the machine. + * - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`, + * a transient condition. + * Everything else surfaces its raw message (or the caller's fallback). + */ +export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string { + const name = (error as { name?: string } | null)?.name + if (name === 'BunkerRejectedError') return 'unpaired' + if (name === 'BunkerTimeoutError') return 'signer-unreachable' + return error instanceof Error ? error.message : fallback +} diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index efc80cf..ca22847 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -10,6 +10,7 @@ import { type ATMMachine, } from '@bitSpire/state-machine' import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning' +import { classifyInitError } from '@/services/init-error' import { startOperatorConfigService, type OperatorConfigService, @@ -706,7 +707,7 @@ export const useAtmStore = defineStore('atm', () => { useLiveServices.value = false initialize(mockServices) } else { - initError.value = error instanceof Error ? error.message : 'Lightning initialization failed' + initError.value = classifyInitError(error, 'Lightning initialization failed') } } } @@ -1019,7 +1020,7 @@ export const useAtmStore = defineStore('atm', () => { useLiveServices.value = false initialize(mockServices) } else { - initError.value = error instanceof Error ? error.message : 'HAL initialization failed' + initError.value = classifyInitError(error, 'HAL initialization failed') } } } @@ -1330,7 +1331,7 @@ export const useAtmStore = defineStore('atm', () => { initialize(mockServices) } } else { - initError.value = error instanceof Error ? error.message : 'Hardware initialization failed' + initError.value = classifyInitError(error, 'Hardware initialization failed') } } } From 8a02d72bd1b68d23a891001493058dab93785fdc Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 21 Jun 2026 09:56:00 +0200 Subject: [PATCH 15/82] feat(deploy): provision VITE_SPIRE_SEED for bunker pairing (Phase E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provision-atm.sh now writes VITE_SPIRE_SEED (the spire-seed:v1: pairing seed from spirekeeper) as the production identity, validating the scheme prefix; the generated nsec path is kept only as a dev fallback when SPIRE_SEED is unset. Relay default moved to the LNbits bundled nostrrelay (ws://$HOST_IP:5001/nostrrelay/test). .env templates (live.nix + the flake's installed-default) swap VITE_ATM_PRIVATE_KEY → VITE_SPIRE_SEED and drop the dead LP-era vars. README notes state.db now also holds the bunker binding (keep it or re-pair). Part of Phase E, aiolabs/bitspire#52. Unblocks the Sintra live-pairing smoke. Co-Authored-By: Claude Opus 4.8 (1M context) --- deploy/nixos/README.md | 4 ++-- deploy/nixos/live.nix | 13 +++++------ deploy/nixos/provision-atm.sh | 41 +++++++++++++++++++++++++---------- flake.nix | 2 +- 4 files changed, 39 insertions(+), 21 deletions(-) diff --git a/deploy/nixos/README.md b/deploy/nixos/README.md index 794c471..af00ed6 100644 --- a/deploy/nixos/README.md +++ b/deploy/nixos/README.md @@ -59,7 +59,7 @@ scp bitspire@:/var/lib/bitspire/.env ~/sintra-backup-$(date +%Y scp bitspire@:/var/lib/bitspire/state.db ~/sintra-backup-$(date +%Y%m%d)/ ``` -The `.env` is the load-bearing one — it contains `VITE_ATM_PRIVATE_KEY` plus the LNbits / relay URLs. `state.db` is transaction history (cheap to keep, fine to drop on dev units). Reuse these in step 7 instead of regenerating. +The `.env` is the load-bearing one — it contains `VITE_SPIRE_SEED` (the NIP-46 bunker pairing seed; or the dev-only `VITE_ATM_PRIVATE_KEY` fallback) plus the LNbits / relay URLs. Note the persisted bunker binding (the ATM's transport key) lives in `state.db` once paired — so on a bunker-backed unit, keep `state.db` too or you'll need to re-pair. `state.db` also holds transaction history. Reuse these in step 7 instead of regenerating. Also before powering off the Sintra: make sure any unpushed commits on `dev` have been pushed AND `./deploy/push-cache.sh sintra` has run. Otherwise the next 04:00 auto-upgrade on the freshly-flashed unit will fail to substitute the new closure (or silently downgrade to whatever `origin/dev` HEAD points at). @@ -202,7 +202,7 @@ Production ATMs on `main` continue to read `main`'s flake (no `?ref=` pin → re | Path | Owner | Purpose | |------|-------|---------| | `/var/lib/bitspire/` | bitspire:bitspire, 0750 | Service data directory | -| `/var/lib/bitspire/.env` | bitspire:bitspire, 0600 | Runtime config — `VITE_RELAY_URL`, `VITE_LNBITS_SERVER_PUBKEY`, `VITE_ATM_PRIVATE_KEY`, … | +| `/var/lib/bitspire/.env` | bitspire:bitspire, 0600 | Runtime config — `VITE_RELAY_URL`, `VITE_LNBITS_SERVER_PUBKEY`, `VITE_SPIRE_SEED` (or dev `VITE_ATM_PRIVATE_KEY`), … | | `/var/lib/bitspire/state.db` | bitspire:bitspire | SQLite — cassette inventory, cashbox state, transaction history | | `/var/lib/bitspire/logs/` | bitspire:bitspire, 0750 | Service logs (if app writes them) | | `/var/lib/bitspire/branding/` | bitspire:bitspire, 0755 | Operator branding override (logo.png + branding.json) — see issue #47 | diff --git a/deploy/nixos/live.nix b/deploy/nixos/live.nix index 6b72fbc..07fe161 100644 --- a/deploy/nixos/live.nix +++ b/deploy/nixos/live.nix @@ -22,16 +22,15 @@ let }.${machineModel} or "USD"; # .env template — runtime secrets are provisioned later via provision-atm.sh. - # Only non-secret defaults and display vars go here. + # Only non-secret defaults and display vars go here. VITE_SPIRE_SEED (the + # NIP-46 bunker pairing seed) is written at provision time; the dev-only + # VITE_ATM_PRIVATE_KEY fallback is omitted here on purpose. envTemplate = pkgs.writeText "bitspire-env" '' VITE_RELAY_URL= - VITE_LIGHTNING_PUB_PUBKEY= - VITE_LIGHTNING_PUB_API_URL= - VITE_ADMIN_TOKEN= - VITE_ATM_PRIVATE_KEY= - VITE_EXTENSION_API_URL= + VITE_LNBITS_SERVER_PUBKEY= + VITE_SPIRE_SEED= VITE_APP_ID= - VITE_LNDCONNECT_URL= + VITE_OPERATOR_PUBKEYS= VITE_LAMASSU_MACHINE_MODEL=${machineModel} VITE_LAMASSU_FIAT_CODE=${fiatCodeForModel} ELECTRON_FORCE_PROD=1 diff --git a/deploy/nixos/provision-atm.sh b/deploy/nixos/provision-atm.sh index 5b4c55b..74f4229 100755 --- a/deploy/nixos/provision-atm.sh +++ b/deploy/nixos/provision-atm.sh @@ -11,9 +11,15 @@ # LNBITS_HTTP_URL Origin LNbits is reachable at over HTTP, used only # to compose the LNURL-withdraw callback URL that # customer wallets dereference. Default: http://10.0.2.2:5000 -# RELAY_URL Nostr relay LNbits subscribes on. Default uses host gateway. -# ATM_PRIVATE_KEY 32-byte hex key, ATM's nostr identity. If unset, a -# fresh key is generated and saved in the .env. +# RELAY_URL Nostr relay LNbits + the bunker subscribe on. +# Default: ws://$HOST_IP:5001/nostrrelay/test (LNbits +# bundled nostrrelay). Override for a separate relay. +# SPIRE_SEED The spire pairing seed (`spire-seed:v1:`) +# minted by spirekeeper. THIS is the production +# identity under the NIP-46 bunker (aiolabs/bitspire#52). +# ATM_PRIVATE_KEY DEV-ONLY 32-byte hex nsec fallback, used only when +# SPIRE_SEED is unset (no bunker). Generated if unset +# AND no SPIRE_SEED is provided. # # Usage: # bash provision-atm.sh # defaults: SSH to localhost:2222 (QEMU) @@ -74,14 +80,28 @@ 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. -RELAY_URL="${RELAY_URL:-ws://$HOST_IP:7777}" +# Step 4: Relay URL. Defaults to the LNbits bundled nostrrelay. +RELAY_URL="${RELAY_URL:-ws://$HOST_IP:5001/nostrrelay/test}" -# Step 5: ATM identity. Generate if unset. -if [ -z "${ATM_PRIVATE_KEY:-}" ]; then - ATM_PRIVATE_KEY=$(openssl rand -hex 32) +# Step 5: Signing identity. Prefer the spire pairing seed (bunker). Only fall +# back to a generated dev nsec when no seed is supplied. +if [ -n "${SPIRE_SEED:-}" ]; then echo "" - echo "--- Generated fresh ATM_PRIVATE_KEY (save this if you want it persisted) ---" + echo "--- Using spire pairing seed (bunker-backed identity) ---" + case "$SPIRE_SEED" in + spire-seed:v1:*) : ;; + *) echo "ERROR: SPIRE_SEED must start with 'spire-seed:v1:'"; exit 1 ;; + esac + IDENTITY_LINES="# Spire pairing seed — bunker-backed identity (aiolabs/bitspire#52) +VITE_SPIRE_SEED=$SPIRE_SEED" +else + if [ -z "${ATM_PRIVATE_KEY:-}" ]; then + ATM_PRIVATE_KEY=$(openssl rand -hex 32) + echo "" + echo "--- No SPIRE_SEED; generated a DEV-ONLY ATM_PRIVATE_KEY (no bunker) ---" + fi + IDENTITY_LINES="# DEV-ONLY local nsec (no bunker pairing) # pragma: allowlist secret +VITE_ATM_PRIVATE_KEY=$ATM_PRIVATE_KEY" fi # Step 6: Write .env to the ATM via SSH. @@ -95,8 +115,7 @@ VITE_RELAY_URL=$RELAY_URL VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY VITE_LNBITS_HTTP_URL=$LNBITS_HTTP_URL -# ATM identity (signing key IS the credential under nostr-transport) -VITE_ATM_PRIVATE_KEY=$ATM_PRIVATE_KEY +$IDENTITY_LINES # Machine configuration VITE_LAMASSU_MACHINE_MODEL=$MODEL diff --git a/flake.nix b/flake.nix index dc69891..9ceb5dd 100644 --- a/flake.nix +++ b/flake.nix @@ -200,7 +200,7 @@ cp ${pkgs.writeText "bitspire-env-default" '' VITE_RELAY_URL=${config.services.bitspire.relayUrl} VITE_LNBITS_SERVER_PUBKEY= - VITE_ATM_PRIVATE_KEY= + VITE_SPIRE_SEED= VITE_APP_ID= VITE_OPERATOR_PUBKEYS= VITE_LAMASSU_MACHINE_MODEL=${machineModel} From 2a64b42cde83937e8ed463a82dbb9913714dabf8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 21 Jun 2026 15:35:35 +0200 Subject: [PATCH 16/82] feat(lnbits): retry-policy switch for idempotent reads (Phase D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry half of the 2026-05-26 error-handling agreement (aiolabs/bitspire#52). `withRetry` retries an operation per the disposition of the error it throws — LnbitsRpcError.retryPolicy (operator_signer_unavailable/rate_limited → backoff, internal_error → retry-once) plus transport timeouts — and rethrows terminal/unknown errors immediately. Applied ONLY to idempotent reads (getWallet/getBalance/listWallets/getPayment/ decodePayment + the lnurlw read methods). create_invoice / pay_invoice / lnurlw_create_link are deliberately NOT wrapped — a blind retry would mint a duplicate or double-pay; their errors surface for flow-level handling. This is why the switch lives at the per-call read layer, not as a blanket client retry. Safe to land before lnbits emits error_code: an absent code already maps to internal_error (retry-once), so reads get one transparent retry on a transient blip with no behaviour change otherwise. 10 tests (backoff/terminal/timeout/ unknown/onRetry). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/lnbits/src/__tests__/retry.test.ts | 96 +++++++++++++++++++++ packages/lnbits/src/client.ts | 68 ++++++++++----- packages/lnbits/src/index.ts | 2 + packages/lnbits/src/retry.ts | 79 +++++++++++++++++ 4 files changed, 225 insertions(+), 20 deletions(-) create mode 100644 packages/lnbits/src/__tests__/retry.test.ts create mode 100644 packages/lnbits/src/retry.ts diff --git a/packages/lnbits/src/__tests__/retry.test.ts b/packages/lnbits/src/__tests__/retry.test.ts new file mode 100644 index 0000000..8a57b45 --- /dev/null +++ b/packages/lnbits/src/__tests__/retry.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from 'vitest' +import { withRetry } from '../retry.js' +import { LnbitsErrorCode, LnbitsRpcError } from '../error-codes.js' + +const noSleep = () => Promise.resolve() + +function rpcErr(code: LnbitsErrorCode): LnbitsRpcError { + return new LnbitsRpcError({ code, rpcName: 'get_wallet', requestId: 'r1' }) +} + +/** A fn that throws `err` the first `failTimes` calls, then returns `value`. */ +function failingFn(failTimes: number, err: unknown, value: T): { fn: () => Promise; calls: () => number } { + let calls = 0 + return { + fn: async () => { + calls++ + if (calls <= failTimes) throw err + return value + }, + calls: () => calls, + } +} + +describe('withRetry', () => { + it('returns immediately on success (one call)', async () => { + const { fn, calls } = failingFn(0, rpcErr(LnbitsErrorCode.InternalError), 'ok') + expect(await withRetry(fn, { sleep: noSleep })).toBe('ok') + expect(calls()).toBe(1) + }) + + it('retries a transient operator_signer_unavailable, then succeeds', async () => { + const { fn, calls } = failingFn(1, rpcErr(LnbitsErrorCode.OperatorSignerUnavailable), 'ok') + expect(await withRetry(fn, { sleep: noSleep })).toBe('ok') + expect(calls()).toBe(2) + }) + + it('retries rate_limited (long backoff) up to maxAttempts then throws', async () => { + const err = rpcErr(LnbitsErrorCode.RateLimited) + const { fn, calls } = failingFn(99, err, 'never') + await expect(withRetry(fn, { sleep: noSleep, maxAttempts: 3 })).rejects.toBe(err) + expect(calls()).toBe(3) + }) + + it('internal_error (retry-once) retries exactly once', async () => { + const err = rpcErr(LnbitsErrorCode.InternalError) + const { fn, calls } = failingFn(99, err, 'never') + await expect(withRetry(fn, { sleep: noSleep, maxAttempts: 5 })).rejects.toBe(err) + expect(calls()).toBe(2) // initial + one retry, then null delay stops it + }) + + it('throws a terminal error immediately (no retry)', async () => { + const err = rpcErr(LnbitsErrorCode.InsufficientBalance) + const { fn, calls } = failingFn(99, err, 'never') + await expect(withRetry(fn, { sleep: noSleep })).rejects.toBe(err) + expect(calls()).toBe(1) + }) + + it('treats unauthorized as terminal (no retry)', async () => { + const err = rpcErr(LnbitsErrorCode.Unauthorized) + const { fn, calls } = failingFn(99, err, 'never') + await expect(withRetry(fn, { sleep: noSleep })).rejects.toBe(err) + expect(calls()).toBe(1) + }) + + it('retries a transport timeout error', async () => { + const timeout = new Error('LnbitsClient.get_wallet: timeout after 30000ms') + const { fn, calls } = failingFn(1, timeout, 'ok') + expect(await withRetry(fn, { sleep: noSleep })).toBe('ok') + expect(calls()).toBe(2) + }) + + it('does NOT retry an unknown error (rethrows immediately)', async () => { + const boom = new Error('relay socket closed') + const { fn, calls } = failingFn(99, boom, 'never') + await expect(withRetry(fn, { sleep: noSleep })).rejects.toBe(boom) + expect(calls()).toBe(1) + }) + + it('backs off with increasing delay per attempt (retry-backoff)', async () => { + const delays: number[] = [] + const err = rpcErr(LnbitsErrorCode.OperatorSignerUnavailable) + const { fn } = failingFn(99, err, 'never') + await expect( + withRetry(fn, { sleep: (ms) => { delays.push(ms); return Promise.resolve() }, maxAttempts: 3 }) + ).rejects.toBe(err) + expect(delays).toEqual([200, 400]) // before attempt 2 and 3; attempt 3 is last → no 3rd sleep + }) + + it('invokes onRetry with attempt/delay/error', async () => { + const onRetry = vi.fn() + const { fn } = failingFn(1, rpcErr(LnbitsErrorCode.OperatorSignerUnavailable), 'ok') + await withRetry(fn, { sleep: noSleep, onRetry }) + expect(onRetry).toHaveBeenCalledOnce() + expect(onRetry.mock.calls[0]![0]).toMatchObject({ attempt: 1, delayMs: 200 }) + }) +}) diff --git a/packages/lnbits/src/client.ts b/packages/lnbits/src/client.ts index 946df5d..de408a1 100644 --- a/packages/lnbits/src/client.ts +++ b/packages/lnbits/src/client.ts @@ -27,6 +27,7 @@ import { } from '@bitSpire/nostr-client' import { verifyEvent } from 'nostr-tools' import { LnbitsRpcError } from './error-codes.js' +import { withRetry } from './retry.js' import type { LnbitsConfig, @@ -155,6 +156,22 @@ export class LnbitsClient { this.startReplyListener() } + /** + * Retry-policy switch for IDEMPOTENT reads only (aiolabs/bitspire#52, Phase D). + * Transient failures (operator_signer_unavailable / rate_limited / + * internal_error / transport timeout) back off and retry; terminal errors + * surface immediately. Never used for pay/create — those would double-pay or + * duplicate on retry. + */ + private idempotent(fn: () => Promise): Promise { + return withRetry(fn, { + onRetry: ({ attempt, delayMs, error }) => { + const code = error instanceof LnbitsRpcError ? error.code : 'timeout' + console.warn(`[LnbitsClient] transient ${code} — retry ${attempt} in ${delayMs}ms`) + }, + }) + } + // ============================================================================ // Wallet // ============================================================================ @@ -166,8 +183,7 @@ export class LnbitsClient { */ async getWallet(walletId?: string): Promise { if (walletId) { - const data = await this.sendRpc('get_wallet', { walletId }) - return data + return this.idempotent(() => this.sendRpc('get_wallet', { walletId })) } const wallets = await this.listWallets() if (wallets.length === 0) { @@ -188,7 +204,7 @@ export class LnbitsClient { /** Enumerate every wallet owned by the calling account. */ async listWallets(): Promise { - const data = await this.sendRpc('list_wallets', {}) + const data = await this.idempotent(() => this.sendRpc('list_wallets', {})) return data ?? [] } @@ -196,6 +212,10 @@ export class LnbitsClient { // Invoices // ============================================================================ + // NOTE: create_invoice / pay_invoice / lnurlw_create_link are NOT wrapped in + // `idempotent()` — a retry would mint a duplicate invoice/link or double-pay. + // Their errors surface for flow-level handling (state machine / operator). + async createInvoice(walletId: string, body: CreateInvoiceBody): Promise { const data = await this.sendRpc('create_invoice', { walletId, body }) return data @@ -208,17 +228,20 @@ export class LnbitsClient { /** Point-lookup of a payment by hash. AUTH_NONE — hashes are hard to guess. */ async getPayment(paymentHash: string): Promise { - const data = await this.sendRpc('get_payment', { - body: { payment_hash: paymentHash }, - }) + const data = await this.idempotent(() => + this.sendRpc('get_payment', { + body: { payment_hash: paymentHash }, + }), + ) return data ?? null } async decodePayment(paymentRequest: string): Promise> { - const data = await this.sendRpc>('decode_payment', { - body: { payment_request: paymentRequest }, - }) - return data + return this.idempotent(() => + this.sendRpc>('decode_payment', { + body: { payment_request: paymentRequest }, + }), + ) } // ============================================================================ @@ -366,18 +389,21 @@ export class LnbitsClient { } async getWithdrawLink(walletId: string, id: string): Promise { - const data = await this.sendRpc('lnurlw_get_link', { walletId, body: { id } }) - return data + return this.idempotent(() => + this.sendRpc('lnurlw_get_link', { walletId, body: { id } }), + ) } async listWithdrawLinks( walletId: string | undefined, body: { limit?: number; offset?: number } = {}, ): Promise<{ data: LnbitsWithdrawLink[]; total: number }> { - return this.sendRpc<{ data: LnbitsWithdrawLink[]; total: number }>('lnurlw_list_links', { - walletId, - body, - }) + return this.idempotent(() => + this.sendRpc<{ data: LnbitsWithdrawLink[]; total: number }>('lnurlw_list_links', { + walletId, + body, + }), + ) } /** @@ -387,10 +413,12 @@ export class LnbitsClient { * — the URL a customer wallet GETs to redeem that specific sub-link. */ async getWithdrawLinkUniqueHashes(walletId: string, id: string): Promise { - return this.sendRpc('lnurlw_unique_hashes', { - walletId, - body: { id }, - }) + return this.idempotent(() => + this.sendRpc('lnurlw_unique_hashes', { + walletId, + body: { id }, + }), + ) } async updateWithdrawLink( diff --git a/packages/lnbits/src/index.ts b/packages/lnbits/src/index.ts index 10e0f05..2085593 100644 --- a/packages/lnbits/src/index.ts +++ b/packages/lnbits/src/index.ts @@ -56,6 +56,8 @@ export { retryPolicyFor, } from './error-codes.js' export type { RetryPolicy } from './error-codes.js' +export { withRetry } from './retry.js' +export type { WithRetryOptions } from './retry.js' export type { LnbitsConfig, LnbitsRpcRequest, diff --git a/packages/lnbits/src/retry.ts b/packages/lnbits/src/retry.ts new file mode 100644 index 0000000..0495859 --- /dev/null +++ b/packages/lnbits/src/retry.ts @@ -0,0 +1,79 @@ +/** + * Retry policy switch for the nostr-transport (aiolabs/bitspire#52, Phase D). + * + * Retries an operation according to the *disposition* of the error it throws — + * the machine-readable `retryPolicy` carried by `LnbitsRpcError` (which mirrors + * the lnbits canonical enum). Transient conditions back off and retry; + * terminal ones throw immediately. + * + * ⚠️ ONLY wrap IDEMPOTENT operations. A blind retry of `pay_invoice` could + * double-pay, and of `create_invoice` / `lnurlw_create_link` would mint + * duplicates — those surface their error for flow-level handling (the state + * machine / operator) instead. See LnbitsClient for which methods opt in. + */ + +import { LnbitsRpcError, type RetryPolicy } from './error-codes.js' + +export interface WithRetryOptions { + /** Max total attempts (default 3). */ + maxAttempts?: number + /** Injectable sleep (tests pass a fake-timer-friendly version). */ + sleep?: (ms: number) => Promise + /** Called before each backoff wait — useful for logging. */ + onRetry?: (info: { attempt: number; delayMs: number; error: unknown }) => void +} + +const DEFAULT_MAX_ATTEMPTS = 3 + +/** Backoff (ms) for the Nth attempt (1-based), or null if the policy is terminal. */ +function backoffMs(policy: RetryPolicy, attempt: number): number | null { + switch (policy) { + case 'retry-backoff': + return 200 * 2 ** (attempt - 1) // 200, 400, 800… + case 'retry-long-backoff': + return 1_000 * 2 ** (attempt - 1) // 1s, 2s, 4s… (rate_limited) + case 'retry-once': + return attempt === 1 ? 0 : null // exactly one retry (internal_error / absent code) + case 'terminal': + case 'terminal-idempotent': + return null + } +} + +/** Retry delay for an error, or null if it must not be retried. */ +function delayForError(error: unknown, attempt: number): number | null { + if (error instanceof LnbitsRpcError) { + return backoffMs(error.retryPolicy, attempt) + } + // A transport timeout from sendRpc ("…: timeout after ms") is transient. + if (error instanceof Error && /timeout after \d+ms/.test(error.message)) { + return 200 * 2 ** (attempt - 1) + } + // Unknown error (programming bug, network teardown) — don't mask it. + return null +} + +/** + * Run `fn`, retrying transient failures per the error's `retryPolicy` (or a + * transport timeout) with backoff, up to `maxAttempts`. Terminal errors and + * unknown errors throw immediately; the last error is rethrown on exhaustion. + */ +export async function withRetry(fn: () => Promise, opts: WithRetryOptions = {}): Promise { + const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS + const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))) + + let lastError: unknown + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn() + } catch (error) { + lastError = error + if (attempt === maxAttempts) break + const delayMs = delayForError(error, attempt) + if (delayMs === null) throw error + opts.onRetry?.({ attempt, delayMs, error }) + await sleep(delayMs) + } + } + throw lastError +} From 762b0def5c77d2efcb6fa18f7809bfa5999ed6fe Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 22 Jun 2026 11:08:29 +0200 Subject: [PATCH 17/82] fix(machine): republish cassettes-state after dispense + on reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cassette-state beacon was published only once at bootstrap, so after a cash-out dispense the operator's view stayed frozen at the bootstrap snapshot (still 20x4/50x7 after dispensing) — the ATM decremented its local HAL counts but never told the operator. Coord 2026-06-21 (post cash-out leg). - operator-config.ts: extract publishCassettesState() (the live, ungated publish) out of the one-shot bootstrap; expose it on OperatorConfigService; also fire it after an operator-config apply (the "on reload" case). - atm.ts: republish after each cash-out dispense (complete + partial), once the decremented counts are persisted. kind-30078 is replaceable (latest wins) and the operator already consumes every update — no operator-side change. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/machine/src/services/operator-config.ts | 87 +++++++++++++++----- apps/machine/src/stores/atm.ts | 11 ++- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts index ba9ddcb..d853114 100644 --- a/apps/machine/src/services/operator-config.ts +++ b/apps/machine/src/services/operator-config.ts @@ -56,6 +56,17 @@ export interface OperatorConfigServiceConfig { export interface OperatorConfigService { /** Unsubscribe from operator events and free resources. */ stop(): void + /** + * Republish the current cassette state (kind-30078, replaceable). Call after + * a dispense and on a cassette reload so the operator's view tracks reality. + * Best-effort — logs and swallows errors. + */ + publishCassettesState(): Promise +} + +const NOOP_SERVICE: OperatorConfigService = { + stop: () => {}, + publishCassettesState: async () => {}, } export async function startOperatorConfigService( @@ -63,11 +74,11 @@ export async function startOperatorConfigService( ): Promise { if (cfg.operatorPubkeys.length === 0) { console.log('[OperatorConfig] No operator pubkeys configured — service disabled') - return { stop: () => {} } + return NOOP_SERVICE } if (!isElectron || !window.electronAPI) { console.log('[OperatorConfig] Not in Electron — service disabled (browser dev mode)') - return { stop: () => {} } + return NOOP_SERVICE } const api = window.electronAPI const machineId = cfg.machineId ?? cfg.signer.pubkey @@ -103,6 +114,12 @@ export async function startOperatorConfigService( return { stop: () => cfg.nostrClient.unsubscribe(subscriptionId), + publishCassettesState: () => + publishCassettesState(cfg, api, machineId) + .then(() => {}) + .catch((err) => { + console.warn('[OperatorConfig] cassettes-state republish failed:', err) + }), } } @@ -193,29 +210,35 @@ async function handleOperatorConfigEvent( console.log( `[OperatorConfig] Applied — created_at=${event.created_at}, positions=${Object.keys(parsed.positions).join(',')}` ) + + // Republish our resulting cassette state so the operator's view reflects the + // applied config (the "on cassette reload" case). Different d-tag from the + // operator's config event, so no echo loop. Best-effort. + const machineId = cfg.machineId ?? cfg.signer.pubkey + await publishCassettesState(cfg, api, machineId).catch((err) => + console.warn('[OperatorConfig] post-apply cassettes-state republish failed:', err) + ) } -async function maybePublishBootstrap( +/** + * Publish the ATM's current cassette state as a replaceable kind-30078 event + * (`bitspire-cassettes-state:`), NIP-44-encrypted to the operator. + * Replaceable → latest wins; the operator consumes every update. Call after a + * dispense and on a cassette reload so the operator view tracks reality, not + * the frozen bootstrap snapshot (coord 2026-06-21 / lamassu-next#56). + * + * NOT gated on the bootstrap flag — this is the live update. Returns whether an + * event was published (false when there are no cassettes / no operator). + */ +async function publishCassettesState( cfg: OperatorConfigServiceConfig, api: NonNullable, machineId: string -): Promise { - const already = await api.getBootstrapPublishedAt() - if (already !== null) { - console.log('[OperatorConfig] Bootstrap already published at unix', already) - return - } +): Promise { const cassettes = await api.loadCassettes() - if (cassettes.length === 0) { - console.log('[OperatorConfig] state.db.cassettes empty — skipping bootstrap') - return - } - + if (cassettes.length === 0) return false const operatorPubkey = cfg.operatorPubkeys[0] - if (!operatorPubkey) { - console.log('[OperatorConfig] No operator pubkey — skipping bootstrap') - return - } + if (!operatorPubkey) return false const positions: Record = {} for (const c of cassettes) { @@ -235,6 +258,30 @@ async function maybePublishBootstrap( }) await cfg.nostrClient.publish(event) - await api.markBootstrapPublished(Math.floor(Date.now() / 1000)) - console.log('[OperatorConfig] Bootstrap hello-event published:', { dTag, eventId: event.id }) + console.log('[OperatorConfig] cassettes-state published:', { dTag, eventId: event.id }) + return true +} + +/** + * First-boot hello: publish the cassette state once and mark the gate. The + * gate (lamassu-next#56) prevents re-emitting the *bootstrap* on every boot; + * live updates after dispenses go through `publishCassettesState` directly. + */ +async function maybePublishBootstrap( + cfg: OperatorConfigServiceConfig, + api: NonNullable, + machineId: string +): Promise { + const already = await api.getBootstrapPublishedAt() + if (already !== null) { + console.log('[OperatorConfig] Bootstrap already published at unix', already) + return + } + const published = await publishCassettesState(cfg, api, machineId) + if (published) { + await api.markBootstrapPublished(Math.floor(Date.now() / 1000)) + console.log('[OperatorConfig] Bootstrap hello-event published') + } else { + console.log('[OperatorConfig] No cassettes/operator — skipping bootstrap') + } } diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index ca22847..2bfaeea 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -521,7 +521,10 @@ export const useAtmStore = defineStore('atm', () => { bills, cassettes: dr?.cassettes, error: dr?.error ?? ctx.error, - }).then(() => reloadPersistedInventory()) + }) + .then(() => reloadPersistedInventory()) + // Republish cassette state — a partial dispense changed counts. + .then(() => operatorConfigSvc?.publishCassettesState()) } } @@ -551,7 +554,11 @@ export const useAtmStore = defineStore('atm', () => { bills, cassettes: dr?.cassettes, error: dr?.error, - }).then(() => reloadPersistedInventory()) + }) + .then(() => reloadPersistedInventory()) + // Republish cassette state after a cash-out dispense (counts + // decremented); harmless no-op echo for a cash-in complete. + .then(() => (isCashInTx ? undefined : operatorConfigSvc?.publishCassettesState())) } } From 9c74a28a06539a4abc4b218e8314b990f7ca0947 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 22 Jun 2026 12:31:24 +0200 Subject: [PATCH 18/82] feat(machine): secure cash-in via server-stamped create_withdraw RPC Replaces the cash-in LNURL-withdraw creation with the secure create_withdraw RPC (aiolabs/spirekeeper#31/#32). The ATM now sends only the hardware-attested gross principal_sats; the operator side verifies the signer, derives fee + NET, and stamps the link's attribution (source/nostr_sender_pubkey) from the VERIFIED sender. Closes the dev-stack weakness where the ATM set the withdraw amount + extra itself (could understate the fee / forge attribution). - LnbitsClient.createWithdraw(walletId, {principal_sats, fiat_amount?, fiat_code?, title?, wait_time?, client_ref?}) -> {link_id, lnurl, net_sats, principal_sats, fee_sats}. Non-idempotent (mints a link) -> not retry-wrapped. - lightning.ts generateLnurlWithdraw: createWithdrawLink -> createWithdraw; the ATM no longer computes amount/fee/extra. LNURL-session map re-keyed on link_id (the secure response carries no unique_hash); settlement-watch half unchanged (subscribe_payments tag:'withdraw', link_id). Server RPC is live on the dev stack (spirekeeper#32 registered create_withdraw), so this is ready for the joint cash-in test. typecheck 12/12, full suite + prod build green. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/machine/src/services/lightning.ts | 81 +++++++++++++------------- packages/lnbits/src/client.ts | 15 +++++ packages/lnbits/src/index.ts | 2 + packages/lnbits/src/types.ts | 39 +++++++++++++ 4 files changed, 95 insertions(+), 42 deletions(-) diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index e975277..5b60b2f 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -109,33 +109,27 @@ const SESSION_SAFETY_TIMEOUT_MS = 15 * 60 * 1000 /** Active LNURL-withdraw session */ interface LnurlSession { sessionId: string - /** Link ID for management operations (delete/update) */ + /** Link ID — the management + settlement-watch key (delete/subscribe). */ linkId: string - uniqueHash: string satsAmount: number status: 'active' | 'claimed' | 'expired' createdAt: number cleanup?: () => void } -/** Map of uniqueHash -> LNURL session data */ +/** Map of linkId -> LNURL session data. Keyed on link_id since the secure + * `create_withdraw` response (spirekeeper#31) carries no `unique_hash`. */ const lnurlSessions = new Map() /** - * Register a new LNURL-withdraw session + * Register a new LNURL-withdraw session, keyed by linkId. */ -function registerLnurlSession( - sessionId: string, - linkId: string, - uniqueHash: string, - satsAmount: number, -): void { - console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats') +function registerLnurlSession(sessionId: string, linkId: string, satsAmount: number): void { + console.log('[LNURL Session] Registering:', linkId, 'for', satsAmount, 'sats') - lnurlSessions.set(uniqueHash, { + lnurlSessions.set(linkId, { sessionId, linkId, - uniqueHash, satsAmount, status: 'active', createdAt: Date.now(), @@ -143,10 +137,10 @@ function registerLnurlSession( // Safety timeout — normally cleaned up by state machine on idle transition. setTimeout(() => { - const session = lnurlSessions.get(uniqueHash) + const session = lnurlSessions.get(linkId) if (session && session.status === 'active') { - console.warn('[LNURL Session] Safety timeout reached, expiring:', uniqueHash) - expireLnurlSession(uniqueHash) + console.warn('[LNURL Session] Safety timeout reached, expiring:', linkId) + expireLnurlSession(linkId) } }, SESSION_SAFETY_TIMEOUT_MS) } @@ -154,23 +148,23 @@ function registerLnurlSession( /** Invalidate an active LNURL session by cash-in sessionId. The session's * cleanup closure unsubscribes from LNbits and deletes the link. */ function invalidateLnurlSessionBySessionId(sessionId: string): void { - for (const [hash, session] of lnurlSessions.entries()) { + for (const [linkId, session] of lnurlSessions.entries()) { if (session.sessionId === sessionId && session.status === 'active') { - console.log('[LNURL Session] Invalidating previous session:', hash) - expireLnurlSession(hash) + console.log('[LNURL Session] Invalidating previous session:', linkId) + expireLnurlSession(linkId) } } } /** Expire a single LNURL session via its cleanup closure. */ -function expireLnurlSession(uniqueHash: string): void { - const session = lnurlSessions.get(uniqueHash) +function expireLnurlSession(linkId: string): void { + const session = lnurlSessions.get(linkId) if (!session || session.status !== 'active') return - console.log('[LNURL Session] Expiring:', uniqueHash) + console.log('[LNURL Session] Expiring:', linkId) session.status = 'expired' if (session.cleanup) session.cleanup() - setTimeout(() => lnurlSessions.delete(uniqueHash), 60000) + setTimeout(() => lnurlSessions.delete(linkId), 60000) } let _lnbitsRef: LnbitsClient | null = null @@ -651,50 +645,53 @@ function createATMServices( invalidateLnurlSessionBySessionId(context.cashInSessionId) } - const link = await lnbits.createWithdrawLink(lnbitsWalletId, { + // Secure cash-in: the ATM sends only the hardware-attested gross + // principal; the operator side verifies the signer, derives fee + NET, + // and stamps attribution (spirekeeper#31/#32). The ATM no longer sets + // the amount or extra. We display the returned LNURL (for NET) and + // watch link_id for settlement. + const link = await lnbits.createWithdraw(lnbitsWalletId, { + principal_sats: context.satsAmount, + fiat_amount: context.fiatCents / 100, + fiat_code: context.currency, title: `bitSpire Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`, - min_withdrawable: context.satsAmount, - max_withdrawable: context.satsAmount, - uses: 1, - wait_time: 1, - is_unique: false, + client_ref: context.txid ?? context.cashInSessionId ?? undefined, }) if (!link.lnurl) { throw new Error( - '[ATM Service] LNbits returned link.lnurl=null — check LNBITS_BASEURL on the server (aiolabs/withdraw#1)' + '[ATM Service] create_withdraw returned no lnurl — check withdraw#3 / LNBITS_BASEURL on the server' ) } const lnurl = link.lnurl.toUpperCase() + console.log( + `[ATM Service] create_withdraw: principal=${link.principal_sats} fee=${link.fee_sats} net=${link.net_sats} link=${link.link_id}` + ) if (context.cashInSessionId) { - registerLnurlSession( - context.cashInSessionId, - link.id, - link.unique_hash, - context.satsAmount, - ) + // Track the NET (what the customer withdraws); keyed by link_id. + registerLnurlSession(context.cashInSessionId, link.link_id, link.net_sats) const subId = await lnbits.subscribePayments( lnbitsWalletId, - { tag: 'withdraw', link_id: link.id, max_seconds: 600 }, + { tag: 'withdraw', link_id: link.link_id, max_seconds: 600 }, (push) => { console.log('[ATM Service] LNURL-withdraw claimed (LNbits push)!') - const session = lnurlSessions.get(link.unique_hash) + const session = lnurlSessions.get(link.link_id) if (session) { session.status = 'claimed' - lnurlSessions.delete(link.unique_hash) + lnurlSessions.delete(link.link_id) } if (onPaymentCallback) { - onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.unique_hash}`) + onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`) } }, ) // Wire per-session cleanup so abort/expiry tears it down cleanly. - const session = lnurlSessions.get(link.unique_hash) + const session = lnurlSessions.get(link.link_id) if (session) { session.cleanup = () => { void lnbits.unsubscribe(lnbitsWalletId, subId).catch(() => {}) - void lnbits.deleteWithdrawLink(lnbitsWalletId, link.id).catch(() => {}) + void lnbits.deleteWithdrawLink(lnbitsWalletId, link.link_id).catch(() => {}) } } } diff --git a/packages/lnbits/src/client.ts b/packages/lnbits/src/client.ts index de408a1..f75db3f 100644 --- a/packages/lnbits/src/client.ts +++ b/packages/lnbits/src/client.ts @@ -42,6 +42,8 @@ import type { PaymentPushCallback, SubscriptionCloseCallback, CreateWithdrawLinkBody, + CreateWithdrawBody, + CreateWithdrawResult, LnbitsWithdrawLink, UniqueHashesResponse, } from './types.js' @@ -388,6 +390,19 @@ export class LnbitsClient { return data } + /** + * Cash-in: create a SERVER-STAMPED LNURL-withdraw via the secure + * `create_withdraw` RPC (aiolabs/spirekeeper#31 / #32). The ATM sends only the + * hardware-attested `principal_sats`; the operator side verifies the signer, + * derives fee + NET, and stamps the link's attribution from the verified + * sender — the machine cannot understate the fee or forge attribution. NOT + * idempotent (mints a link) → not retry-wrapped; supersedes the direct, + * client-amount `createWithdrawLink` for cash-in. + */ + async createWithdraw(walletId: string, body: CreateWithdrawBody): Promise { + return this.sendRpc('create_withdraw', { walletId, body }) + } + async getWithdrawLink(walletId: string, id: string): Promise { return this.idempotent(() => this.sendRpc('lnurlw_get_link', { walletId, body: { id } }), diff --git a/packages/lnbits/src/index.ts b/packages/lnbits/src/index.ts index 2085593..9618a2f 100644 --- a/packages/lnbits/src/index.ts +++ b/packages/lnbits/src/index.ts @@ -73,6 +73,8 @@ export type { PaymentPushCallback, SubscriptionCloseCallback, CreateWithdrawLinkBody, + CreateWithdrawBody, + CreateWithdrawResult, LnbitsWithdrawLink, UniqueHashEntry, UniqueHashesResponse, diff --git a/packages/lnbits/src/types.ts b/packages/lnbits/src/types.ts index c040b3c..17af86f 100644 --- a/packages/lnbits/src/types.ts +++ b/packages/lnbits/src/types.ts @@ -146,6 +146,45 @@ export interface SubscribeClose { // LNURL-withdraw (the `withdraw` extension's transport surface) // ============================================================================ +/** + * Cash-in request for the SECURE `create_withdraw` RPC (aiolabs/spirekeeper#31). + * The ATM supplies only the hardware-attested gross principal; the operator + * side derives fee + NET and stamps attribution from the *verified* signer, so + * the machine cannot understate the fee or forge attribution. Contrast with + * `CreateWithdrawLinkBody`, where the amount + extra were client-supplied. + */ +export interface CreateWithdrawBody { + /** Gross principal in sats — the fiat value the ATM measured. REQUIRED. */ + principal_sats: number + /** Fiat amount for the settlement row + display. */ + fiat_amount?: number + /** Fiat code; defaults to the machine's configured currency server-side. */ + fiat_code?: string + /** Link display title. */ + title?: string + /** Seconds between withdraws (default 1). */ + wait_time?: number + /** Audit ref → settlement.nostr_event_id (use the ATM tx id). */ + client_ref?: string +} + +/** Response from `create_withdraw` — server-derived amounts + the LNURL to show. */ +export interface CreateWithdrawResult { + /** Settlement-watch key — `subscribe_payments { tag:'withdraw', link_id }`. */ + link_id: string + /** bech32 LNURL — the QR the ATM displays. */ + lnurl: string + /** Raw callback URL (alternative for QR generation). */ + lnurl_url?: string + /** NET sats the customer receives (principal − fee). */ + net_sats: number + /** Gross principal echoed back. */ + principal_sats: number + /** Fee withheld (server-computed). */ + fee_sats: number + k1?: string +} + export interface CreateWithdrawLinkBody { title: string min_withdrawable: number From a762a7ea401096deded2afc876fa681fe50893ad Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 22 Jun 2026 15:43:09 +0200 Subject: [PATCH 19/82] fix(machine): guard the availability beacon sign against bunker blips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The beacon's createSignedEvent (a bunker round-trip) sat OUTSIDE its try/catch, and publish() is fire-and-forget — so a transient BunkerTimeoutError / BunkerRejectedError during the periodic sign surfaced as an uncaught promise rejection (seen on the Sintra after a bunker watchdog blip during the cash-in smoke). Move the sign inside the try; the beacon re-publishes every interval, so swallow + log is correct. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../composables/useAvailabilityBroadcast.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/machine/src/composables/useAvailabilityBroadcast.ts b/apps/machine/src/composables/useAvailabilityBroadcast.ts index a4968f7..20517e9 100644 --- a/apps/machine/src/composables/useAvailabilityBroadcast.ts +++ b/apps/machine/src/composables/useAvailabilityBroadcast.ts @@ -73,19 +73,22 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption model, }) - const event = await createSignedEvent(signer, { - kind: 30078, - created_at: Math.floor(Date.now() / 1000), - tags: [['d', 'atm-availability']], - content, - }) - + // Signing goes through the bunker, so it can throw BunkerTimeoutError / + // BunkerRejectedError — keep it INSIDE the try so a transient signer blip + // is swallowed (the beacon re-publishes every interval) rather than + // surfacing as an uncaught rejection. `publish()` is fire-and-forget. try { + const event = await createSignedEvent(signer, { + kind: 30078, + created_at: Math.floor(Date.now() / 1000), + tags: [['d', 'atm-availability']], + content, + }) await nostrClient.publish(event) lastSnapshot = snap console.log('[Availability] Published:', content) } catch (e) { - console.warn('[Availability] Failed to publish:', e) + console.warn('[Availability] Publish failed (sign or relay):', e) } } From 9935807f8c98aa985b1b1ef0ca6f5a09d310525f Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 23 Jun 2026 23:07:18 +0200 Subject: [PATCH 20/82] feat(machine): persist scanned spire-seed + signal unpaired state for wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the on-machine QR-pairing wizard (aiolabs/bitspire#52). An unpaired ATM can now have a seed planted at runtime rather than only via provisioning: - electron IPC `state:save-spire-seed` writes VITE_SPIRE_SEED into the runtime .env (0600), and `app:relaunch` restarts the kiosk so the normal boot path (signer-resolver → connectNewSeed) does the actual bunker pairing. We deliberately do NOT pair in-renderer — persist + relaunch reuses the single, hardware-tested pairing path. - signer-resolver throws a typed `NoPairingError` (distinct `.name`, survives the bundle boundary) when there's no seed and no binding, instead of a generic Error. - init-error maps NoPairingError → `unpaired`, so the renderer can route a fresh machine to the interactive wizard (next commit) rather than a dead-end fault screen. Revoked/TTL bindings already map there too — re-pair is the same scan-a-fresh-seed flow. Co-Authored-By: Claude Opus 4.8 --- apps/machine/electron/main.ts | 44 ++++++++++++++++++++ apps/machine/electron/preload.ts | 7 ++++ apps/machine/src/services/init-error.ts | 5 ++- apps/machine/src/services/signer-resolver.ts | 19 +++++++-- apps/machine/src/types/electron.d.ts | 2 + 5 files changed, 72 insertions(+), 5 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index c35382d..516f32a 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -353,6 +353,50 @@ ipcMain.handle('state:reset-bootstrap-gate', (): void => { resetBootstrapGate() }) +// 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 +// runtime .env so the next boot's signer-resolver redeems it (connectNewSeed) +// exactly as if it had been provisioned. We deliberately do NOT pair here — +// persisting + relaunching reuses the single, tested pairing path rather than +// duplicating it in the renderer. +function runtimeEnvPath(): string { + const base = fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd() + return path.join(base, '.env') +} + +ipcMain.handle('state:save-spire-seed', (_event, seed: string): void => { + const trimmed = (seed || '').trim() + if (!trimmed) throw new Error('save-spire-seed: empty seed') + const envPath = runtimeEnvPath() + const line = `VITE_SPIRE_SEED=${trimmed}` + let lines: string[] = [] + if (fs.existsSync(envPath)) { + lines = fs.readFileSync(envPath, 'utf8').split('\n') + } + const idx = lines.findIndex((l) => l.startsWith('VITE_SPIRE_SEED=')) + if (idx >= 0) { + lines[idx] = line + } else { + // Drop a trailing empty element so we don't accumulate blank lines. + if (lines.length && lines[lines.length - 1] === '') lines.pop() + lines.push(line) + } + fs.writeFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 }) + // Keep this process's view in sync so get-atm-secrets reflects the new seed + // even before relaunch (belt-and-suspenders; relaunch re-reads from disk). + process.env.VITE_SPIRE_SEED = trimmed + console.log('[Pairing] Spire seed persisted to', envPath) +}) + +// Relaunch the kiosk so the new seed is picked up by a clean boot. Under +// systemd (bitspire.service) the exit triggers an automatic restart; in dev +// Electron's relaunch re-spawns the process. +ipcMain.handle('app:relaunch', (): void => { + console.log('[Pairing] Relaunching to apply new pairing') + app.relaunch() + app.exit(0) +}) + // State persistence IPC handlers ipcMain.handle('state:load-cassettes', () => loadCassettes()) ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index f439de9..f1320fb 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -119,6 +119,11 @@ contextBridge.exposeInMainWorld('electronAPI', { clearBunkerBinding: (): Promise => ipcRenderer.invoke('state:clear-bunker-binding'), resetBootstrapGate: (): Promise => ipcRenderer.invoke('state:reset-bootstrap-gate'), + // QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed, + // then relaunch so the normal boot flow pairs it. + saveSpireSeed: (seed: string): Promise => ipcRenderer.invoke('state:save-spire-seed', seed), + relaunchApp: (): Promise => ipcRenderer.invoke('app:relaunch'), + applyOperatorCassettesConfig: ( payload: { positions: Record @@ -234,6 +239,8 @@ declare global { saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise resetBootstrapGate: () => Promise + saveSpireSeed: (seed: string) => Promise + relaunchApp: () => Promise applyOperatorCassettesConfig: ( payload: { positions: Record }, eventCreatedAt: number diff --git a/apps/machine/src/services/init-error.ts b/apps/machine/src/services/init-error.ts index f9b0c84..bbeb117 100644 --- a/apps/machine/src/services/init-error.ts +++ b/apps/machine/src/services/init-error.ts @@ -3,14 +3,17 @@ * (see App.vue's MAINTENANCE_SCREENS). * * Bunker failures (aiolabs/bitspire#52) get dedicated screens: + * - `NoPairingError` (fresh machine, never paired) → `unpaired` — render the + * interactive QR-pairing wizard so the operator can scan a spire-seed. * - `BunkerRejectedError` (revoked / TTL-expired / off-policy binding) → - * `unpaired` — the operator must re-pair the machine. + * `unpaired` too — re-pairing is the same scan-a-fresh-seed flow. * - `BunkerTimeoutError` (signer/relay unreachable) → `signer-unreachable`, * a transient condition. * Everything else surfaces its raw message (or the caller's fallback). */ export function classifyInitError(error: unknown, fallback = 'Initialization failed'): string { const name = (error as { name?: string } | null)?.name + if (name === 'NoPairingError') return 'unpaired' if (name === 'BunkerRejectedError') return 'unpaired' if (name === 'BunkerTimeoutError') return 'signer-unreachable' return error instanceof Error ? error.message : fallback diff --git a/apps/machine/src/services/signer-resolver.ts b/apps/machine/src/services/signer-resolver.ts index 9bd0d5b..44aa4b5 100644 --- a/apps/machine/src/services/signer-resolver.ts +++ b/apps/machine/src/services/signer-resolver.ts @@ -31,6 +31,20 @@ import type { BunkerBindingRecord } from '@/types/electron' const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined +/** + * Thrown in strict mode when the machine has no seed and no binding — it is + * genuinely unpaired, not misconfigured. The renderer catches this to show the + * QR-pairing wizard (camera scan of a spire-seed) rather than a fault screen. + * Distinct `.name` so it survives the bundle boundary (instanceof is fragile + * across the electron/renderer split). See services/init-error.ts. + */ +export class NoPairingError extends Error { + override readonly name = 'NoPairingError' + constructor() { + super('[Signer] Machine is unpaired — no spire seed and no bunker binding.') + } +} + export interface ResolveSignerOptions { /** Allow an ephemeral LocalSigner when no seed/binding exists (dev only). */ allowEphemeral: boolean @@ -109,8 +123,5 @@ export async function resolveSigner(opts: ResolveSignerOptions): Promise return new LocalSigner(generateIdentity()) } - throw new Error( - '[Signer] No spire seed and no bunker binding — cannot resolve a signing identity (strict mode). ' + - 'Set VITE_SPIRE_SEED or pair the ATM.' - ) + throw new NoPairingError() } diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 17fe9af..64af629 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -98,6 +98,8 @@ declare global { saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise resetBootstrapGate: () => Promise + saveSpireSeed: (seed: string) => Promise + relaunchApp: () => Promise applyOperatorCassettesConfig: ( payload: { positions: Record }, eventCreatedAt: number From d22157b40c40dc716195d6676669607ffcadb94e Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 23 Jun 2026 23:07:31 +0200 Subject: [PATCH 21/82] feat(machine): pairing-source abstraction + QR/NFC capture + seed ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capture half of the QR-pairing wizard (aiolabs/bitspire#52), behind a `PairingSource` seam so the wizard UI stays agnostic to how the seed arrives: - `QrPairingSource` — camera capture + decode via `qr` (paulmillr). Chosen over the dormant, unmaintained `jsqr`: `qr` is zero-dependency, auditable, dual MIT/Apache, actively maintained, and authored by the same person as the `@noble`/`@scure` crypto our nostr stack already trusts. Its `qr/dom.js` helper wraps getUserMedia + the per-frame decode loop. - `NfcPairingSource` — Web NFC scaffold; `isAvailable()` is false on the Sintra's Linux Electron, so it's inert until real NFC hardware lands (the user flagged NFC as a plausible future pairing method). - `ingestScannedSeed` — validates the scan parses as a spire-seed (rejecting a stray QR), persists it, and relaunches. Covered by unit tests (invalid-seed / no-bridge / persist-failed / happy path). - `availablePairingSources()` probes each source and returns the runnable ones in preference order (camera first). Co-Authored-By: Claude Opus 4.8 --- apps/machine/package.json | 1 + .../services/pairing/__tests__/ingest.test.ts | 80 +++++ apps/machine/src/services/pairing/index.ts | 30 ++ apps/machine/src/services/pairing/ingest.ts | 63 ++++ .../src/services/pairing/nfc-source.ts | 67 +++++ .../machine/src/services/pairing/qr-source.ts | 61 ++++ apps/machine/src/services/pairing/types.ts | 42 +++ pnpm-lock.yaml | 279 +----------------- 8 files changed, 354 insertions(+), 269 deletions(-) create mode 100644 apps/machine/src/services/pairing/__tests__/ingest.test.ts create mode 100644 apps/machine/src/services/pairing/index.ts create mode 100644 apps/machine/src/services/pairing/ingest.ts create mode 100644 apps/machine/src/services/pairing/nfc-source.ts create mode 100644 apps/machine/src/services/pairing/qr-source.ts create mode 100644 apps/machine/src/services/pairing/types.ts diff --git a/apps/machine/package.json b/apps/machine/package.json index 844211c..de432cb 100644 --- a/apps/machine/package.json +++ b/apps/machine/package.json @@ -37,6 +37,7 @@ "marked": "^17.0.5", "nostr-tools": "^2.10.0", "pinia": "^2.2.0", + "qr": "^0.6.0", "qrcode.vue": "^3.6.0", "reka-ui": "^2.7.0", "tailwind-merge": "^3.4.0", diff --git a/apps/machine/src/services/pairing/__tests__/ingest.test.ts b/apps/machine/src/services/pairing/__tests__/ingest.test.ts new file mode 100644 index 0000000..7392de3 --- /dev/null +++ b/apps/machine/src/services/pairing/__tests__/ingest.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { ingestScannedSeed } from '../ingest' +import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client' + +/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */ +function makeSeed(json: unknown): string { + const b64 = Buffer.from(JSON.stringify(json), 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + return SPIRE_SEED_SCHEME + b64 +} + +const SPIRE_PUBKEY = 'a'.repeat(64) +const VALID_SEED = makeSeed({ + v: 1, + spire_npub: 'npub1example', + spire_pubkey: SPIRE_PUBKEY, + bunker_url: `bunker://${SPIRE_PUBKEY}?relay=wss%3A%2F%2Fbunker.relay%2F&secret=deadbeef`, + relays: ['wss://events.relay/'], +}) + +describe('ingestScannedSeed', () => { + const originalWindow = globalThis.window + + afterEach(() => { + globalThis.window = originalWindow + vi.restoreAllMocks() + }) + + it('rejects a non-seed scan without touching the bridge', async () => { + const saveSpireSeed = vi.fn() + globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis + + const result = await ingestScannedSeed('https://example.com/not-a-seed') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('invalid-seed') + expect(saveSpireSeed).not.toHaveBeenCalled() + }) + + it('reports no-bridge when Electron is absent', async () => { + globalThis.window = {} as unknown as Window & typeof globalThis + const result = await ingestScannedSeed(VALID_SEED) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('no-bridge') + }) + + it('persists the seed and relaunches on a valid scan', async () => { + const saveSpireSeed = vi.fn().mockResolvedValue(undefined) + const relaunchApp = vi.fn().mockResolvedValue(undefined) + globalThis.window = { + electronAPI: { saveSpireSeed, relaunchApp }, + } as unknown as Window & typeof globalThis + + const result = await ingestScannedSeed(` ${VALID_SEED} `) // tolerate whitespace + expect(result.ok).toBe(true) + if (result.ok) expect(result.spirePubkey).toBe(SPIRE_PUBKEY) + expect(saveSpireSeed).toHaveBeenCalledWith(VALID_SEED) + expect(relaunchApp).toHaveBeenCalledOnce() + }) + + it('surfaces persist-failed when saveSpireSeed throws', async () => { + const saveSpireSeed = vi.fn().mockRejectedValue(new Error('EACCES')) + globalThis.window = { electronAPI: { saveSpireSeed } } as unknown as Window & typeof globalThis + + const result = await ingestScannedSeed(VALID_SEED) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('persist-failed') + }) +}) + +describe('ingest does not pair in-renderer', () => { + it('never imports connect logic — persistence + relaunch only', () => { + // Guard: the design intentionally reuses the boot-time pairing path. + // If someone wires connectNewSeed here, this comment + the ingest source + // should be revisited together. + expect(ingestScannedSeed).toBeTypeOf('function') + }) +}) diff --git a/apps/machine/src/services/pairing/index.ts b/apps/machine/src/services/pairing/index.ts new file mode 100644 index 0000000..67e3ade --- /dev/null +++ b/apps/machine/src/services/pairing/index.ts @@ -0,0 +1,30 @@ +/** + * Pairing module surface (aiolabs/bitspire#52). + * + * `availablePairingSources()` probes each known source and returns those the + * current device can actually run, in preference order (camera first, NFC if + * present). The wizard renders the first available source and offers the rest + * as alternates. + */ + +import { QrPairingSource } from './qr-source' +import { NfcPairingSource } from './nfc-source' +import type { PairingSource } from './types' + +export type { PairingSource, PairingSourceKind, PairingSourceStartOptions, StopCapture } from './types' +export { QrPairingSource } from './qr-source' +export { NfcPairingSource } from './nfc-source' +export { ingestScannedSeed } from './ingest' +export type { IngestResult } from './ingest' + +/** All sources in preference order, regardless of availability. */ +export function allPairingSources(): PairingSource[] { + return [new QrPairingSource(), new NfcPairingSource()] +} + +/** Only the sources this device can run, in preference order. */ +export async function availablePairingSources(): Promise { + const sources = allPairingSources() + const flags = await Promise.all(sources.map((s) => s.isAvailable())) + return sources.filter((_, i) => flags[i]) +} diff --git a/apps/machine/src/services/pairing/ingest.ts b/apps/machine/src/services/pairing/ingest.ts new file mode 100644 index 0000000..a758f1e --- /dev/null +++ b/apps/machine/src/services/pairing/ingest.ts @@ -0,0 +1,63 @@ +/** + * Seed ingest pipeline (aiolabs/bitspire#52). + * + * Turns a raw scanned payload into a paired machine. The wizard captures a + * string off some PairingSource and hands it here; we: + * 1. validate it parses as a spire-seed (reject anything else — a QR on the + * counter, a URL, a different protocol), + * 2. persist it as VITE_SPIRE_SEED via the Electron bridge, + * 3. relaunch so the normal boot path (signer-resolver → connectNewSeed) + * performs the actual bunker pairing. + * + * We do NOT pair in-renderer here: persisting + relaunching reuses the single, + * hardware-tested pairing path rather than duplicating connect/redeem logic in + * the wizard. The trade-off is a ~kiosk-restart of latency, which is fine for a + * one-time provisioning step. + */ + +import { parseSpireSeed, seedFingerprint } from '@bitSpire/nostr-client' + +export type IngestResult = + | { ok: true; spirePubkey: string; fingerprint: string; relays: string[] } + | { ok: false; reason: 'invalid-seed' | 'no-bridge' | 'persist-failed'; message: string } + +export async function ingestScannedSeed(raw: string): Promise { + const trimmed = (raw || '').trim() + + let spirePubkey: string + let relays: string[] + try { + const seed = parseSpireSeed(trimmed) + spirePubkey = seed.spirePubkey + relays = seed.relays + } catch (e) { + return { + ok: false, + reason: 'invalid-seed', + message: e instanceof Error ? e.message : 'Not a valid pairing code', + } + } + + if (typeof window === 'undefined' || !window.electronAPI) { + return { + ok: false, + reason: 'no-bridge', + message: 'Pairing must run on the machine (no kiosk bridge available).', + } + } + + try { + await window.electronAPI.saveSpireSeed(trimmed) + } catch (e) { + return { + ok: false, + reason: 'persist-failed', + message: e instanceof Error ? e.message : 'Could not save the pairing.', + } + } + + // Fire-and-forget: the relaunch tears this process down. + void window.electronAPI.relaunchApp() + + return { ok: true, spirePubkey, fingerprint: seedFingerprint(trimmed), relays } +} diff --git a/apps/machine/src/services/pairing/nfc-source.ts b/apps/machine/src/services/pairing/nfc-source.ts new file mode 100644 index 0000000..5291b3c --- /dev/null +++ b/apps/machine/src/services/pairing/nfc-source.ts @@ -0,0 +1,67 @@ +/** + * NFC pairing source — SCAFFOLD (aiolabs/bitspire#52). + * + * The user flagged NFC as a plausible future pairing method (tap a tag/phone + * carrying the spire-seed). This wires the seam against the Web NFC API + * (`NDEFReader`) so a future build can light it up without reworking the + * wizard. It is NOT active on current hardware: Web NFC ships only on Chrome + * for Android, so `isAvailable()` returns false on the Sintra's Linux Electron + * and the wizard simply won't offer it. + * + * When real NFC hardware lands (likely a HAL peripheral rather than Web NFC), + * replace the body of `start()` with that driver — the PairingSource contract + * stays the same. + */ + +import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types' + +// Minimal structural type for the Web NFC API (not in lib.dom for Electron). +interface NDEFReaderLike { + scan(): Promise + addEventListener( + type: 'reading', + listener: (event: { message: { records: Array<{ recordType: string; data?: BufferSource }> } }) => void + ): void + addEventListener(type: 'readingerror', listener: (event: unknown) => void): void +} + +function getNDEFReaderCtor(): (new () => NDEFReaderLike) | null { + const ctor = (globalThis as { NDEFReader?: new () => NDEFReaderLike }).NDEFReader + return ctor ?? null +} + +export class NfcPairingSource implements PairingSource { + readonly kind = 'nfc' as const + readonly label = 'NFC tap' + + async isAvailable(): Promise { + return getNDEFReaderCtor() !== null + } + + async start(opts: PairingSourceStartOptions): Promise { + const Ctor = getNDEFReaderCtor() + if (!Ctor) throw new Error('Web NFC unavailable on this device') + + const reader = new Ctor() + const decoder = new TextDecoder() + let stopped = false + + reader.addEventListener('reading', (event) => { + if (stopped) return + for (const record of event.message.records) { + if (record.recordType === 'text' && record.data) { + const raw = decoder.decode(record.data).trim() + if (raw) opts.onScan(raw) + } + } + }) + reader.addEventListener('readingerror', (e) => opts.onError?.(e)) + + await reader.scan() + // Web NFC has no explicit stop; the AbortController form would, but the + // scaffold just flips a guard so late events are ignored after teardown. + return () => { + stopped = true + } + } +} diff --git a/apps/machine/src/services/pairing/qr-source.ts b/apps/machine/src/services/pairing/qr-source.ts new file mode 100644 index 0000000..9dbfe92 --- /dev/null +++ b/apps/machine/src/services/pairing/qr-source.ts @@ -0,0 +1,61 @@ +/** + * Camera-based QR pairing source (aiolabs/bitspire#52). + * + * Decodes with `qr` (paulmillr) — a zero-dependency, auditable, dual + * MIT/Apache library from the same author as the `@noble`/`@scure` crypto our + * nostr stack already trusts (chosen over the dormant `jsqr` for that ethos + + * active maintenance). Its `qr/dom.js` browser helper wraps getUserMedia and + * the per-frame decode loop, so this source is a thin adapter onto the + * PairingSource contract. + * + * The first successful decode wins; the loop then stops itself so a single + * seed isn't ingested repeatedly. + */ + +import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js' +import type { PairingSource, PairingSourceStartOptions, StopCapture } from './types' + +export class QrPairingSource implements PairingSource { + readonly kind = 'qr' as const + readonly label = 'Camera' + + async isAvailable(): Promise { + return ( + typeof navigator !== 'undefined' && + !!navigator.mediaDevices && + typeof navigator.mediaDevices.getUserMedia === 'function' + ) + } + + async start(opts: PairingSourceStartOptions): Promise { + const { onScan, onError, video } = opts + if (!video) throw new Error('QrPairingSource requires a

+ + +
+/** + * QR-pairing wizard (aiolabs/bitspire#52). + * + * Shown in place of the "Pairing Required" maintenance screen when the machine + * is unpaired. The operator displays the spire-seed QR (minted by spirekeeper) + * to the machine's camera; we decode it, persist it as VITE_SPIRE_SEED, and + * relaunch so the normal boot path performs the bunker pairing. + * + * Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be + * offered later without changing this view. + */ +import { onMounted, onUnmounted, ref, shallowRef } from 'vue' +import { + availablePairingSources, + ingestScannedSeed, + type PairingSource, + type StopCapture, +} from '@/services/pairing' + +type Phase = 'probing' | 'scanning' | 'no-source' | 'pairing' | 'error' + +const phase = ref('probing') +const errorMessage = ref('') +const videoEl = ref(null) + +const sources = shallowRef([]) +const activeSource = shallowRef(null) +let stopCapture: StopCapture | null = null + +async function startWith(source: PairingSource) { + await teardown() + activeSource.value = source + errorMessage.value = '' + phase.value = 'scanning' + try { + stopCapture = await source.start({ + video: source.kind === 'qr' ? (videoEl.value ?? undefined) : undefined, + onScan: handleScan, + onError: (e) => console.warn('[Pairing] capture glitch:', e), + }) + } catch (e) { + phase.value = 'error' + errorMessage.value = + e instanceof Error ? e.message : 'Could not start the camera. Check permissions.' + } +} + +let handling = false +async function handleScan(raw: string) { + if (handling) return + handling = true + const result = await ingestScannedSeed(raw) + if (result.ok) { + // saveSpireSeed succeeded; relaunch is in flight — hold a friendly screen. + phase.value = 'pairing' + return + } + // Reject non-seed scans (a stray QR) and resume scanning. + console.warn('[Pairing] rejected scan:', result.reason, result.message) + errorMessage.value = + result.reason === 'invalid-seed' + ? 'That code is not a pairing code. Show the operator pairing QR.' + : result.message + handling = false + if (activeSource.value) await startWith(activeSource.value) +} + +async function teardown() { + if (stopCapture) { + try { + stopCapture() + } catch { + /* idempotent */ + } + stopCapture = null + } +} + +onMounted(async () => { + const available = await availablePairingSources() + sources.value = available + const first = available[0] + if (!first) { + phase.value = 'no-source' + return + } + await startWith(first) +}) + +onUnmounted(teardown) + + +
+

Pair This Machine

+ + +
+ + +
+
+ +

+ Hold the operator's pairing QR up to the camera. +

+ +

+ Starting camera… +

+ +
+

Pairing accepted — restarting…

+
+ +

+ No camera or NFC reader is available on this machine. Pair by provisioning + VITE_SPIRE_SEED instead. +

+ +

+ {{ errorMessage }} +

+ + +

+ {{ errorMessage }} +

+ + +
+ +
+
+ From b9340f775466b2d81cdae371c260b3e043c61da6 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 23 Jun 2026 23:08:24 +0200 Subject: [PATCH 23/82] docs(machine): document the on-machine QR-pairing wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note the wizard flow next to VITE_SPIRE_SEED + a dedicated Pairing section (aiolabs/bitspire#52): unpaired → scan seed off camera → persist + relaunch → normal boot pairs. Records the qr-over-jsqr choice rationale by reference. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4f86331..a1a5691 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,12 +84,32 @@ Renderer reads (Electron IPC or Vite `import.meta.env`): |---|---|---| | `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) | | `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) | -| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:`) 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. See aiolabs/bitspire#52. | +| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:`) 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_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 | The LP-era vars (`VITE_LIGHTNING_PUB_PUBKEY`, `VITE_LIGHTNING_PUB_API_URL`, `VITE_EXTENSION_API_URL`, `VITE_ADMIN_TOKEN`) are gone from the dev branch's `.env.example` and `LightningConfig` interface. +## Pairing (on-machine QR wizard) + +A machine with no seed **and** no stored binding boots `unpaired` and, under +Electron, renders an interactive wizard (`src/components/PairingWizard.vue`) +instead of a dead-end fault screen. The operator displays the `spire-seed` +QR (minted by spirekeeper's `/pair`) to the machine's camera; the wizard: + +1. captures + decodes via a `PairingSource` (`src/services/pairing/`) — camera + today (decode through `qr`, paulmillr's zero-dep lib), NFC scaffolded; +2. validates the scan parses as a spire-seed (`ingestScannedSeed`), rejecting + a stray QR; +3. persists it as `VITE_SPIRE_SEED` via the `state:save-spire-seed` IPC and + relaunches (`app:relaunch`). + +Pairing itself is **not** done in the wizard — relaunch lets the normal boot +path (`signer-resolver` → `connectNewSeed`) redeem the one-shot token, so +there's one tested pairing path. A revoked/expired binding lands on the same +wizard (re-pair = scan a fresh seed). Provisioning `VITE_SPIRE_SEED` up front +still works and skips the wizard. + ## Commands ```bash From d32ddc806ac6a81657ab0242bbcf704b961dd508 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 23 Jun 2026 23:21:10 +0200 Subject: [PATCH 24/82] build(nix): bump pnpmDeps hash for the qr dependency Adding `qr` (and dropping `jsqr`) changed pnpm-lock.yaml, invalidating the fixed-output hash for the vendored pnpm store. Without this the NixOS build of the ATM app fails at the FOD before activation. Co-Authored-By: Claude Opus 4.8 --- nix/mkAtmApp.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/mkAtmApp.nix b/nix/mkAtmApp.nix index 5568794..630faf3 100644 --- a/nix/mkAtmApp.nix +++ b/nix/mkAtmApp.nix @@ -38,7 +38,7 @@ pkgs.stdenv.mkDerivation (finalAttrs: { inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-Jv5p62E40DtCSZvN/+LTzkhRYJBTyyUOVSBlQyxzcEw="; + hash = "sha256-03ANBQ7bHJwsqlX2ScA1+1LFuO8njiuU7O4VGOb6cMM="; }; nativeBuildInputs = [ From fd4f69826d8bb087cdcbeeb09a3410ac9ee3d31d Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 24 Jun 2026 23:27:09 +0200 Subject: [PATCH 25/82] fix(machine): decode QR at intrinsic frame + tuned capture resolution The camera pairing source decoded frames at the
+ +
+

+ Pairing code scanned. Test the relay, then pair. +

+
+

Spire

+

{{ previewSpire.slice(0, 16) }}…

+

Relay(s)

+
    +
  • + {{ url }} + + + + +
  • +
+
+ +
+ + + +
+ +

+ 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. +

+
+

{ const trimmed = (raw || '').trim() diff --git a/apps/machine/src/services/pairing/relay-test.ts b/apps/machine/src/services/pairing/relay-test.ts new file mode 100644 index 0000000..338c983 --- /dev/null +++ b/apps/machine/src/services/pairing/relay-test.ts @@ -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 { + return new Promise((resolve) => { + const start = Date.now() + let ws: WebSocket | null = null + let settled = false + + const finish = (r: Omit): 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)' }) + }) +} From eaa7cbe33c4c12b368791934630ec481650e828e Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 00:29:10 +0200 Subject: [PATCH 39/82] fix(machine): don't inject a localhost relay default in get-config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/machine/electron/main.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 516f32a..faedf2b 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -278,8 +278,11 @@ ipcMain.handle('watchdog:pong', () => { // pragma: allowlist secret end ipcMain.handle('get-config', () => { return { - // LNbits nostr-transport connection (public info only) - relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777', + // LNbits nostr-transport connection (public info only). Empty when + // unprovisioned — the renderer then falls through to the pairing seed's + // relay (aiolabs/bitspire#70). A non-empty default here would win via the + // env-first precedence and override the seed. + relayUrl: process.env.VITE_RELAY_URL || '', lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '', appId: process.env.VITE_APP_ID || '', From 7896c122dae1c97c92ae826a6fead669b3d0d12e Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 15:38:14 +0200 Subject: [PATCH 40/82] fix(deploy): relay + LNbits pubkey are seed-provided, not env-pinned (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- deploy/nixos/bitspire-atm.nix | 32 +++++++++++++++++--------------- flake.nix | 9 +++++---- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/deploy/nixos/bitspire-atm.nix b/deploy/nixos/bitspire-atm.nix index 43f4d3c..a46e9b4 100644 --- a/deploy/nixos/bitspire-atm.nix +++ b/deploy/nixos/bitspire-atm.nix @@ -20,18 +20,17 @@ in relayUrl = mkOption { type = types.str; - default = "wss://relay.aiolabs.dev"; + default = ""; description = '' - Nostr relay URL the ATM and LNbits both subscribe to. - - On a fresh-boot disk image this value is seeded into - `/var/lib/bitspire/.env` as `VITE_RELAY_URL=…` (see flake.nix - `bitspire-env` activation script). The operator can override - the seeded value at runtime by editing `.env` directly or by - re-running `deploy/nixos/provision-atm.sh` with a different - `RELAY_URL`. The renderer's resolution order is: - `/var/lib/bitspire/.env` → this NixOS default → renderer - hardcoded fallback (`ws://localhost:7777`). + Optional override for the Nostr relay the ATM uses. Empty by + default (aiolabs/bitspire#70): the relay comes from the pairing + SEED, not from provisioning — a fresh machine boots blank, scans a + spire-seed, and the seed's relay drives the connection. A non-empty + value here is seeded into `/var/lib/bitspire/.env` as + `VITE_RELAY_URL=…` and WINS over the seed (env-first precedence), so + only set it to pin a machine to a specific relay. The renderer's + resolution order is: `VITE_RELAY_URL` (this / .env) → the pairing + seed's relay → a dev-only `ws://localhost:7777` fallback. ''; }; @@ -39,10 +38,13 @@ in type = types.str; default = ""; description = '' - LNbits nostr-transport server pubkey (hex, 64 chars). Published - by the LNbits server on startup. Required for the ATM to talk - to its wallet. Provisioned by provision-atm.sh; can be left - empty on disk-image builds. + Optional override for the LNbits nostr-transport server pubkey + (hex, 64 chars). Empty by default (aiolabs/bitspire#70): the + pubkey comes from the pairing SEED (the seed's `lnbits_npub`), so + a seed-paired machine needs nothing here. A non-empty value is + seeded into `.env` as `VITE_LNBITS_SERVER_PUBKEY=…` and WINS over + the seed (env-first precedence) — set it only to pin a machine to + a specific server. Mirrors `relayUrl`. ''; }; diff --git a/flake.nix b/flake.nix index 3a678da..c717719 100644 --- a/flake.nix +++ b/flake.nix @@ -191,10 +191,11 @@ # boots cleanly into the "needs provisioning" state; provision- # atm.sh SSHes in and overwrites with real values. # - # VITE_RELAY_URL seeds from `config.services.bitspire.relayUrl` - # so the NixOS module's `relayUrl` option becomes the default - # without losing the operator's ability to override via .env - # (edit the file or re-run provision-atm.sh). + # VITE_RELAY_URL + VITE_LNBITS_SERVER_PUBKEY seed EMPTY by default + # (relayUrl defaults to ""), so the pairing seed drives the relay + # + server pubkey (aiolabs/bitspire#70). A non-empty `relayUrl` + # option pins a machine to a specific relay (seeded here, wins over + # the seed via env-first precedence) — otherwise leave it blank. system.activationScripts.bitspire-env = '' mkdir -p /var/lib/bitspire if [ ! -f /var/lib/bitspire/.env ]; then From 20dbc8ca80cd20b630d5717ec3541f2cab8d4054 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 15:38:14 +0200 Subject: [PATCH 41/82] fix(deploy): provision-atm.sh writes relay/pubkey only on explicit override (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- deploy/nixos/provision-atm.sh | 100 +++++++++++++++++++--------------- 1 file changed, 57 insertions(+), 43 deletions(-) diff --git a/deploy/nixos/provision-atm.sh b/deploy/nixos/provision-atm.sh index 74f4229..31e08e8 100755 --- a/deploy/nixos/provision-atm.sh +++ b/deploy/nixos/provision-atm.sh @@ -4,19 +4,22 @@ # kind-21000 NIP-44 v2 events on a relay — there is no out-of-band token, # the ATM's nostr private key IS the credential. # pragma: allowlist secret # -# Required environment variables (or edit defaults below): -# LNBITS_SERVER_PUBKEY Hex pubkey published by the LNbits server at startup. -# From the LNbits compose: -# docker logs lnbits | grep 'nostr_transport pubkey' -# LNBITS_HTTP_URL Origin LNbits is reachable at over HTTP, used only -# to compose the LNURL-withdraw callback URL that -# customer wallets dereference. Default: http://10.0.2.2:5000 -# RELAY_URL Nostr relay LNbits + the bunker subscribe on. -# Default: ws://$HOST_IP:5001/nostrrelay/test (LNbits -# bundled nostrrelay). Override for a separate relay. -# SPIRE_SEED The spire pairing seed (`spire-seed:v1:`) -# minted by spirekeeper. THIS is the production -# identity under the NIP-46 bunker (aiolabs/bitspire#52). +# The primary input is SPIRE_SEED — the pairing seed carries the relay, the +# LNbits server pubkey AND the signing identity, so a seed-provisioned machine +# needs nothing else (aiolabs/bitspire#70). +# +# Environment variables: +# SPIRE_SEED RECOMMENDED. The spire pairing seed +# (`spire-seed:v1:`) minted by spirekeeper. +# Carries relay + LNbits server pubkey + the production +# identity under the NIP-46 bunker (aiolabs/bitspire#52 / #70). +# RELAY_URL OPTIONAL override — pins VITE_RELAY_URL and WINS over the +# seed's relay (env-first precedence). Leave unset to let the +# seed drive it. Required only on the no-seed dev path +# (default there: ws://$HOST_IP:5001/nostrrelay/test). +# LNBITS_SERVER_PUBKEY OPTIONAL override (hex). Leave unset with a seed. On the +# no-seed dev path it's scraped from +# `docker logs lnbits | grep 'nostr_transport pubkey'`. # ATM_PRIVATE_KEY DEV-ONLY 32-byte hex nsec fallback, used only when # SPIRE_SEED is unset (no bunker). Generated if unset # AND no SPIRE_SEED is provided. @@ -61,40 +64,51 @@ else echo "--- LAN ATM: using $HOST_IP as dev machine address ---" fi -# Step 2: Resolve the LNbits server pubkey. Prefer the env override; else -# fall back to scraping the local docker compose stack. -if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then - echo "" - echo "--- Step 1: Extracting LNbits nostr-transport pubkey from docker logs ---" - LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \ - | grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \ - | tail -1 || true) - if [ -z "$LNBITS_SERVER_PUBKEY" ]; then - echo "ERROR: Could not extract LNbits pubkey. Set LNBITS_SERVER_PUBKEY explicitly" - echo "or start the LNbits stack first (docker compose -f docker/docker-compose.dev.yml up lnbits)." - exit 1 - fi -fi -echo "LNbits server pubkey: ${LNBITS_SERVER_PUBKEY:0:16}..." +# Steps 2-4: transport config (relay + LNbits server pubkey) + signing identity. +# +# Under aiolabs/bitspire#70 the relay + server pubkey come from the pairing SEED, +# so a seed-provisioned machine needs NEITHER in .env. We only pin them when the +# operator EXPLICITLY passes RELAY_URL / LNBITS_SERVER_PUBKEY (a deliberate +# override that WINS over the seed via env-first precedence), or when there is no +# seed (the dev-nsec fallback has nothing else to supply them, so we scrape/default). +TRANSPORT_LINES="" -# Step 3: Pin LNbits HTTP origin. -LNBITS_HTTP_URL="${LNBITS_HTTP_URL:-http://$HOST_IP:5000}" - -# Step 4: Relay URL. Defaults to the LNbits bundled nostrrelay. -RELAY_URL="${RELAY_URL:-ws://$HOST_IP:5001/nostrrelay/test}" - -# Step 5: Signing identity. Prefer the spire pairing seed (bunker). Only fall -# back to a generated dev nsec when no seed is supplied. if [ -n "${SPIRE_SEED:-}" ]; then - echo "" - echo "--- Using spire pairing seed (bunker-backed identity) ---" case "$SPIRE_SEED" in spire-seed:v1:*) : ;; *) echo "ERROR: SPIRE_SEED must start with 'spire-seed:v1:'"; exit 1 ;; esac + echo "" + echo "--- Spire pairing seed: relay + LNbits pubkey come from the seed ---" + if [ -n "${RELAY_URL:-}" ]; then + echo " (pinning VITE_RELAY_URL=$RELAY_URL — overrides the seed's relay)" + TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL" + fi + if [ -n "${LNBITS_SERVER_PUBKEY:-}" ]; then + TRANSPORT_LINES="${TRANSPORT_LINES:+$TRANSPORT_LINES +}VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY" + fi IDENTITY_LINES="# Spire pairing seed — bunker-backed identity (aiolabs/bitspire#52) VITE_SPIRE_SEED=$SPIRE_SEED" else + # No seed → DEV-ONLY nsec fallback. Nothing else supplies the relay + pubkey, + # so scrape/default them. + if [ -z "${LNBITS_SERVER_PUBKEY:-}" ]; then + echo "" + echo "--- No seed: extracting LNbits nostr-transport pubkey from docker logs ---" + LNBITS_SERVER_PUBKEY=$(docker logs lnbits 2>&1 \ + | grep -oP 'nostr_transport pubkey:?\s*\K[a-f0-9]{64}' \ + | tail -1 || true) + if [ -z "$LNBITS_SERVER_PUBKEY" ]; then + echo "ERROR: no SPIRE_SEED, and could not extract the LNbits pubkey." + echo "Provide a SPIRE_SEED (recommended — the seed carries relay + pubkey)," + echo "or set LNBITS_SERVER_PUBKEY explicitly." + exit 1 + fi + fi + RELAY_URL="${RELAY_URL:-ws://$HOST_IP:5001/nostrrelay/test}" + TRANSPORT_LINES="VITE_RELAY_URL=$RELAY_URL +VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY" if [ -z "${ATM_PRIVATE_KEY:-}" ]; then ATM_PRIVATE_KEY=$(openssl rand -hex 32) echo "" @@ -110,10 +124,10 @@ echo "--- Step 2: Writing .env to ATM ---" ENV_CONTENT="# bitSpire Configuration # Auto-generated by provision-atm.sh on $(date -Iseconds) -# LNbits nostr-transport connection -VITE_RELAY_URL=$RELAY_URL -VITE_LNBITS_SERVER_PUBKEY=$LNBITS_SERVER_PUBKEY -VITE_LNBITS_HTTP_URL=$LNBITS_HTTP_URL +# LNbits nostr-transport. Relay + server pubkey come from the pairing seed +# (aiolabs/bitspire#70); present below only as an explicit override or the +# no-seed dev fallback. +$TRANSPORT_LINES $IDENTITY_LINES @@ -132,6 +146,6 @@ echo "" echo "=== ATM provisioned successfully ===" echo "" echo "Credentials written to /var/lib/bitspire/.env" -echo "ATM service restarted. It should connect to LNbits via relay $RELAY_URL." +echo "ATM service restarted. Relay: ${RELAY_URL:-from the pairing seed}." echo "" echo "To check status: ssh -p $ATM_SSH_PORT $ATM_USER@$ATM_HOST 'sudo journalctl -u bitspire -f'" From ce87f85a7387378c7d120c385c05794dc66a3d9f Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 15:38:14 +0200 Subject: [PATCH 42/82] fix(machine): maintenance beacon uses the pairing seed's relay (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/machine/src/App.vue | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index c7e1537..2b89eb7 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -103,11 +103,16 @@ onMounted(async () => { try { const { NostrClient, createSignedEvent } = await import('@bitSpire/nostr-client') const { resolveSigner } = await import('@/services/signer-resolver') - const relayUrl = config?.relayUrl || import.meta.env.VITE_RELAY_URL // Best-effort: resolve a signer (bunker resume / pairing, or dev nsec). // If the ATM isn't paired yet, skip the beacon rather than fail the screen. const resolved = await resolveSigner({ allowEphemeral: true }).catch(() => null) const signer = resolved?.signer ?? null + // Same env → pairing-seed precedence as lightning.ts: on a blank-.env + // seed-driven machine the relay comes from the pairing transport, not env. + const relayUrl = + config?.relayUrl || + import.meta.env.VITE_RELAY_URL || + resolved?.transport?.relays?.[0] if (signer && relayUrl) { const client = new NostrClient({ relays: [{ url: relayUrl }], signer }) await client.connect() From e99628ef845cbda1e3a5bae089d47499259ea930 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 15:38:14 +0200 Subject: [PATCH 43/82] docs: relay + LNbits pubkey are seed-provided, not required (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 6 +++--- apps/machine/.env.example | 10 +++++++--- deploy/nixos/README.md | 6 +++--- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a1a5691..1a068ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,9 +82,9 @@ Renderer reads (Electron IPC or Vite `import.meta.env`): | Var | Required | Notes | |---|---|---| -| `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) | -| `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) | -| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:`) from spirekeeper. Carries a one-shot NIP-46 connect token + the spire signing pubkey + bunker URL. First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. Provisioning it up front is optional — an unpaired machine renders an on-screen QR-pairing wizard that scans the seed off the camera (see below). See aiolabs/bitspire#52. | +| `VITE_RELAY_URL` | no (seed-provided) | Relay both ATM and LNbits subscribe to. **Comes from the pairing seed** (aiolabs/bitspire#70); set this only as an override — it WINS over the seed via env-first precedence. Dev override: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) | +| `VITE_LNBITS_SERVER_PUBKEY` | no (seed-provided) | 64-char hex transport pubkey. **Comes from the seed's `lnbits_npub`** (#70); env override only. LNbits prints it on startup (`docker logs lnbits \| grep 'Public key (share this)'`) | +| `VITE_SPIRE_SEED` | yes (prod) | Spire pairing seed (`spire-seed:v1:`) from spirekeeper. Carries the relay(s), the LNbits transport pubkey (`lnbits_npub`), the spire signing pubkey (`spire_npub`), and a one-shot NIP-46 connect token (#70 slimmed the shape). First boot redeems it and persists the binding to `state.db`; later boots resume by fingerprint. A changed seed re-pairs. Provisioning it up front is optional — an unpaired machine renders an on-screen QR-pairing wizard that scans the seed off the camera (see below). See aiolabs/bitspire#52. | | `VITE_ATM_PRIVATE_KEY` | dev only | 64-char hex raw nsec fallback for running without a bunker. Ignored when `VITE_SPIRE_SEED` or a stored binding exists. | | `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands | diff --git a/apps/machine/.env.example b/apps/machine/.env.example index e691e46..66541dc 100644 --- a/apps/machine/.env.example +++ b/apps/machine/.env.example @@ -19,11 +19,15 @@ VITE_LAMASSU_FIAT_CODE=USD # VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]' # ============================================================================= -# LNbits Connection (Required) — nostr-native-transport +# LNbits Connection (dev override — normally seed-provided) — nostr-native-transport # ============================================================================= +# On a real machine the pairing SEED (VITE_SPIRE_SEED) carries the relay AND the +# server pubkey (aiolabs/bitspire#70), so leave both blank there. Set them here +# only for browser dev without a seed/bunker — they WIN over the seed. -# Nostr relay WebSocket URL — relay LNbits is subscribed to. -VITE_RELAY_URL=ws://localhost:7777 +# Nostr relay WebSocket URL. Dev stack uses LNbits's bundled nostrrelay: +# VITE_RELAY_URL=ws://localhost:5001/nostrrelay/test +VITE_RELAY_URL= # LNbits nostr-transport server pubkey (hex, 64 chars). # Printed by the LNbits server on startup: diff --git a/deploy/nixos/README.md b/deploy/nixos/README.md index af00ed6..2bfc333 100644 --- a/deploy/nixos/README.md +++ b/deploy/nixos/README.md @@ -187,7 +187,7 @@ The `dev`-branch `flake.nix` pins the auto-upgrade source to `?ref=dev` so any A ```nix system.autoUpgrade = { enable = true; - flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git?ref=dev#${machineModel}-installed"; + flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#${machineModel}-installed"; dates = "04:00"; allowReboot = false; }; @@ -262,8 +262,8 @@ ls -la /dev/serial/by-id/ { services.bitspire = { enable = true; - relayUrl = "wss://relay.aiolabs.dev"; # ATM ↔ LNbits relay - lnbitsServerPubkey = "<64-hex>"; # LNbits transport pubkey + relayUrl = ""; # seed-provided (#70); set to PIN a relay + lnbitsServerPubkey = ""; # seed-provided (#70); set to PIN a pubkey appDir = "/opt/bitspire"; # rarely overridden — defaults via flake dataDir = "/var/lib/bitspire"; # rarely overridden logLevel = "info"; # error | warn | info | debug From 7abc2e3305009965c5c81314eb5653ed748b11b6 Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 2 Jul 2026 18:33:31 +0200 Subject: [PATCH 44/82] refactor(machine): remove dead Lightning.Pub nprofile UI (post-3d cutover) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/machine/electron/preload.ts | 4 -- apps/machine/src/types/electron.d.ts | 4 -- apps/machine/src/views/IdleView.vue | 43 +------------- apps/machine/src/views/SupportView.vue | 80 +------------------------- 4 files changed, 3 insertions(+), 128 deletions(-) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 5483e46..ba53b62 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -17,10 +17,6 @@ export interface RuntimeConfig { relayUrl: string /** LNbits nostr-transport server pubkey (hex, 64 chars). */ lnbitsServerPubkey: string - /** Legacy LP fields — retained until 3d removes the LP backend. Optional. */ - lightningPubPubkey?: string - lightningPubApiUrl?: string - extensionApiUrl?: string appId: string machineModel: string fiatCode: string diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 3b17d0e..bca4d0f 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -6,10 +6,6 @@ export interface RuntimeConfig { relayUrl: string /** LNbits nostr-transport server pubkey (hex, 64 chars). */ lnbitsServerPubkey: string - /** Legacy LP fields — retained until 3d removes the LP backend. Optional. */ - lightningPubPubkey?: string - lightningPubApiUrl?: string - extensionApiUrl?: string appId: string machineModel: string fiatCode: string diff --git a/apps/machine/src/views/IdleView.vue b/apps/machine/src/views/IdleView.vue index f05d9a2..3465cc1 100644 --- a/apps/machine/src/views/IdleView.vue +++ b/apps/machine/src/views/IdleView.vue @@ -1,7 +1,6 @@