From a0c2f38ef0e9820c5d5e7b1027088420a62725f8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 12 Jun 2026 17:36:19 +0200 Subject: [PATCH 001/118] 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 002/118] 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 003/118] =?UTF-8?q?docs(adr):=20ADR-002=20remote=20access?= =?UTF-8?q?=20&=20fleet=20management=20=E2=80=94=20three=20planes,=20NetBi?= =?UTF-8?q?rd?= 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 004/118] 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 005/118] 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 006/118] 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 007/118] 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 008/118] 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 009/118] 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 010/118] 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 011/118] 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 012/118] 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 013/118] 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 014/118] 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 015/118] 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 016/118] 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 017/118] 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 018/118] 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 019/118] 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 020/118] 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 021/118] 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 023/118] 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 024/118] 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 025/118] 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 039/118] 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 040/118] 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 041/118] 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 042/118] 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 043/118] 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 044/118] 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 @@ + + diff --git a/apps/machine/src/services/access/__tests__/authorize.test.ts b/apps/machine/src/services/access/__tests__/authorize.test.ts new file mode 100644 index 0000000..0f175af --- /dev/null +++ b/apps/machine/src/services/access/__tests__/authorize.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest' +import { npubEncode, nprofileEncode } from 'nostr-tools/nip19' +import { authorize, hashId, hashPin, type AllowListEntry } from '../authorize' + +const SALT = 'test-salt' +const HEX_A = 'aa'.repeat(32) +const HEX_B = 'bb'.repeat(32) +const NPUB_A = npubEncode(HEX_A) +const NPUB_B = npubEncode(HEX_B) + +describe('access authorize (ADR-003)', () => { + describe('open enrollment (prototype)', () => { + it('grants any valid npub as user', async () => { + const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], { + salt: SALT, + openEnrollment: true, + }) + expect(out.status).toBe('granted') + expect(out).toMatchObject({ status: 'granted', role: 'user' }) + }) + + it('rejects a stray / non-npub QR', async () => { + const out = await authorize({ kind: 'npub', npub: 'https://example.com/not-an-npub' }, [], { + salt: SALT, + openEnrollment: true, + }) + expect(out.status).toBe('denied') + expect(out).toMatchObject({ reason: 'not a valid npub' }) + }) + + it('accepts a `nostr:` URI prefix (with surrounding whitespace)', async () => { + const out = await authorize({ kind: 'npub', npub: ` nostr:${NPUB_A}\n` }, [], { + salt: SALT, + openEnrollment: true, + }) + expect(out.status).toBe('granted') + }) + + it('accepts an nprofile and resolves to the same identity as its npub', async () => { + const nprofile = nprofileEncode({ pubkey: HEX_A, relays: ['wss://relay.example'] }) + const viaNprofile = await authorize({ kind: 'npub', npub: nprofile }, [], { + salt: SALT, + openEnrollment: true, + }) + const viaNpub = await authorize({ kind: 'npub', npub: NPUB_A }, [], { + salt: SALT, + openEnrollment: true, + }) + expect(viaNprofile.status).toBe('granted') + // Same underlying pubkey → same credential hash. + expect(viaNprofile.credentialIdHash).toBe(viaNpub.credentialIdHash) + }) + }) + + describe('allow-list (closed)', () => { + it('denies an unlisted npub when not open-enrollment', async () => { + const out = await authorize({ kind: 'npub', npub: NPUB_A }, [], { salt: SALT }) + expect(out).toMatchObject({ status: 'denied', reason: 'not authorized' }) + }) + + it('grants a listed npub with its role, no PIN', async () => { + const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'operator' } + const out = await authorize({ kind: 'npub', npub: NPUB_A }, [entry], { salt: SALT }) + expect(out).toMatchObject({ status: 'granted', role: 'operator' }) + }) + + it('does not match npub B against npub A entry', async () => { + const entry: AllowListEntry = { idHash: await hashId(HEX_A, SALT), role: 'user' } + const out = await authorize({ kind: 'npub', npub: NPUB_B }, [entry], { salt: SALT }) + expect(out.status).toBe('denied') + }) + }) + + describe('PIN second factor', () => { + const makeEntry = async (): Promise => ({ + idHash: await hashId(HEX_A, SALT), + role: 'user', + pinHash: await hashPin('1234', SALT), + }) + + it('asks for a PIN when one is configured and none supplied', async () => { + const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], { salt: SALT }) + expect(out.status).toBe('pin-required') + }) + + it('grants on correct PIN', async () => { + const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], { + salt: SALT, + pin: '1234', + }) + expect(out).toMatchObject({ status: 'granted', role: 'user' }) + }) + + it('denies on wrong PIN', async () => { + const out = await authorize({ kind: 'npub', npub: NPUB_A }, [await makeEntry()], { + salt: SALT, + pin: '9999', + }) + expect(out).toMatchObject({ status: 'denied', reason: 'incorrect PIN' }) + }) + }) + + describe('challenge credential (v2 seam)', () => { + it('is not yet authorized', async () => { + const out = await authorize( + { kind: 'challenge', pubkey: HEX_A, nonce: 'n', sig: 's' }, + [], + { salt: SALT, openEnrollment: true } + ) + expect(out.status).toBe('denied') + }) + }) +}) diff --git a/apps/machine/src/services/access/authorize.ts b/apps/machine/src/services/access/authorize.ts new file mode 100644 index 0000000..a6c99be --- /dev/null +++ b/apps/machine/src/services/access/authorize.ts @@ -0,0 +1,141 @@ +/** + * Credential authorization (ADR-003). + * + * PROTOTYPE (this PR): a QR "badge" carrying an npub grants terminal access, + * with an OPTIONAL PIN as a second factor. Matching is against a local + * allow-list of hashed identities; `openEnrollment` admits any valid npub + * (no allow-list) for early prototyping. Only salted hashes are compared or + * stored — never the raw npub/UID (KYC-free). + * + * Identity id per scan kind: + * - npub → hex pubkey (decoded, canonical), then hashed + * - uid → raw UID hashed (NFC, PR4) + * - challenge → v2 seam (PR5), not yet authorized + */ + +import { decode as nip19Decode } from 'nostr-tools/nip19' +import type { AccessRole } from '@bitSpire/state-machine' +import type { AccessScan } from './types' + +/** One authorized identity. `idHash` = hashId(, salt). */ +export interface AllowListEntry { + idHash: string + role: AccessRole + /** When set, access requires this PIN (hashPin(pin, salt)) as a 2nd factor. */ + pinHash?: string + /** Optional operator-facing label (never a person's real identity). */ + label?: string +} + +export interface AuthorizeOptions { + /** Per-machine salt for all hashing. */ + salt: string + /** Admit any valid credential when the allow-list has no match (prototype). */ + openEnrollment?: boolean + /** PIN supplied on the follow-up call after a `pin-required` outcome. */ + pin?: string +} + +/** + * Three outcomes, so the caller can drive a two-step flow: + * - `granted` → send ACCESS_GRANTED + * - `pin-required` → prompt for a PIN, then call authorize() again with `pin` + * - `denied` → send ACCESS_DENIED(reason) + */ +export type AuthorizeOutcome = + | { status: 'granted'; role: AccessRole; credentialIdHash: string } + | { status: 'pin-required'; credentialIdHash: string } + | { status: 'denied'; credentialIdHash: string; reason: string } + +/** Salted SHA-256, hex-encoded. */ +async function sha256Hex(input: string): Promise { + const data = new TextEncoder().encode(input) + const digest = await crypto.subtle.digest('SHA-256', data) + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('') +} + +export const hashId = (id: string, salt: string): Promise => sha256Hex(`id:${salt}:${id}`) +export const hashPin = (pin: string, salt: string): Promise => sha256Hex(`pin:${salt}:${pin}`) + +/** + * Resolve a scan to a canonical identity string, or `null` if malformed. + * npub is decoded to its hex pubkey so npub/hex forms compare equal and a + * stray (non-npub) QR is rejected. + */ +function canonicalId(scan: AccessScan): string | null { + if (scan.kind === 'uid') return scan.uid || null + if (scan.kind === 'challenge') return null // v2 — handled separately + // Tolerate real-world nostr QR shapes: a bare `npub1…`, a `nostr:` URI + // prefix, and `nprofile1…` (npub + relay hints, what many clients export). + const raw = scan.npub.trim().replace(/^nostr:/i, '') + try { + const decoded = nip19Decode(raw) + if (decoded.type === 'npub' && typeof decoded.data === 'string') { + return decoded.data + } + if ( + decoded.type === 'nprofile' && + decoded.data && + typeof (decoded.data as { pubkey?: unknown }).pubkey === 'string' + ) { + return (decoded.data as { pubkey: string }).pubkey + } + return null + } catch { + return null + } +} + +/** + * Decide whether a scanned credential is authorized. + * Always resolves (never throws) so the caller can uniformly react. + */ +export async function authorize( + scan: AccessScan, + allowList: AllowListEntry[], + opts: AuthorizeOptions +): Promise { + if (scan.kind === 'challenge') { + return { + status: 'denied', + credentialIdHash: '', + reason: 'challenge-response credentials not yet supported', + } + } + + const id = canonicalId(scan) + if (!id) { + return { + status: 'denied', + credentialIdHash: '', + reason: scan.kind === 'npub' ? 'not a valid npub' : 'invalid credential', + } + } + + const credentialIdHash = await hashId(id, opts.salt) + const entry = allowList.find((e) => e.idHash === credentialIdHash) + + if (!entry) { + if (opts.openEnrollment) { + return { status: 'granted', role: 'user', credentialIdHash } + } + return { status: 'denied', credentialIdHash, reason: 'not authorized' } + } + + // No PIN configured → single-factor grant. + if (!entry.pinHash) { + return { status: 'granted', role: entry.role, credentialIdHash } + } + + // PIN configured but not yet supplied → ask for it. + if (opts.pin === undefined) { + return { status: 'pin-required', credentialIdHash } + } + + // PIN supplied → verify. + const pinHash = await hashPin(opts.pin, opts.salt) + if (pinHash !== entry.pinHash) { + return { status: 'denied', credentialIdHash, reason: 'incorrect PIN' } + } + return { status: 'granted', role: entry.role, credentialIdHash } +} diff --git a/apps/machine/src/services/access/index.ts b/apps/machine/src/services/access/index.ts new file mode 100644 index 0000000..fb1e26c --- /dev/null +++ b/apps/machine/src/services/access/index.ts @@ -0,0 +1,42 @@ +/** + * Access-control module surface (ADR-003). + * + * `availableAccessReaders()` returns the readers this device can run, in + * preference order: the camera npub-QR badge first (prototype, works on the + * batm3 today), the mock reader as a keyboard/console fallback. PR3 adds a + * Web-NFC reader and PR4 the serial `/dev/ttyNFC` reader ahead of these. + */ + +import { QrNpubAccessReader } from './qr-npub-reader' +import { MockAccessReader } from './mock-reader' +import type { AccessReader } from './types' + +export type { + AccessReader, + AccessReaderKind, + AccessReaderStartOptions, + AccessScan, + AccessRole, + StopCapture, +} from './types' +export { QrNpubAccessReader } from './qr-npub-reader' +export { MockAccessReader, MOCK_NPUB } from './mock-reader' +export { authorize, hashId, hashPin } from './authorize' +export type { AllowListEntry, AuthorizeOptions, AuthorizeOutcome } from './authorize' + +/** All readers in preference order, regardless of availability. */ +export function allAccessReaders(): AccessReader[] { + // PR3: WebNfcAccessReader, PR4: SerialNfcAccessReader — inserted ahead of the + // camera once real NFC hardware is present. + return [new QrNpubAccessReader(), new MockAccessReader()] +} + +/** + * Only the readers this device can run, in preference order. The mock reader + * is always available, so it lands last as a guaranteed fallback. + */ +export async function availableAccessReaders(): Promise { + const readers = allAccessReaders() + const flags = await Promise.all(readers.map((r) => r.isAvailable())) + return readers.filter((_, i) => flags[i]) +} diff --git a/apps/machine/src/services/access/mock-reader.ts b/apps/machine/src/services/access/mock-reader.ts new file mode 100644 index 0000000..c918b18 --- /dev/null +++ b/apps/machine/src/services/access/mock-reader.ts @@ -0,0 +1,53 @@ +/** + * Mock access reader (ADR-003) — SCAFFOLD, no hardware. + * + * A keyboard/console fallback for when no camera or NFC reader is present + * (headless dev, CI, a batm3 with a dead camera). Emits an npub scan on + * demand via two triggers: + * - `window.__bitspireMockCard(npub?)` — from the LockedView dev button or + * the devtools console. + * - the `F9` key — a quick tap on the physical machine. + * + * Registered only when it is the sole available reader (see `index.ts`), so it + * never shadows the real camera/NFC path. + */ + +import { npubEncode } from 'nostr-tools/nip19' +import type { AccessReader, AccessReaderStartOptions, StopCapture } from './types' + +/** + * Default npub the mock emits when none is supplied — derived from a fixed + * (all-ones) hex pubkey so it carries a valid bech32 checksum and survives + * `authorize()`'s nip19 decode. Not a real key; dev-only. + */ +export const MOCK_NPUB = npubEncode('11'.repeat(32)) + +interface MockCardGlobal { + __bitspireMockCard?: (npub?: string) => void +} + +export class MockAccessReader implements AccessReader { + readonly kind = 'mock' as const + readonly label = 'Mock reader (dev)' + + async isAvailable(): Promise { + return true + } + + async start(opts: AccessReaderStartOptions): Promise { + const emit = (npub: string = MOCK_NPUB) => opts.onScan({ kind: 'npub', npub }) + + const g = globalThis as unknown as MockCardGlobal + g.__bitspireMockCard = emit + + const onKey = (e: KeyboardEvent) => { + if (e.key === 'F9') emit() + } + window.addEventListener('keydown', onKey) + + return () => { + window.removeEventListener('keydown', onKey) + if (g.__bitspireMockCard === emit) delete g.__bitspireMockCard + } + } +} diff --git a/apps/machine/src/services/access/qr-npub-reader.ts b/apps/machine/src/services/access/qr-npub-reader.ts new file mode 100644 index 0000000..e95c507 --- /dev/null +++ b/apps/machine/src/services/access/qr-npub-reader.ts @@ -0,0 +1,79 @@ +/** + * QR-npub access reader (ADR-003, PROTOTYPE). + * + * Until the NFC reader hardware exists, the batm3's camera — the same one the + * pairing wizard uses — reads a QR "badge" that encodes the user's npub. The + * decoded npub is handed to `authorize()`, which admits it (optionally behind + * a PIN). This is a thin adapter onto the same `qr/dom.js` decode loop as + * `pairing/qr-source.ts`; see that file for the capture-resolution rationale. + * + * It emits the raw decoded string as an `npub` scan and lets `authorize()` + * validate it — a stray, non-npub QR is rejected there, not here. + */ + +import { QRCanvas, frontalCamera, frameLoop } from 'qr/dom.js' +import type { AccessReader, AccessReaderStartOptions, StopCapture } from './types' + +export class QrNpubAccessReader implements AccessReader { + readonly kind = 'qr-npub' as const + readonly label = 'Camera (npub QR)' + + async isAvailable(): Promise { + return ( + typeof navigator !== 'undefined' && + !!navigator.mediaDevices && + typeof navigator.mediaDevices.getUserMedia === 'function' + ) + } + + async start(opts: AccessReaderStartOptions): Promise { + const { onScan, onError, video } = opts + if (!video) throw new Error('QrNpubAccessReader requires a

+ +
+

+ Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }} +

+ +
+

diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue index 6257882..5a037c3 100644 --- a/apps/machine/src/views/CashOutView.vue +++ b/apps/machine/src/views/CashOutView.vue @@ -328,6 +328,24 @@ function formatFiat(cents: number): string {

+ +
+

+ Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }} +

+ +
+

diff --git a/apps/machine/src/views/LockedView.vue b/apps/machine/src/views/LockedView.vue index 90c4ac0..86115e9 100644 --- a/apps/machine/src/views/LockedView.vue +++ b/apps/machine/src/views/LockedView.vue @@ -1,316 +1,109 @@ From 44a5ebbd1276790ac9abe3a053fca791c80f6db2 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Thu, 6 Aug 2026 22:58:49 +0200 Subject: [PATCH 084/118] fix(access): reset router to home on re-lock so the reopened gate lands on idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a transaction with the gate enabled, the machine re-locks (cashOut/ cashIn → locked, not idle), so the view's isIdle watch never fires and the router stays on /cash-out|/cash-in. When the next tap reopens the gate to idle, the stale transaction view showed (e.g. a completed sell's collect screen, stuck). Reset the route to / while locked (router-view hidden under LockedView) so idle renders IdleView. --- apps/machine/src/App.vue | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index 742c868..a4e22a0 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -1,6 +1,6 @@ + + diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 34acf7e..fd00df2 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -324,6 +324,9 @@ export const useAtmStore = defineStore('atm', () => { // hit-keyed withdraw/pay steps plus balance + fiat for display. Cleared when // the machine re-locks. Never logged. const loadedBoltCard = ref(null) + // The card balance is hidden by default on the public screen; the holder + // reveals it with the eye toggle. Resets on re-lock. + const cardBalanceRevealed = ref(false) const fiatCode = ref('USD') // Defaults are 0 — the operator's fee config (received via Nostr // kind-30078 `bitspire-fees:` envelope from satmachineadmin) @@ -459,6 +462,25 @@ export const useAtmStore = defineStore('atm', () => { // start), so App.vue's LockedView branch never renders on a non-access machine. const isLocked = computed(() => currentState.value === 'locked') + /** + * Fiat view of the loaded card's balance, the way the LNbits wallet page + * prices it: the card server's own currency and rate first (the wallet's + * currency, else the instance default); when it priced nothing, the ATM's + * fiat at its display rate. Null when neither is available. + */ + const loadedCardFiat = computed<{ amount: number; currency: string } | null>(() => { + const card = loadedBoltCard.value + if (!card) return null + if (card.currency && card.fiat !== null) return { amount: card.fiat, currency: card.currency } + if (btcPrice.value && btcPrice.value > 0) { + return { amount: (card.balanceSats / 1e8) * btcPrice.value, currency: fiatCode.value } + } + return null + }) + function toggleCardBalance() { + cardBalanceRevealed.value = !cardBalanceRevealed.value + } + const isCashIn = computed(() => { const state = snapshot.value?.value return typeof state === 'object' && 'cashIn' in state @@ -517,6 +539,7 @@ export const useAtmStore = defineStore('atm', () => { // re-locks) so the next customer starts fresh — never carry a card over. if (state === 'locked' && loadedBoltCard.value) { loadedBoltCard.value = null + cardBalanceRevealed.value = false } // Detect network from first invoice we see @@ -753,6 +776,7 @@ export const useAtmStore = defineStore('atm', () => { }) if (outcome.status === 'granted') { loadedBoltCard.value = opened.session + cardBalanceRevealed.value = false nfcStatus.value = { state: 'accepted', message: 'Card accepted' } grantAccess(outcome.role, outcome.credentialIdHash) } else { @@ -1869,6 +1893,9 @@ export const useAtmStore = defineStore('atm', () => { simulateBoltCardReceive, // Tap-to-enter: card session opened at the locked screen, reused at Complete loadedBoltCard, + cardBalanceRevealed, + loadedCardFiat, + toggleCardBalance, completeWithCard, simulateBoltCardEntry, diff --git a/apps/machine/src/views/CashInView.vue b/apps/machine/src/views/CashInView.vue index b75a2a4..a86acd0 100644 --- a/apps/machine/src/views/CashInView.vue +++ b/apps/machine/src/views/CashInView.vue @@ -3,6 +3,7 @@ import { watch, computed, ref } from 'vue' import { useRouter } from 'vue-router' import { useAtmStore } from '@/stores/atm' import { Button } from '@/components/ui/button' +import CardChip from '@/components/CardChip.vue' import { Alert, AlertDescription } from '@/components/ui/alert' import { Input } from '@/components/ui/input' import QRCode from '@/components/QRCode.vue' @@ -368,9 +369,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice) v-if="atmStore.loadedBoltCard" class="flex w-full max-w-md flex-col items-center gap-2 pt-2" > -

- Card ••{{ atmStore.loadedBoltCard.externalId.slice(-4) }} -

+
+ + From ec2b15c08fc4edafafdaae414336dc8cbf3e172c Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 20 Sep 2026 17:07:17 +0200 Subject: [PATCH 097/118] docs: Bolt Card session contract, ADR-003 amendment for verified entry docs/boltcard-session.md is the /session wire contract (sibling of boltcard-receive-resolver.md), including the trust boundary: the session URL is derived from the card's own host, so open enrollment is still not a security boundary (#91). ADR-003's amendment now records verified entry via /session and the hidden-by-default balance display. Co-Authored-By: Claude Fable 5.1 --- docs/adr/003-nfc-access-control-layer.md | 4 +- docs/boltcard-receive-resolver.md | 6 ++ docs/boltcard-session.md | 117 +++++++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 docs/boltcard-session.md diff --git a/docs/adr/003-nfc-access-control-layer.md b/docs/adr/003-nfc-access-control-layer.md index 6079fd1..b5ebcdb 100644 --- a/docs/adr/003-nfc-access-control-layer.md +++ b/docs/adr/003-nfc-access-control-layer.md @@ -10,8 +10,8 @@ The gate landed in aiolabs/bitspire#86 as **Bolt Card tap-to-enter**, not the np - **Reader.** The batm3 / upboard reader is a USB CCID contactless reader (Feitian KP382, OMNIKEY 5022) driven by `pcscd` + `nfc-pcsc` in the **main process** (`electron/nfc-service.ts`, from #83), not the serial `/dev/ttyNFC` device or Web NFC. Taps reach the renderer over the existing `nfc:card-tapped` IPC and the store routes them by state (`locked` → enter; the cash screens → pay / receive). The renderer-side `AccessReader` abstraction, the camera npub-QR reader and the mock reader were never wired to anything and have been removed; `services/access/` now holds only `authorize`, the Bolt Card parser and the credential types. - **Credential.** Identity is the card's boltcards `external_id`, parsed **locally** from the tapped `lnurlw` (`AccessScan` kind `boltcard`), not the NFC UID. Only `hashId(external_id, salt)` is compared or logged. The `npub` variant (with its PIN second factor) stays in `authorize()` and its tests for a future non-card credential; `challenge` remains the v2 seam; the planned `uid` variant is gone. -- **Soft entry, verify-at-payment.** A tap yields a single-use SUN `p`/`c`. Verifying it at entry would spend the voucher we want to reuse at Complete, so entry makes **no server call**. The stored `lnurlw` is presented once, at Complete, through the #83 / #84 payment paths, and that is where the cryptographic check happens. Consequence: the loaded card is single-shot — after the first Complete attempt, accepted or declined, the store drops it and the customer re-taps. -- **Open enrollment is not a security boundary.** With `openEnrollment` on (the current posture on every gated machine), any NDEF tag whose URL contains `/scan/` unlocks the terminal. The gate keeps casual users off the menu; money still only moves on a valid SUN. Closing this — a provisioned allow-list, or a verify-at-entry variant that spends one tap — is tracked in aiolabs/bitspire#91. +- **Verified entry via `/session`** (superseding #86's soft entry). A tap yields a single-use SUN `p`/`c`, and verifying it at entry would have spent the voucher Complete needed, so #86 made no server call at entry. The fork now exposes `/session/?p=&c=` (`docs/boltcard-session.md`): it spends the SUN **once**, proves a genuine non-replayed card, returns the card wallet's balance + fiat, and hands back the hit-keyed LUD-03 / LUD-06 second steps so Complete still needs no second tap. The session is single-shot — after the first Complete attempt, accepted or declined, the store drops it and the customer re-taps. The balance is shown on the idle menu and cash screens **hidden by default** behind an eye toggle (`CardChip.vue`), priced the way the LNbits wallet page prices it (wallet currency, else instance default, else the ATM's fiat at its own rate). +- **Open enrollment is still not a security boundary.** `/session` proves the card is genuine *to the server the card names*: the session URL is derived from the tapped `lnurlw`'s host, so with `openEnrollment` on a forged NDEF tag pointing at a server that answers `authenticated: true` still unlocks the terminal. Money is unaffected (cash-out never dispenses without `PAYMENT_RECEIVED`; cash-in pays where the holder pointed). Closing the gate means pinning the accepted card-server host(s) and/or a provisioned allow-list — tracked in aiolabs/bitspire#91. - **Session semantics.** One tap = one session; every transaction terminal state returns to `locked`. Inactivity (60 s on the idle menu) and the absolute cap (10 min) are measured at the DOM layer (`useSessionSecurity`) because an XState `after` cannot observe touches. Both send `END_SESSION`, which the machine accepts **from `idle` only**, so a timer can never abandon stacked bills or an in-flight dispense. An explicit End Session button re-locks immediately. - **Config.** `ACCESS_CONTROL_ENABLED` / `ACCESS_OPEN_ENROLLMENT` / `ACCESS_DEV_UNLOCK` / `ACCESS_SALT` from env, overridden by `/var/lib/bitspire/access.json` (pushed with `deploy/nixos/provision-access.sh`, no rebuild). `devUnlock` defaults **off**. - **Audit** is still a console stub; the state.db write promised in decision 7 is tracked in aiolabs/bitspire#90. diff --git a/docs/boltcard-receive-resolver.md b/docs/boltcard-receive-resolver.md index d505d3c..02cfbc0 100644 --- a/docs/boltcard-receive-resolver.md +++ b/docs/boltcard-receive-resolver.md @@ -99,6 +99,12 @@ wallet …". ## Notes +- **Tap-to-enter uses `/session` instead.** When the access gate is on, the + card was already verified at entry and the ATM holds the LUD-06 second step + from `/session` (see `boltcard-session.md`), so Complete calls `pay.callback` + directly and never touches `/pay`. `/pay` remains the path for a card tapped + directly on the cash-in screen (gate off, or a second card). + - **Double-payout:** the cash-in screen still shows the LNURL-withdraw QR as a fallback (customer _pulls_). A tap _pays_ instead. The ATM gates re-entry while a tap is in flight and leaves `displayingQR` on success; the withdraw diff --git a/docs/boltcard-session.md b/docs/boltcard-session.md new file mode 100644 index 0000000..2daa775 --- /dev/null +++ b/docs/boltcard-session.md @@ -0,0 +1,117 @@ +# Bolt Card session — one verified tap for a terminal visit + +Wire contract for the `/session` endpoint the ATM's access gate (ADR-003 +tap-to-enter) uses on the LNbits `boltcards` extension. Implemented in the +aiolabs fork (`git.atitlan.io/aiolabs/boltcards`, `v1.1.1-aio.3`+); consumed by +`apps/machine/electron/boltcard-session.ts`. + +## Why a session + +A Bolt Card tap yields a single-use SUN `p`/`c`: the card server verifies it +and advances the card's read counter, so any endpoint that checks it — `/scan`, +`/pay`, `/verify` — spends it. The gate wants two things from one tap: + +1. **Verify at entry** — a genuine, non-replayed card unlocks the terminal and + we can show the holder their balance. +2. **Complete without a second tap** — the buy or sell later in the visit + moves sats with the same card. + +`/session` does the verification once and hands back the _second steps_ of +both LNURL flows, keyed by a single-use server-side `hit` — the same bearer +`/scan` (as `k1`) and `/pay` already issue. The terminal holds no `p`/`c` +afterwards. + +## Endpoint + +``` +GET /boltcards/api/v1/session/{external_id}?p={p}&c={c} +``` + +Same URL shape as `/scan/{external_id}?p=&c=` with `scan` → `session`; the ATM +derives it by string substitution on the tapped `lnurlw` +(`scanUrlToSessionUrl()`). SUN verification is byte-for-byte `/scan`'s (shared +helper in the fork): unknown / disabled card, UID mismatch, bad CMAC, replayed +counter all reject with `/scan`'s reasons. On success the counter advances and +one `hit` is recorded. + +## Response + +```json +{ + "authenticated": true, + "external_id": "abc123", + "card_name": "Alice", + "balance_msat": 123456000, + "currency": "USD", + "fiat": 98.76, + "withdraw": { + "callback": "https://lnbits.l484.com/boltcards/api/v1/lnurl/cb/", + "k1": "", + "minWithdrawable": 1000, + "maxWithdrawable": 50000000 + }, + "withdraw_blocked_reason": null, + "pay": { + "callback": "https://lnbits.l484.com/boltcards/api/v1/pay/cb/", + "minSendable": 1000, + "maxSendable": 50000000, + "metadata": "[[\"text/plain\",\"Bolt Card top-up\"]]" + } +} +``` + +- `balance_msat` — the card wallet's balance. Display only. +- `currency` / `fiat` — the balance priced the way the LNbits wallet page does + it: the wallet's own currency (per-wallet setting) first, else the instance's + default accounting currency, at the server's rate. `null` when the server has + no currency or the rate lookup failed; the ATM then prices the sats itself in + its own fiat at its display rate. A rate failure never fails the session. +- `withdraw` — the LUD-03 second step. The ATM calls + `callback?k1=&pr=` at cash-out Complete. `null` with + `withdraw_blocked_reason` set when `/scan` would have refused (daily limit + spent); cash-in stays possible. +- `pay` — the LUD-06 second step. The ATM calls `callback?amount=` at + cash-in Complete and pays the returned BOLT11 over its own nostr transport. +- Limits are the card's `tx_limit`, as on `/scan` and `/pay`. + +Rejection: + +```json +{ "authenticated": false, "reason": "This link is already used." } +``` + +`reason` is surfaced verbatim on the locked screen — terse, non-sensitive. + +## Semantics of the hit + +- The first withdraw that uses the hit spends it (`spent = true`), exactly as + after a `/scan`; a second withdraw is refused with "Payment already claimed." +- A top-up does not mark the hit spent (as `/pay` today). +- The ATM treats the whole session as single-shot regardless: after the first + Complete attempt, accepted or declined, it drops the session and asks for a + re-tap (`completeWithCard` in `stores/atm.ts`). +- Hits do not expire server-side. The ATM's session security (60 s idle, + 10 min cap, End Session) bounds how long one is held. + +## Flow + +``` +locked ── tap ─▶ GET /session/?p=&c= (spends the SUN) + ├─ authenticated:false → stay locked, show reason + └─ authenticated:true → authorize(external_id) → idle, session held + CardChip: "Alice · ••c123 •••••• [eye]" +sell: pick amount → Complete Sale → withdraw.callback?k1&pr → PAYMENT_RECEIVED → dispense +buy: insert cash → Complete Purchase → pay.callback?amount → BOLT11 → ATM pays → complete +… re-lock (End Session / idle / complete) drops the session +``` + +## Trust boundary (read this) + +The ATM derives the session URL from the **card's own `lnurlw` host**. With +`openEnrollment` on, a forged NDEF tag pointing at an attacker's server that +answers `{"authenticated": true, …}` still unlocks the terminal. Money is not +at risk — a fake server can only make the ATM pay an invoice the holder chose +(cash-in) or accept a pull it never honours (cash-out never dispenses without +`PAYMENT_RECEIVED`) — but the _gate_ is only as trustworthy as the host it was +told to ask. Closing that means pinning the card-server host(s) the gate +accepts; tracked in aiolabs/bitspire#91. From a497f0ca08dc9dd672923452e5bf4ebc0c8e1ad9 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 20 Sep 2026 23:34:45 +0200 Subject: [PATCH 098/118] fix(idle): drop the redundant 'Available: N sats' line from the centre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The machine's balance already sits in App.vue's top-right status chip. Repeated in the centre — right above the holder's card chip on a tap-to-enter session — it reads as *their* balance ('Available: 0 sats' next to a card showing 959,242 sats). Keep only the buy/sell rates there. Co-Authored-By: Claude Fable 5.1 --- apps/machine/src/views/IdleView.vue | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/apps/machine/src/views/IdleView.vue b/apps/machine/src/views/IdleView.vue index c7b18ed..c88da60 100644 --- a/apps/machine/src/views/IdleView.vue +++ b/apps/machine/src/views/IdleView.vue @@ -74,16 +74,12 @@ function handleCashOut() { >Just Bitcoin - +
- - Available: - {{ atmStore.balanceSats.toLocaleString() }} sats - Buy: Date: Tue, 22 Sep 2026 14:24:13 +0200 Subject: [PATCH 099/118] fix(access): pass plain objects over IPC for session Complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At Complete the store handed the session's withdraw/pay step to window.electronAPI straight out of the loadedBoltCard ref — a Vue reactive proxy — and Electron's structured clone refused it: '[ATM] Bolt Card withdraw failed: Error: An object could not be cloned.' (sintra, 2026-09-21 06:51). The customer had to re-tap, which works because the direct-tap path passes a plain string. Copy the steps field by field into plain objects before they cross the bridge. Co-Authored-By: Claude Fable 5.1 --- apps/machine/src/stores/atm.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index fd00df2..bb5bc8b 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -306,6 +306,22 @@ export const useAtmStore = defineStore('atm', () => { // screen (raw lnurlw, spent by this call) or the session opened at entry // (hit-keyed steps, no p/c). type BoltCardSource = { lnurlw: string } | { session: CardSession } + // Electron IPC structured-clones its arguments and rejects Vue reactive + // proxies with "An object could not be cloned". `loadedBoltCard` is a ref, + // so anything reached through it is a proxy — copy the steps field by field + // into plain objects before they cross the bridge. + const plainWithdrawStep = (w: NonNullable) => ({ + callback: w.callback, + k1: w.k1, + minWithdrawable: w.minWithdrawable, + maxWithdrawable: w.maxWithdrawable, + }) + const plainPayStep = (p: CardSession['pay']) => ({ + callback: p.callback, + minSendable: p.minSendable, + maxSendable: p.maxSendable, + metadata: p.metadata, + }) // Access-control gate config (ADR-003). Defaults disabled → the machine's // `locked` state bypasses straight to `idle` (behaviour identical to no gate). // Populated from RuntimeConfig.accessControl in initializeForProduction. @@ -673,7 +689,7 @@ export const useAtmStore = defineStore('atm', () => { 'session' in source ? source.session.withdraw ? await api.withdrawWithSession({ - withdraw: source.session.withdraw, + withdraw: plainWithdrawStep(source.session.withdraw), bolt11: invoice, amountMsat, }) @@ -713,7 +729,7 @@ export const useAtmStore = defineStore('atm', () => { const api = window.electronAPI! const res = 'session' in source - ? await api.resolveSessionInvoice({ pay: source.session.pay, amountMsat }) + ? await api.resolveSessionInvoice({ pay: plainPayStep(source.session.pay), amountMsat }) : await api.resolveCardInvoice({ lnurlw: source.lnurlw, amountMsat }) if (!res.ok || !res.bolt11) { boltCardProcessing.value = false From 8e11c41f6227d83bee77b61e74f68fb18ab02910 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 14:24:13 +0200 Subject: [PATCH 100/118] perf(access): keep the rate lookup off the unlock path, log entry timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tap → unlock took ~3 s on sintra. The card server's /session now fills fiat only from its warm rate cache (aiolabs/boltcards fix/session-fiat-from-cache); when it returns a currency with fiat null, the store prices the balance in that currency from the ATM's own rate source after the unlock, so the chip still shows the wallet's currency. Log how long the session call took and whether the server priced it, so the next latency question can be answered from the journal. Co-Authored-By: Claude Fable 5.1 --- apps/machine/src/stores/atm.ts | 26 ++++++++++++++++++++++++++ docs/boltcard-session.md | 8 +++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index bb5bc8b..c868322 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -343,6 +343,10 @@ export const useAtmStore = defineStore('atm', () => { // The card balance is hidden by default on the public screen; the holder // reveals it with the eye toggle. Resets on re-lock. const cardBalanceRevealed = ref(false) + // When the card server names a currency but didn't price the balance (its + // rate cache was cold — it never blocks the unlock on a rate lookup), price + // it here from the ATM's own rate source, in that currency. + const cardFiatRate = ref<{ currency: string; btcPrice: number } | null>(null) const fiatCode = ref('USD') // Defaults are 0 — the operator's fee config (received via Nostr // kind-30078 `bitspire-fees:` envelope from satmachineadmin) @@ -488,6 +492,10 @@ export const useAtmStore = defineStore('atm', () => { const card = loadedBoltCard.value if (!card) return null if (card.currency && card.fiat !== null) return { amount: card.fiat, currency: card.currency } + const rate = cardFiatRate.value + if (card.currency && rate && rate.currency === card.currency) { + return { amount: (card.balanceSats / 1e8) * rate.btcPrice, currency: card.currency } + } if (btcPrice.value && btcPrice.value > 0) { return { amount: (card.balanceSats / 1e8) * btcPrice.value, currency: fiatCode.value } } @@ -556,6 +564,7 @@ export const useAtmStore = defineStore('atm', () => { if (state === 'locked' && loadedBoltCard.value) { loadedBoltCard.value = null cardBalanceRevealed.value = false + cardFiatRate.value = null } // Detect network from first invoice we see @@ -779,7 +788,14 @@ export const useAtmStore = defineStore('atm', () => { boltCardProcessing.value = true nfcStatus.value = { state: 'processing', message: 'Verifying card…' } try { + const t0 = Date.now() const opened = await window.electronAPI.openCardSession({ lnurlw }) + console.info( + `[ATM] Card session ${opened.ok ? 'opened' : 'refused'} in ${Date.now() - t0} ms` + + (opened.ok + ? ` (fiat ${opened.session.fiat === null ? 'not ' : ''}priced by card server)` + : '') + ) if (!opened.ok) { nfcStatus.value = { state: 'declined', message: opened.reason } denyAccess(opened.reason) @@ -793,8 +809,18 @@ export const useAtmStore = defineStore('atm', () => { if (outcome.status === 'granted') { loadedBoltCard.value = opened.session cardBalanceRevealed.value = false + cardFiatRate.value = null nfcStatus.value = { state: 'accepted', message: 'Card accepted' } grantAccess(outcome.role, outcome.credentialIdHash) + // Price the balance in the card's currency off the unlock path. + const { currency, fiat, externalId } = opened.session + if (currency && fiat === null) { + void fetchBtcPrice(currency).then((price) => { + if (price && loadedBoltCard.value?.externalId === externalId) { + cardFiatRate.value = { currency, btcPrice: price } + } + }) + } } else { // pin-required can't occur for card-only open-enrollment; treat as denied. const reason = outcome.status === 'denied' ? outcome.reason : 'card not authorized' diff --git a/docs/boltcard-session.md b/docs/boltcard-session.md index 2daa775..e93242a 100644 --- a/docs/boltcard-session.md +++ b/docs/boltcard-session.md @@ -63,9 +63,11 @@ one `hit` is recorded. - `balance_msat` — the card wallet's balance. Display only. - `currency` / `fiat` — the balance priced the way the LNbits wallet page does it: the wallet's own currency (per-wallet setting) first, else the instance's - default accounting currency, at the server's rate. `null` when the server has - no currency or the rate lookup failed; the ATM then prices the sats itself in - its own fiat at its display rate. A rate failure never fails the session. + default accounting currency. `fiat` is filled **only from the server's + already-warm rate cache** — this response gates the unlock, and a cold rate + lookup queries external exchanges (~1 s). On a cache miss it is `null` and + the ATM prices the sats itself: in `currency` from its own rate source, else + in its own fiat at its display rate. No rate lookup ever blocks the session. - `withdraw` — the LUD-03 second step. The ATM calls `callback?k1=&pr=` at cash-out Complete. `null` with `withdraw_blocked_reason` set when `/scan` would have refused (daily limit From aa22ba1c27b8744d491e0dbe018954659222a0d6 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 14:54:57 +0200 Subject: [PATCH 101/118] fix(machine): keep the mouse pointer visible on the web demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index.html's inline From ebb07ce22d2227f9b4c3e58da4ae256ce88fe6cf Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 18:16:06 +0200 Subject: [PATCH 102/118] fix(lightning): arm the cash-out settlement watch before the invoice is shown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A one-tap Bolt Card Complete settles in about a second. Subscribing took two sequential nostr round trips first — decode_payment to recover the hash, then subscribe_payments — roughly eight seconds against a remote relay, because the watch was armed when the invoice was DISPLAYED. The settlement push is an ephemeral event with no replay, so it fired before anything was listening: the machine sat on a paid invoice until it timed out and the customer's sats were taken with no cash dispensed. On sintra 2026-09-22 this hit both one-tap sells (26,660 and 26,500 sats). The old two-tap flow only ever worked because fumbling with the card covered the window; at 07:18 the push landed two seconds after the watch went live. Three layered defences, one mechanism: - Arm at creation. generateInvoice does not resolve until the watch is live, so the invoice cannot reach the screen unwatched. - Take the payment hash from the create_invoice response instead of decoding it back off the bolt11 — the value was already in hand and the round trip was half the window (repo guidance says as much). - Latch and poll. A settlement that still beats the consumer is replayed on attach, and get_payment runs alongside the subscription so a push that is lost or never sent cannot strand a payment either. Co-Authored-By: Claude Fable 5.1 --- .../__tests__/settlement-watch.test.ts | 148 ++++++++++++ apps/machine/src/services/lightning.ts | 226 ++++++++++++++---- 2 files changed, 331 insertions(+), 43 deletions(-) create mode 100644 apps/machine/src/services/__tests__/settlement-watch.test.ts diff --git a/apps/machine/src/services/__tests__/settlement-watch.test.ts b/apps/machine/src/services/__tests__/settlement-watch.test.ts new file mode 100644 index 0000000..87a136b --- /dev/null +++ b/apps/machine/src/services/__tests__/settlement-watch.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { initialContext, type ATMContext } from '@bitSpire/state-machine' +import type { LnbitsClient, LnbitsPayment } from '@bitSpire/lnbits' +import { createATMServices } from '../lightning' + +/** + * The cash-out settlement watch (2026-09-22 regression). + * + * A one-tap Bolt Card Complete settles in about a second; subscribing over + * nostr takes several. When the watch was armed at display time the push — + * an ephemeral event with no replay — fired before anything listened, and the + * machine sat on a paid invoice until it timed out, taking the sats without + * dispensing. These pin the three defences: arm before the invoice is handed + * out, latch a settlement that still beats the consumer, and poll so a push + * that never arrives cannot strand a payment. + */ + +const BOLT11 = 'lnbc265u1p4t9gthpp5td44vd9a0s5er' +const HASH = 'aa'.repeat(32) + +const paid = (preimage = 'PREIMAGE'): LnbitsPayment => + ({ payment_hash: HASH, status: 'success', preimage }) as LnbitsPayment + +function makeLnbits(over: Partial> = {}) { + let pushTo: ((p: LnbitsPayment) => void) | null = null + const api = { + createInvoice: vi.fn(async () => ({ payment_request: BOLT11, payment_hash: HASH })), + subscribePayments: vi.fn( + async (_w: unknown, _f: unknown, onPush: (p: LnbitsPayment) => void) => { + pushTo = onPush + return 'sub-1' + } + ), + getPayment: vi.fn(async (): Promise => null), + unsubscribe: vi.fn(async () => true), + decodePayment: vi.fn(async () => ({ payment_hash: HASH })), + ...over, + } + return { api, push: (p: LnbitsPayment) => pushTo?.(p) } +} + +const ctx = (): ATMContext => ({ ...initialContext, satsAmount: 26_500, exchangeRate: 1325 }) + +const services = (l: { api: Record }) => + createATMServices(vi.fn(), l.api as unknown as LnbitsClient, 'wallet-1') + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +describe('cash-out settlement watch', () => { + it('is armed before the invoice is handed out, without decoding it back', async () => { + const l = makeLnbits() + const invoice = await services(l).generateInvoice(ctx()) + + expect(invoice).toBe(BOLT11) + // Armed during generateInvoice, not later at display time. + expect(l.api.subscribePayments).toHaveBeenCalledTimes(1) + expect(l.api.subscribePayments.mock.calls[0]![1]).toMatchObject({ payment_hash: HASH }) + // The hash came from the creation response, so no round trip to recover it. + expect(l.api.decodePayment).not.toHaveBeenCalled() + }) + + it('replays a settlement that beat the consumer (the race that lost payments)', async () => { + const l = makeLnbits() + const svc = services(l) + const invoice = await svc.generateInvoice(ctx()) + + // Card pays before the machine reaches displayingInvoice. + l.push(paid()) + + const onPaid = vi.fn() + svc.watchInvoice(invoice, onPaid) + await vi.advanceTimersByTimeAsync(0) + + expect(onPaid).toHaveBeenCalledWith('PREIMAGE') + }) + + it('delivers a push that arrives while the consumer is attached', async () => { + const l = makeLnbits() + const svc = services(l) + const invoice = await svc.generateInvoice(ctx()) + + const onPaid = vi.fn() + svc.watchInvoice(invoice, onPaid) + l.push(paid('LATER')) + + expect(onPaid).toHaveBeenCalledWith('LATER') + }) + + it('settles from the poll when no push ever arrives', async () => { + const l = makeLnbits() + l.api.getPayment = vi.fn(async () => paid('VIA-POLL')) + const svc = services(l) + const invoice = await svc.generateInvoice(ctx()) + + const onPaid = vi.fn() + svc.watchInvoice(invoice, onPaid) + expect(onPaid).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(7_000) + expect(onPaid).toHaveBeenCalledWith('VIA-POLL') + }) + + it('polls even when arming the subscription fails', async () => { + const l = makeLnbits() + l.api.subscribePayments = vi.fn(async () => { + throw new Error('relay down') + }) + l.api.getPayment = vi.fn(async () => paid('VIA-POLL')) + const svc = services(l) + const invoice = await svc.generateInvoice(ctx()) + + const onPaid = vi.fn() + svc.watchInvoice(invoice, onPaid) + await vi.advanceTimersByTimeAsync(7_000) + + expect(onPaid).toHaveBeenCalledWith('VIA-POLL') + }) + + it('reports each settlement once, whichever path saw it first', async () => { + const l = makeLnbits() + l.api.getPayment = vi.fn(async () => paid('VIA-POLL')) + const svc = services(l) + const invoice = await svc.generateInvoice(ctx()) + + const onPaid = vi.fn() + svc.watchInvoice(invoice, onPaid) + l.push(paid('VIA-PUSH')) + await vi.advanceTimersByTimeAsync(20_000) + + expect(onPaid).toHaveBeenCalledTimes(1) + expect(onPaid).toHaveBeenCalledWith('VIA-PUSH') + }) + + it('stops polling and unsubscribes when the transaction ends', async () => { + const l = makeLnbits() + const svc = services(l) + const invoice = await svc.generateInvoice(ctx()) + const stop = svc.watchInvoice(invoice, vi.fn()) + + stop() + expect(l.api.unsubscribe).toHaveBeenCalledWith(undefined, 'sub-1') + + const pollsAfterStop = (l.api.getPayment as ReturnType).mock.calls.length + await vi.advanceTimersByTimeAsync(30_000) + expect((l.api.getPayment as ReturnType).mock.calls.length).toBe(pollsAfterStop) + }) +}) diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index aa6b52c..ee9a441 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -217,7 +217,7 @@ export interface LightningBackend { }): Promise<{ paymentRequest: string; paymentHash?: string }> payInvoice( bolt11: string, - amountSats: number, + amountSats: number ): Promise<{ success: boolean; preimage?: string; error?: string }> } @@ -434,12 +434,12 @@ export async function initializeLightningServices(options?: { console.log( '[Lightning] Relay(s):', relays.join(', '), - envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)', + envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)' ) console.log( '[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)', - envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : '', + envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : '' ) // Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS // (env). An empty set disables the fees/operator-config services → the machine @@ -449,7 +449,7 @@ export async function initializeLightningServices(options?: { '[Lightning] Operator pubkey(s):', CONFIG.operatorPubkeys.length ? CONFIG.operatorPubkeys.join(', ') + ' (env)' - : '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)', + : '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)' ) // Strict mode: validate the RESOLVED config is production-ready (no @@ -473,7 +473,7 @@ export async function initializeLightningServices(options?: { if (!CONFIG.lnbitsServerPubkey) { throw new Error( '[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' + - 'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).', + 'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).' ) } @@ -542,7 +542,11 @@ export async function initializeLightningServices(options?: { const mc = await lnbits.getMachineConfig() if (mc.operator_pubkey) { CONFIG.operatorPubkeys = [mc.operator_pubkey] - console.log('[Lightning] Operator pubkey(s):', mc.operator_pubkey, '(server-delivered, #70 P1)') + console.log( + '[Lightning] Operator pubkey(s):', + mc.operator_pubkey, + '(server-delivered, #70 P1)' + ) } if (mc.fee_config && isElectron && window.electronAPI) { // Persist the server-delivered fee config so atm.ts's awaiting-fees gate @@ -555,17 +559,17 @@ export async function initializeLightningServices(options?: { cashOutFeeFraction: mc.fee_config.cash_out_fee_fraction, schemaVersion: mc.fee_config.schema_version, }, - mc.created_at, + mc.created_at ) console.log( '[Lightning] Server-delivered fee config:', - applied.applied ? 'applied' : `skipped (${applied.reason})`, + applied.applied ? 'applied' : `skipped (${applied.reason})` ) } } catch (e) { console.warn( '[Lightning] get_machine_config unavailable; falling back to env/kind-30078 for operator config:', - (e as Error).message, + (e as Error).message ) } } @@ -671,7 +675,7 @@ export async function initializeLightningServices(options?: { } }, lnbits, - lnbitsWalletId, + lnbitsWalletId ) return { @@ -706,13 +710,142 @@ export async function initializeLightningServices(options?: { /** * Create ATMServices implementation using the LNbits nostr-transport. */ -function createATMServices( +export function createATMServices( onPaymentSuccess: (preimage: string) => void, lnbits: LnbitsClient, - lnbitsWalletId: string, + lnbitsWalletId: string ): ATMServices { const onPaymentCallback = onPaymentSuccess + // ── Cash-out settlement watch ─────────────────────────────────────────── + /** + * A watch on one cash-out invoice, armed the moment the invoice exists and + * consumed later by the state machine's `displayingInvoice` actor. + * + * Arming at creation rather than at display closes a race that swallowed + * real payments on 2026-09-22 (sintra). Subscribing costs a nostr round + * trip — about four seconds against a remote relay — while a one-tap Bolt + * Card Complete settles in roughly one. The settlement push is an ephemeral + * event with no replay, so it fired before anything was listening and the + * machine sat on a paid invoice until it timed out, taking the sats without + * dispensing. The old two-tap flow only worked because fumbling with the + * card covered the gap. + * + * Three defences, in order: the invoice is not returned until its watch is + * armed; a settlement that still beats the UI is latched and replayed when + * the consumer attaches; and a poll runs alongside the subscription so a + * lost or unsent push cannot strand a payment either way. + */ + interface InvoiceWatch { + paymentHash: string + subId: string | null + /** Preimage seen before a consumer attached; replayed on attach. */ + settled: string | null + consumer: ((preimage: string) => void) | null + poll: ReturnType | null + released: boolean + } + const invoiceWatches = new Map() + + // Backstop cadence. One cheap RPC; on the money path a little extra relay + // traffic is worth far more than a payment that lands with no cash. + const SETTLEMENT_POLL_MS = 6_000 + + function stopInvoiceWatchPoll(watch: InvoiceWatch): void { + if (watch.poll) { + clearInterval(watch.poll) + watch.poll = null + } + } + + /** Deliver a settlement exactly once, to the consumer or into the latch. */ + function settleInvoiceWatch(watch: InvoiceWatch, preimage: string, via: string): void { + if (watch.released || watch.settled) return + watch.settled = preimage + stopInvoiceWatchPoll(watch) + console.log(`[ATM Service] Invoice paid (${via})!`) + watch.consumer?.(preimage) + } + + function startInvoiceWatchPoll(watch: InvoiceWatch): void { + let inFlight = false + watch.poll = setInterval(() => { + if (inFlight || watch.settled || watch.released) return + inFlight = true + void lnbits + .getPayment(watch.paymentHash) + .then((payment) => { + if (payment?.status === 'success') { + settleInvoiceWatch(watch, payment.preimage ?? 'payment-confirmed', 'poll') + } + }) + .catch(() => { + /* transport blip — the next tick retries */ + }) + .finally(() => { + inFlight = false + }) + }, SETTLEMENT_POLL_MS) + } + + /** Arm the watch for a freshly created invoice. Resolves once it is live. */ + async function armInvoiceWatch(bolt11: string, paymentHash: string): Promise { + const startedAt = Date.now() + const watch: InvoiceWatch = { + paymentHash, + subId: null, + settled: null, + consumer: null, + poll: null, + released: false, + } + invoiceWatches.set(bolt11, watch) + try { + // walletId omitted: payment_hash is the natural primary key for "wait + // for THIS invoice to settle." Under path B + // (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment to + // the operator's wallet, so a subscription scoped to the ATM's + // pre-override wallet_id would AND-filter the settlement out and never + // fire. With wallet_id omitted, lnbits resolves the wallet from + // get_standalone_payment(payment_hash) and ownership-checks against the + // auth'd account — works on both pre/post-override wallets. + // Coordination log 2026-05-31T18:50Z (lnbits) for the confirmation, + // §18:35Z for the joint smoke that surfaced the bug. + watch.subId = await lnbits.subscribePayments( + undefined, + { payment_hash: paymentHash, max_seconds: 600 }, + (push) => { + if (push.payment_hash !== paymentHash || push.status !== 'success') return + settleInvoiceWatch(watch, push.preimage ?? 'payment-confirmed', 'LNbits push') + }, + (reason) => console.log(`[ATM Service] Settlement subscription closed (${reason})`) + ) + console.log( + `[ATM Service] Settlement watch armed in ${Date.now() - startedAt} ms ` + + `(hash ${paymentHash.slice(0, 12)}…)` + ) + } catch (e) { + // The poll below then carries settlement on its own, which is exactly + // why it runs whether or not the subscription came up. + console.error('[ATM Service] Settlement subscribe failed — polling only:', e) + } + startInvoiceWatchPoll(watch) + } + + /** Tear a watch down: the transaction ended, one way or another. */ + function releaseInvoiceWatch(bolt11: string): void { + const watch = invoiceWatches.get(bolt11) + if (!watch) return + watch.released = true + watch.consumer = null + stopInvoiceWatchPoll(watch) + invoiceWatches.delete(bolt11) + if (watch.subId) { + // wallet_id omitted to match the subscribePayments call above. + void lnbits.unsubscribe(undefined, watch.subId).catch(() => {}) + } + } + return { /** * 3b.4: ndebit cash-in path removed. CashInView.vue ignores this @@ -806,7 +939,7 @@ function createATMServices( if (onPaymentCallback) { onPaymentCallback(push.preimage ?? `lnurl-withdraw-${link.link_id}`) } - }, + } ) // Wire per-session cleanup so abort/expiry tears it down cleanly. const session = lnurlSessions.get(link.link_id) @@ -849,15 +982,14 @@ function createATMServices( * matches machine fiat_code * - `type: "cash_out"` / `source: "bitspire"` — discriminators */ + // (see armInvoiceWatch below — the watch is armed before this resolves) generateInvoice: async (context: ATMContext): Promise => { const amountSats = context.satsAmount // Cash-out: satsAmount = principal + commission. principal is // derived from the raw market rate (no commission baked in) so a // consumer can independently audit the split. const principalSats = - context.exchangeRate > 0 - ? Math.floor((context.fiatCents / 100) * context.exchangeRate) - : 0 + context.exchangeRate > 0 ? Math.floor((context.fiatCents / 100) * context.exchangeRate) : 0 const feeSats = Math.max(0, amountSats - principalSats) console.log( '[ATM Service] Generating invoice — gross', @@ -897,6 +1029,17 @@ function createATMServices( if (!payment.payment_request) { throw new Error('LNbits createInvoice returned empty payment_request') } + // Arm the settlement watch BEFORE the invoice reaches the screen, and + // take the payment hash from the response we already have rather than + // spending a round trip decoding it back off the bolt11. See + // armInvoiceWatch for why the timing matters. + if (payment.payment_hash) { + await armInvoiceWatch(payment.payment_request, payment.payment_hash) + } else { + console.error( + '[ATM Service] createInvoice returned no payment_hash — settlement watch will arm late' + ) + } return payment.payment_request }, @@ -1063,15 +1206,30 @@ function createATMServices( * push, filtered by payment_hash. Returns a cleanup function. */ watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => { - console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...') - if (!invoice.toLowerCase().startsWith('ln')) { console.error('[ATM Service] Invalid invoice format - expected BOLT11') return () => {} } + console.log('[ATM Service] Watching invoice for payment:', invoice.slice(0, 32) + '...') + const armed = invoiceWatches.get(invoice) + if (armed) { + armed.consumer = callback + // Settled between arming and display (a one-tap card pull can beat the + // state transition): replay the latched settlement instead of waiting + // on a push that has already come and gone. + if (armed.settled) { + const preimage = armed.settled + queueMicrotask(() => callback(preimage)) + } + return () => releaseInvoiceWatch(invoice) + } + + // No armed watch: an invoice this service didn't create. Recover the + // hash over the wire and arm now. This is the pre-2026-09-22 behaviour + // and carries the race that arming-at-creation fixes, so say so. + console.warn('[ATM Service] No armed settlement watch for this invoice — arming late') let cancelled = false - let subId: string | null = null ;(async () => { try { const decoded = await lnbits.decodePayment(invoice) @@ -1081,36 +1239,18 @@ function createATMServices( return } if (cancelled) return - // walletId omitted: payment_hash is the natural primary key for - // "wait for THIS invoice to settle." Under path B - // (NOSTR_TRANSPORT_ROSTER_REQUIRED=true) lnbits routes the payment - // to the operator's wallet, so a subscription scoped to the ATM's - // pre-override wallet_id would AND-filter the settlement out and - // never fire. With wallet_id omitted, lnbits resolves the wallet - // from get_standalone_payment(payment_hash) and ownership-checks - // against the auth'd account — works on both pre/post-override - // wallets. Coordination log 2026-05-31T18:50Z (lnbits) for the - // confirmation, §18:35Z for the joint smoke that surfaced the bug. - subId = await lnbits.subscribePayments( - undefined, - { payment_hash: paymentHash, max_seconds: 600 }, - (push) => { - if (push.payment_hash !== paymentHash) return - if (push.status !== 'success') return - console.log('[ATM Service] Invoice paid (LNbits push)!') - callback(push.preimage ?? 'payment-confirmed') - }, - ) + await armInvoiceWatch(invoice, paymentHash) + const late = invoiceWatches.get(invoice) + if (!late || cancelled) return + late.consumer = callback + if (late.settled) callback(late.settled) } catch (e) { console.error('[ATM Service] LNbits watchInvoice failed:', e) } })() return () => { cancelled = true - if (subId) { - // wallet_id omitted to match the subscribePayments call above. - void lnbits.unsubscribe(undefined, subId).catch(() => {}) - } + releaseInvoiceWatch(invoice) } }, From 67573008ee81b29104a3b1efbec00284c497e06e Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 18:16:06 +0200 Subject: [PATCH 103/118] fix(machine): surface a payment taken with no cash dispensed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a card accepted a cash-out pull and settlement never confirmed, the machine returned to the amount screen as though nothing had happened — the customer's wallet had paid and there was nothing on screen or in the journal to say so. Latch that transition, log it with the txid, and show a red notice naming the reference an operator can reconcile against. Co-Authored-By: Claude Fable 5.1 --- apps/machine/src/stores/atm.ts | 29 ++++++++++++++++++++++++++ apps/machine/src/views/CashOutView.vue | 18 ++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index c868322..f9af8cb 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -343,6 +343,12 @@ export const useAtmStore = defineStore('atm', () => { // The card balance is hidden by default on the public screen; the holder // reveals it with the eye toggle. Resets on re-lock. const cardBalanceRevealed = ref(false) + // Set when a card accepted a cash-out pull and the machine then left the + // invoice screen without ever seeing the payment settle. The customer's + // wallet paid and no cash came out, so this must be visible and carry the + // reference an operator can reconcile against — never a silent return to + // the amount screen. Cleared when the next transaction starts. + const settlementError = ref<{ txid: string | null; message: string } | null>(null) // When the card server names a currency but didn't price the balance (its // rate cache was cold — it never blocks the unlock on a rate lookup), price // it here from the ATM's own rate source, in that currency. @@ -663,6 +669,26 @@ export const useAtmStore = defineStore('atm', () => { (prevNestedState === 'displayingInvoice' && currentNested !== 'displayingInvoice') || (prevNestedState === 'displayingQR' && currentNested !== 'displayingQR') if (leftBoltCardScreen) { + // The card accepted the pull (that's what leaves processing latched + // with an 'accepted' status) but we left the invoice screen for + // somewhere other than the dispenser: the payment was taken and no + // cash followed. Surface it with the txid instead of dropping the + // customer back on the amount screen as if nothing had happened. + if ( + prevNestedState === 'displayingInvoice' && + currentNested !== 'dispensingCash' && + boltCardProcessing.value && + nfcStatus.value?.state === 'accepted' + ) { + const txid = context.value?.txid ?? null + console.error( + `[ATM] Settlement never confirmed after the card accepted the pull — txid=${txid}` + ) + settlementError.value = { + txid, + message: 'Your payment was accepted but the cash was not dispensed.', + } + } boltCardProcessing.value = false nfcStatus.value = null } @@ -1786,10 +1812,12 @@ export const useAtmStore = defineStore('atm', () => { // Convenience methods for common events function selectCashIn() { + settlementError.value = null send({ type: 'SELECT_CASH_IN' }) } function selectCashOut() { + settlementError.value = null send({ type: 'SELECT_CASH_OUT' }) } @@ -1942,6 +1970,7 @@ export const useAtmStore = defineStore('atm', () => { simulateBoltCardEntry, // Access control (ADR-003) + settlementError, accessControl, isLocked, grantAccess, diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue index 50980ec..50a43a2 100644 --- a/apps/machine/src/views/CashOutView.vue +++ b/apps/machine/src/views/CashOutView.vue @@ -178,6 +178,24 @@ function formatFiat(cents: number): string { key="selectingAmount" class="flex flex-1 flex-col justify-center px-4 lg:px-[8vw] py-4 lg:py-6 gap-4 lg:gap-6" > + +
+

+ {{ atmStore.settlementError.message }} +

+

+ Please contact the operator. +

+
+
Date: Tue, 22 Sep 2026 17:50:49 +0200 Subject: [PATCH 104/118] fix(access): instrument Complete, and say when a card can't sell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Complete on a session that fails leaves nothing in the journal: the decline path has no logging, so a failed sell is indistinguishable from a button that never fired. Add the telemetry that was missing — completeWithCard logs outcome, duration and reason, and the entry line now says whether selling is available at all. Two real defects alongside it. A session whose withdraw step the card server withheld (daily limit spent, card disabled) still rendered a Complete Sale button that could only ever fail; it now shows the server's reason instead. And the decline path advised 'tap your card to try again' even for refusals a re-tap cannot lift, so 'blocked' is now its own outcome: nothing was consumed, the session stays loaded, and no re-tap is suggested. Co-Authored-By: Claude Fable 5.1 --- apps/machine/src/stores/atm.ts | 42 ++++++++++++++++++++------ apps/machine/src/views/CashOutView.vue | 13 ++++++++ 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index f9af8cb..0ed4719 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -301,7 +301,11 @@ export const useAtmStore = defineStore('atm', () => { // 'declined' — presented and refused, or failed after presentation. The // boltcards server bumps the SUN counter on the first GET, so // treat the voucher as spent even when the failure was ours. - type BoltCardOutcome = 'skipped' | 'accepted' | 'declined' + // 'blocked' — refused BEFORE anything was presented, because the card + // server withheld that step when the session opened (e.g. the + // card's daily limit is already spent). Nothing was consumed + // and no re-tap can lift it, so the session stays loaded. + type BoltCardOutcome = 'skipped' | 'accepted' | 'declined' | 'blocked' // Where a Complete gets its voucher: a card tapped right now on the cash // screen (raw lnurlw, spent by this call) or the session opened at entry // (hit-keyed steps, no p/c). @@ -715,6 +719,15 @@ export const useAtmStore = defineStore('atm', () => { const invoice = context.value?.invoice if (!invoice) return 'skipped' if (boltCardProcessing.value) return 'skipped' // one pull at a time + // The card server withheld the withdraw step when this session opened, so + // there is nothing to present. Say why instead of attempting a payment. + if ('session' in source && !source.session.withdraw) { + nfcStatus.value = { + state: 'declined', + message: source.session.withdrawBlockedReason ?? 'This card cannot sell right now', + } + return 'blocked' + } boltCardProcessing.value = true nfcStatus.value = { state: 'processing', message: 'Reading card…' } try { @@ -722,13 +735,12 @@ export const useAtmStore = defineStore('atm', () => { const api = window.electronAPI! const res = 'session' in source - ? source.session.withdraw - ? await api.withdrawWithSession({ - withdraw: plainWithdrawStep(source.session.withdraw), - bolt11: invoice, - amountMsat, - }) - : { ok: false, reason: source.session.withdrawBlockedReason ?? 'card cannot pay now' } + ? await api.withdrawWithSession({ + // Non-null: a withheld step is pre-flighted above. + withdraw: plainWithdrawStep(source.session.withdraw!), + bolt11: invoice, + amountMsat, + }) : await api.lnurlWithdraw({ lnurlw: source.lnurlw, bolt11: invoice, amountMsat }) if (res.ok) { nfcStatus.value = { state: 'accepted', message: 'Card accepted — confirming payment…' } @@ -819,7 +831,10 @@ export const useAtmStore = defineStore('atm', () => { console.info( `[ATM] Card session ${opened.ok ? 'opened' : 'refused'} in ${Date.now() - t0} ms` + (opened.ok - ? ` (fiat ${opened.session.fiat === null ? 'not ' : ''}priced by card server)` + ? ` (fiat ${opened.session.fiat === null ? 'not ' : ''}priced by card server; sell ` + + (opened.session.withdraw + ? 'available)' + : `BLOCKED: ${opened.session.withdrawBlockedReason ?? 'withdraw step withheld'})`) : '') ) if (!opened.ok) { @@ -878,10 +893,19 @@ export const useAtmStore = defineStore('atm', () => { async function completeWithCard() { const card = loadedBoltCard.value if (!card) return + const t0 = Date.now() let outcome: BoltCardOutcome = 'skipped' if (isCashOut.value) outcome = await handleBoltCardTap({ session: card }) else if (isCashIn.value) outcome = await handleBoltCardReceive({ session: card }) + console.info( + `[ATM] Complete with card: ${outcome} in ${Date.now() - t0} ms` + + (outcome === 'accepted' ? '' : ` — ${nfcStatus.value?.message ?? 'no reason given'}`) + ) if (outcome === 'skipped') return + // Blocked is the card server's standing answer for this visit: nothing was + // consumed, and re-tapping cannot lift it. Keep the session loaded so the + // chip still shows whose card it is, and skip the re-tap advice below. + if (outcome === 'blocked') return loadedBoltCard.value = null if (outcome === 'declined') { const reason = nfcStatus.value?.message ?? 'Card declined' diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue index 50a43a2..896f165 100644 --- a/apps/machine/src/views/CashOutView.vue +++ b/apps/machine/src/views/CashOutView.vue @@ -353,7 +353,20 @@ function formatFiat(cents: number): string { class="flex w-full max-w-md flex-col items-center gap-2 pt-2" > + +

+ {{ + atmStore.loadedBoltCard.withdrawBlockedReason ?? 'This card cannot sell right now' + }} +