From a0c2f38ef0e9820c5d5e7b1027088420a62725f8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 12 Jun 2026 17:36:19 +0200 Subject: [PATCH 001/164] 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/164] 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/164] =?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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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/164] 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' + }} +

- - - -
-

Rate: 1,000 sats/$

-
- - - - - - - - - -
- - -
-

Activity Log

-
-
- - - - -` - -// Create HTTP server -const server = http.createServer((req, res) => { - res.writeHead(200, { 'Content-Type': 'text/html' }) - res.end(html) -}) - -// Create WebSocket server -const wss = new WebSocketServer({ server }) - -wss.on('connection', (ws) => { - wsClients.push(ws) - ws.on('close', () => { - wsClients = wsClients.filter((c) => c !== ws) - }) - - ws.on('message', (data) => { - try { - const msg = JSON.parse(data) - if (msg.type === 'insert_cash' && typeof msg.amount === 'number') { - insertCash(msg.amount) - } else if (msg.type === 'reset') { - resetAtm() - } - } catch (e) {} - }) - - // Send current state - if (ndebit) { - ws.send( - JSON.stringify({ - type: 'status', - balance: atmBalance, - ndebit, - ndebitQR, - ndebitUri: withdrawAmount > 0 ? formatNdebitUri(ndebit, withdrawAmount) : null, - withdrawAmount, - cashInserted, - atmState, - satsPerUsd: SATS_PER_USD, - isLinked, - }) - ) - } -}) - -// Start server -server.listen(PORT, async () => { - console.log(`Mock ATM Machine running at http://localhost:${PORT}`) - await initialize() -}) diff --git a/packages/nostr-client/dev/nip44v1.mjs b/packages/nostr-client/dev/nip44v1.mjs deleted file mode 100644 index da908d5..0000000 --- a/packages/nostr-client/dev/nip44v1.mjs +++ /dev/null @@ -1,111 +0,0 @@ -/** - * NIP-44 v1 Implementation - * - * This is the XChaCha20-based encryption used by Lightning.Pub for Kind 21000 RPC events. - * It differs from standard NIP-44 v2 (used in nostr-tools) which uses ChaCha20-Poly1305. - */ - -import { base64 } from '@scure/base' -import { randomBytes } from '@noble/hashes/utils.js' -import { streamXOR as xchacha20 } from '@stablelib/xchacha20' -import { secp256k1 } from '@noble/curves/secp256k1.js' -import { sha256 } from '@noble/hashes/sha2.js' - -const XCHACHA20_VERSION = 1 - -/** - * Convert hex string to Uint8Array - * @param {string} hex - Hex string - * @returns {Uint8Array} - */ -function hexToBytes(hex) { - const bytes = new Uint8Array(hex.length / 2) - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16) - } - return bytes -} - -/** - * Get shared secret for NIP-44 v1 encryption - * @param {Uint8Array|string} privateKey - Private key (32 bytes or hex string) - * @param {string} publicKey - Public key (32 bytes hex string, no prefix) - * @returns {Uint8Array} - 32-byte shared secret - */ -export function getConversationKey(privateKey, publicKey) { - // Convert private key to Uint8Array if it's a hex string - const privKeyBytes = typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey - - // Convert public key (with 02 prefix) to Uint8Array - const pubKeyBytes = hexToBytes('02' + publicKey) - - // Get ECDH shared point - const sharedPoint = secp256k1.getSharedSecret(privKeyBytes, pubKeyBytes) - - // Hash the x-coordinate of the shared point - return sha256(sharedPoint.slice(1, 33)) -} - -/** - * Encrypt content using NIP-44 v1 (XChaCha20) - * @param {string} content - Plaintext content to encrypt - * @param {Uint8Array} conversationKey - 32-byte conversation key from getConversationKey - * @returns {string} - Base64-encoded encrypted payload - */ -export function encrypt(content, conversationKey) { - const nonce = randomBytes(24) - const plaintext = new TextEncoder().encode(content) - - // XChaCha20 stream cipher - encrypts in place - const ciphertext = new Uint8Array(plaintext.length) - xchacha20(conversationKey, nonce, plaintext, ciphertext) - - // Encode: version byte + nonce + ciphertext - return base64.encode(new Uint8Array([XCHACHA20_VERSION, ...nonce, ...ciphertext])) -} - -/** - * Decrypt content using NIP-44 v1 (XChaCha20) - * @param {string} content - Base64-encoded encrypted payload - * @param {Uint8Array} conversationKey - 32-byte conversation key from getConversationKey - * @returns {string} - Decrypted plaintext - */ -export function decrypt(content, conversationKey) { - const payload = decodePayload(content) - - // XChaCha20 stream cipher - decrypts in place - const plaintext = new Uint8Array(payload.ciphertext.length) - xchacha20(conversationKey, payload.nonce, payload.ciphertext, plaintext) - - return new TextDecoder().decode(plaintext) -} - -/** - * Decode encrypted payload (supports both formats) - * @param {string} content - Base64-encoded or JSON-encoded payload - * @returns {{nonce: Uint8Array, ciphertext: Uint8Array}} - */ -function decodePayload(content) { - // Check for JSON format - if (content.startsWith('{') && content.endsWith('}')) { - const parsed = JSON.parse(content) - if (parsed.v !== XCHACHA20_VERSION) { - throw new Error(`Unsupported encryption version: ${parsed.v}`) - } - return { - nonce: base64.decode(parsed.nonce), - ciphertext: base64.decode(parsed.ciphertext), - } - } - - // Binary format: version byte + nonce (24 bytes) + ciphertext - const buf = base64.decode(content) - if (buf[0] !== XCHACHA20_VERSION) { - throw new Error(`Unsupported encryption version: ${buf[0]}`) - } - - return { - nonce: buf.subarray(1, 25), - ciphertext: buf.subarray(25), - } -} diff --git a/packages/nostr-client/dev/run-debit-agent.mjs b/packages/nostr-client/dev/run-debit-agent.mjs deleted file mode 100644 index 8dd87c9..0000000 --- a/packages/nostr-client/dev/run-debit-agent.mjs +++ /dev/null @@ -1,221 +0,0 @@ -#!/usr/bin/env node -/** - * ATM Debit Approval Agent (TESTING ONLY) - * - * ⚠️ WARNING: This script auto-approves ALL debit requests without validation! - * ⚠️ DO NOT use in production - use the integrated debit approval service instead. - * - * Purpose: - * - Standalone debugging tool for testing the GetLiveDebitRequests subscription - * - Helps diagnose relay connectivity and message decryption issues - * - Useful when the integrated service isn't receiving events - * - * Usage: - * # Set environment variables (or create .env file in apps/machine/) - * export ATM_PRIVATE_KEY= - * export LIGHTNING_PUB_PUBKEY= - * export RELAY_URL=ws://localhost:7777 - * - * # Run the script - * node run-debit-agent.mjs - * - * Production alternative: - * The ATM app (apps/machine) includes an integrated debit approval service - * with session-based single-use protection. See: - * - apps/machine/src/services/lightning.ts (startDebitApprovalService) - * - docs/ndebit-cash-in-flow.md - */ - -import { Relay } from 'nostr-tools/relay' -import { finalizeEvent, getPublicKey } from 'nostr-tools' -import * as nip44v1 from './nip44v1.mjs' -import fs from 'node:fs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' - -// Load .env file from apps/machine if it exists -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const envPath = path.join(__dirname, '../../apps/machine/.env') -if (fs.existsSync(envPath)) { - const envContent = fs.readFileSync(envPath, 'utf-8') - for (const line of envContent.split('\n')) { - const trimmed = line.trim() - if (trimmed && !trimmed.startsWith('#')) { - const [key, ...valueParts] = trimmed.split('=') - if (key && valueParts.length > 0) { - // Map VITE_ prefixed vars to non-prefixed - const envKey = key.replace(/^VITE_/, '') - process.env[envKey] = valueParts.join('=') - } - } - } - console.log('[Config] Loaded .env from:', envPath) -} - -// Configuration from environment -const ATM_PRIVATE_KEY_HEX = process.env.ATM_PRIVATE_KEY -const LIGHTNING_PUB_PUBKEY = process.env.LIGHTNING_PUB_PUBKEY -const RELAY_URL = process.env.RELAY_URL || 'ws://localhost:7777' - -// Validate required config -if (!ATM_PRIVATE_KEY_HEX) { - console.error('ERROR: ATM_PRIVATE_KEY environment variable is required') - console.error('Set it directly or create apps/machine/.env with VITE_ATM_PRIVATE_KEY') - process.exit(1) -} - -if (!LIGHTNING_PUB_PUBKEY) { - console.error('ERROR: LIGHTNING_PUB_PUBKEY environment variable is required') - console.error('Set it directly or create apps/machine/.env with VITE_LIGHTNING_PUB_PUBKEY') - process.exit(1) -} - -const ATM_PRIVATE_KEY = Uint8Array.from(Buffer.from(ATM_PRIVATE_KEY_HEX, 'hex')) -const ATM_PUBLIC_KEY = getPublicKey(ATM_PRIVATE_KEY) - -console.log('') -console.log('='.repeat(60)) -console.log(' ATM Debit Approval Agent (TESTING ONLY)') -console.log('='.repeat(60)) -console.log('') -console.log('⚠️ WARNING: Auto-approves ALL requests without validation!') -console.log('⚠️ For production, use the integrated service in apps/machine') -console.log('') -console.log('ATM Pubkey:', ATM_PUBLIC_KEY) -console.log('Lightning.Pub Pubkey:', LIGHTNING_PUB_PUBKEY) -console.log('Relay URL:', RELAY_URL) -console.log('') - -async function main() { - // Connect to relay - console.log('Connecting to relay...') - const relay = await Relay.connect(RELAY_URL) - console.log('Connected!') - console.log('') - - // Create conversation key for NIP-44 v1 encryption - const conversationKey = nip44v1.getConversationKey(ATM_PRIVATE_KEY_HEX, LIGHTNING_PUB_PUBKEY) - - // Subscribe to GetLiveDebitRequests - console.log('Subscribing to live debit requests...') - - const subscribeRequest = { - rpcName: 'GetLiveDebitRequests', - authIdentifier: ATM_PUBLIC_KEY, - body: {}, - } - - const subEvent = finalizeEvent( - { - kind: 21000, - created_at: Math.floor(Date.now() / 1000), - tags: [['p', LIGHTNING_PUB_PUBKEY]], - content: nip44v1.encrypt(JSON.stringify(subscribeRequest), conversationKey), - }, - ATM_PRIVATE_KEY - ) - - // Listen for debit requests - console.log('Listening for debit requests...') - console.log('(Test by scanning ndebit QR with ShockWallet)') - console.log('') - - const debitSub = relay.subscribe( - [ - { - kinds: [21000], - authors: [LIGHTNING_PUB_PUBKEY], - '#p': [ATM_PUBLIC_KEY], - since: Math.floor(Date.now() / 1000) - 5, - }, - ], - { - async onevent(evt) { - try { - const decrypted = nip44v1.decrypt(evt.content, conversationKey) - const message = JSON.parse(decrypted) - - // Check if this is a live debit request - if (message.requestId === 'GetLiveDebitRequests' && message.debit) { - console.log('') - console.log('========================================') - console.log('DEBIT REQUEST RECEIVED!') - console.log(' Request ID:', message.request_id) - console.log(' From npub:', message.npub?.substring(0, 16) + '...') - console.log(' Debit type:', message.debit.type) - - if (message.debit.invoice) { - console.log(' Invoice:', message.debit.invoice.substring(0, 50) + '...') - - // Auto-approve by responding with INVOICE type - console.log('') - console.log('⚠️ AUTO-APPROVING debit request (NO VALIDATION)...') - - const approveRequest = { - rpcName: 'RespondToDebit', - authIdentifier: ATM_PUBLIC_KEY, - body: { - npub: message.npub, - request_id: message.request_id, - response: { - type: 'invoice', - invoice: message.debit.invoice, - }, - }, - } - - const approveEvent = finalizeEvent( - { - kind: 21000, - created_at: Math.floor(Date.now() / 1000), - tags: [['p', LIGHTNING_PUB_PUBKEY]], - content: nip44v1.encrypt(JSON.stringify(approveRequest), conversationKey), - }, - ATM_PRIVATE_KEY - ) - - await relay.publish(approveEvent) - console.log('APPROVED! (event id:', approveEvent.id.substring(0, 16) + '...)') - } - - console.log('========================================') - console.log('') - } else if (message.rpcName) { - console.log('RPC response:', message.rpcName, ':', message.status || 'received') - } else if (message.requestId) { - console.log('Live subscription active:', message.status) - } - } catch (err) { - // Ignore decryption failures for events not meant for us - if (!err.message?.includes('Unsupported')) { - // Uncomment for debugging: - // console.log('Decryption error:', err.message) - } - } - }, - } - ) - - await relay.publish(subEvent) - console.log('Subscription sent, waiting for debit requests...') - console.log('') - - // Keep running - process.on('SIGINT', () => { - console.log('\nShutting down...') - debitSub.close() - relay.close() - process.exit(0) - }) - - // Heartbeat - while (true) { - await new Promise((r) => setTimeout(r, 30000)) - console.log('Still listening...') - } -} - -main().catch((err) => { - console.error('Error:', err.message) - process.exit(1) -}) diff --git a/packages/nostr-client/dev/test-debit.mjs b/packages/nostr-client/dev/test-debit.mjs deleted file mode 100644 index c1861ac..0000000 --- a/packages/nostr-client/dev/test-debit.mjs +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Test CLINK Debit flow - * - * This simulates what ShockWallet does when scanning an ndebit: - * 1. Decode the ndebit to get pubkey, relay, pointer - * 2. Create a bolt11 invoice (we'll get one from Alice) - * 3. Send Kind 21002 debit request to Lightning.Pub - * 4. Wait for payment response - */ - -import { Relay } from 'nostr-tools/relay' -import { finalizeEvent, getPublicKey } from 'nostr-tools' -import { nip44 } from 'nostr-tools' -import { bech32 } from '@scure/base' -import { randomBytes } from 'crypto' - -// Generate a random keypair for this test (simulates ShockWallet) -const WALLET_PRIVATE_KEY = randomBytes(32) -const WALLET_PUBLIC_KEY = getPublicKey(WALLET_PRIVATE_KEY) - -// The ndebit to test -const NDEBIT = process.argv[2] -// The bolt11 invoice to be paid -const BOLT11 = process.argv[3] - -if (!NDEBIT || !BOLT11) { - console.log('Usage: node test-debit.mjs ') - console.log('') - console.log('Example:') - console.log(' # First create an invoice on Alice:') - console.log(' docker exec bitspire-lnd-alice lncli --network=regtest addinvoice --amt 1000') - console.log('') - console.log(' # Then test the debit:') - console.log(' node test-debit.mjs ndebit1... lnbcrt...') - process.exit(1) -} - -function decodeNdebit(ndebit) { - const { prefix, words } = bech32.decode(ndebit, 5000) - if (prefix !== 'ndebit') throw new Error('Invalid ndebit prefix') - - const data = new Uint8Array(bech32.fromWords(words)) - - let pubkey, relay, pointer - let offset = 0 - - while (offset < data.length) { - const type = data[offset] - const length = data[offset + 1] - const value = data.slice(offset + 2, offset + 2 + length) - - switch (type) { - case 0: - pubkey = Buffer.from(value).toString('hex') - break - case 1: - relay = new TextDecoder().decode(value) - break - case 2: - pointer = new TextDecoder().decode(value) - break - } - - offset += 2 + length - } - - return { pubkey, relay, pointer } -} - -async function main() { - console.log('=== CLINK Debit Test ===') - console.log('') - console.log('Test wallet pubkey:', WALLET_PUBLIC_KEY) - console.log('') - - // Decode ndebit - const debit = decodeNdebit(NDEBIT) - console.log('Decoded ndebit:') - console.log(' Pubkey:', debit.pubkey) - console.log(' Relay:', debit.relay) - console.log(' Pointer:', debit.pointer || '(none)') - console.log('') - - // Connect to relay (override Docker internal hostnames with localhost for local testing) - const relayUrl = debit.relay - .replace('host.docker.internal', 'localhost') - .replace('ws://strfry:', 'ws://localhost:') - console.log('Connecting to relay:', relayUrl) - const relay = await Relay.connect(relayUrl) - console.log('Connected!') - console.log('') - - // Build debit request payload - const requestPayload = { - pointer: debit.pointer, - bolt11: BOLT11, - } - - console.log('Request payload:', JSON.stringify(requestPayload, null, 2)) - console.log('') - - // Encrypt with NIP-44 - const conversationKey = nip44.getConversationKey(WALLET_PRIVATE_KEY, debit.pubkey) - const encryptedContent = nip44.encrypt(JSON.stringify(requestPayload), conversationKey) - - // Create Kind 21002 event - const event = finalizeEvent( - { - kind: 21002, - created_at: Math.floor(Date.now() / 1000), - tags: [ - ['p', debit.pubkey], - ['clink_version', '1'], - ], - content: encryptedContent, - }, - WALLET_PRIVATE_KEY - ) - - console.log('Publishing debit request (event id:', event.id.substring(0, 16) + '...)...') - - // Subscribe to responses - let responseReceived = false - const sub = relay.subscribe( - [ - { - kinds: [21002], - authors: [debit.pubkey], - '#p': [WALLET_PUBLIC_KEY], - '#e': [event.id], - since: Math.floor(Date.now() / 1000) - 5, - }, - ], - { - onevent(evt) { - console.log('') - console.log('Got response event:', evt.id.substring(0, 16) + '...') - try { - const decrypted = nip44.decrypt(evt.content, conversationKey) - const response = JSON.parse(decrypted) - console.log('Response:', JSON.stringify(response, null, 2)) - - if (response.res === 'ok') { - console.log('') - console.log('✅ DEBIT SUCCESS!') - if (response.preimage) { - console.log('Preimage:', response.preimage) - } - } else if (response.res === 'GFY') { - console.log('') - console.log('❌ DEBIT FAILED:', response.error) - } - - responseReceived = true - } catch (err) { - console.log('Failed to decrypt:', err.message) - } - }, - } - ) - - // Publish request - await relay.publish(event) - console.log('Request published, waiting for response...') - - // Wait for response - for (let i = 0; i < 30; i++) { - await new Promise((r) => setTimeout(r, 1000)) - if (responseReceived) break - if (i % 5 === 4) console.log('Still waiting... (' + (i + 1) + 's)') - } - - if (!responseReceived) { - console.log('') - console.log('❌ No response received within timeout') - } - - sub.close() - relay.close() -} - -main().catch(console.error) diff --git a/packages/nostr-client/dev/test-ndebit.mjs b/packages/nostr-client/dev/test-ndebit.mjs deleted file mode 100644 index bcddaf4..0000000 --- a/packages/nostr-client/dev/test-ndebit.mjs +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env node -/** - * Test script to simulate a wallet sending an ndebit claim request - * This tests whether Lightning.Pub sends Kind 21002 responses after the fix - */ - -import { Relay } from 'nostr-tools/relay' -import { nip44, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools' -import { decodeBech32 } from '@shocknet/clink-sdk' - -const { getConversationKey, encrypt, decrypt } = nip44 - -const NDEBIT = - 'ndebit1qgpkzardqyg8wue69uhhxarjvee8jw3hxumnwqpqf05wyqarxsdm9d62fh9lsa6wqc2r0a37cgd00mp3gnydpf5w9uusavytcn' -const RELAY_URL = 'ws://localhost:7777' -const AMOUNT_SATS = 5000 // Small test amount - -// Generate a wallet keypair for this test -const WALLET_PRIVATE_KEY = generateSecretKey() -const WALLET_PUBLIC_KEY = getPublicKey(WALLET_PRIVATE_KEY) - -async function main() { - console.log('🔧 Test: ndebit claim flow (NIP-44 v2)') - console.log('='.repeat(50)) - - // Decode ndebit to get Lightning.Pub pubkey and pointer - const decoded = decodeBech32(NDEBIT) - const LPUB_PUBKEY = decoded.data.pubkey - const POINTER = decoded.data.pointer - console.log(`\n📍 Lightning.Pub pubkey: ${LPUB_PUBKEY.slice(0, 16)}...`) - console.log(`🔑 Pointer (user ID): ${POINTER.slice(0, 16)}...`) - console.log(`👛 Test wallet pubkey: ${WALLET_PUBLIC_KEY.slice(0, 16)}...`) - console.log(`💰 Amount: ${AMOUNT_SATS} sats`) - - // Connect to relay - console.log(`\n🔌 Connecting to relay: ${RELAY_URL}`) - const relay = await Relay.connect(RELAY_URL) - console.log('✅ Connected!') - - // Build the debit request data (NdebitData format) - // Using newNdebitFullAccessRequest format with amount - const debitData = { - amount_sats: AMOUNT_SATS, - pointer: POINTER, - } - - // Encrypt using NIP-44 v2 - const conversationKey = getConversationKey(WALLET_PRIVATE_KEY, LPUB_PUBKEY) - const encryptedContent = encrypt(JSON.stringify(debitData), conversationKey) - - // Build event with correct tags (including clink_version) - const event = finalizeEvent( - { - kind: 21002, - created_at: Math.floor(Date.now() / 1000), - tags: [ - ['p', LPUB_PUBKEY], - ['clink_version', '1'], - ], - content: encryptedContent, - }, - WALLET_PRIVATE_KEY - ) - - console.log(`\n📤 Sending Kind 21002 debit request`) - console.log(` Event ID: ${event.id.slice(0, 16)}...`) - console.log(` Content length: ${encryptedContent.length} chars`) - - // Subscribe for responses BEFORE sending the request - let responseReceived = false - const startTime = Date.now() - - // Filter for Kind 21002 responses from Lightning.Pub that reference our event - const sub = relay.subscribe( - [ - { - kinds: [21002], - authors: [LPUB_PUBKEY], - '#p': [WALLET_PUBLIC_KEY], - '#e': [event.id], - since: Math.floor(Date.now() / 1000) - 5, - }, - ], - { - onevent(evt) { - console.log(`\n📥 Received Kind 21002 response!`) - console.log(` Event ID: ${evt.id.slice(0, 16)}...`) - console.log(` Author: ${evt.pubkey.slice(0, 16)}...`) - - // Check #e tag (should reference our original event) - const eTag = evt.tags.find((t) => t[0] === 'e') - if (eTag) { - console.log(` #e tag: ${eTag[1].slice(0, 16)}...`) - if (eTag[1] === event.id) { - console.log(' ✅ Correctly references our original event!') - } - } else { - console.log(' ⚠️ No #e tag found') - } - - try { - const response = JSON.parse(decrypt(evt.content, conversationKey)) - console.log(`\n📋 Response content:`) - console.log(JSON.stringify(response, null, 2)) - - if (response.res === 'OK') { - console.log('\n✅✅✅ SUCCESS! Lightning.Pub sent Kind 21002 response correctly!') - console.log(' The fix is working!') - } else if (response.res === 'GFY' || response.error) { - console.log(`\n⚠️ Response indicates error: ${response.error || 'unknown'}`) - console.log(' (Expected if payment denied or auth required)') - } - } catch (e) { - console.log(' ❌ Could not decrypt response:', e.message) - } - - responseReceived = true - }, - } - ) - - // Publish the debit request - await relay.publish(event) - console.log('✅ Request published!') - - // Wait for response with timeout - console.log('\n⏳ Waiting for Kind 21002 response (30s timeout)...') - const timeout = 30000 - const checkInterval = 1000 - - while (!responseReceived && Date.now() - startTime < timeout) { - await new Promise((r) => setTimeout(r, checkInterval)) - const elapsed = Math.floor((Date.now() - startTime) / 1000) - process.stdout.write(`\r ${elapsed}s elapsed...`) - } - - console.log('') - - if (!responseReceived) { - console.log('\n❌❌❌ TIMEOUT! No Kind 21002 response received.') - console.log(' This means the fix did NOT work or there was another issue.') - - // Let's check what Kind 21002 events exist - console.log('\n🔍 Checking for any Kind 21002 events on relay...') - let foundEvents = 0 - const allDebitSub = relay.subscribe( - [ - { - kinds: [21002], - limit: 10, - }, - ], - { - onevent(evt) { - foundEvents++ - const pTags = evt.tags.filter((t) => t[0] === 'p').map((t) => t[1].slice(0, 8) + '...') - const eTags = evt.tags.filter((t) => t[0] === 'e').map((t) => t[1].slice(0, 8) + '...') - console.log( - ` ${foundEvents}. id=${evt.id.slice(0, 12)}... by=${evt.pubkey.slice(0, 8)}... #p=${pTags.join(',')} #e=${eTags.join(',')}` - ) - }, - oneose() { - console.log(` (Found ${foundEvents} Kind 21002 events total)`) - }, - } - ) - - await new Promise((r) => setTimeout(r, 3000)) - allDebitSub.close() - } - - sub.close() - relay.close() - console.log('\n🏁 Test complete') - process.exit(responseReceived ? 0 : 1) -} - -main().catch((e) => { - console.error('Fatal error:', e) - process.exit(1) -}) diff --git a/packages/nostr-client/dev/test-pay.mjs b/packages/nostr-client/dev/test-pay.mjs deleted file mode 100644 index 4385322..0000000 --- a/packages/nostr-client/dev/test-pay.mjs +++ /dev/null @@ -1,116 +0,0 @@ -import { NostrClient, loadIdentityFromHex, encryptContent, decryptJSON } from '../dist/index.js' -import { Relay } from 'nostr-tools/relay' -import { finalizeEvent } from 'nostr-tools' -import { randomUUID } from 'crypto' - -const DEV_PRIVATE_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' -const LIGHTNING_PUB_PUBKEY = - process.env.LIGHTNING_PUB_PUBKEY || - '4a72e400a254bf74a70cc711ab97e461b8d4fd9738b1ac3b5194cdeb3192ab91' -const RELAY_URL = process.env.NOSTR_RELAY_URL || 'ws://localhost:7777' - -// Get a fresh invoice from Alice first: -// docker exec bitspire-lnd-alice lncli --network=regtest addinvoice --amt 1000 -const TEST_INVOICE = process.argv[2] - -if (!TEST_INVOICE) { - console.log('Usage: node test-pay.mjs ') - console.log( - 'Generate invoice: docker exec bitspire-lnd-alice lncli --network=regtest addinvoice --amt 1000' - ) - process.exit(1) -} - -async function main() { - const identity = loadIdentityFromHex(DEV_PRIVATE_KEY) - console.log('Using identity:', identity.publicKey) - - console.log('Connecting to relay...') - const relay = await Relay.connect(RELAY_URL) - console.log('Connected!') - - const requestId = randomUUID() - - // Check if invoice has amount (look for pattern before '1' separator) - const amountMatch = TEST_INVOICE.toLowerCase().match(/ln(?:bc|tb|bcrt)(\d+)?([munp])?1/) - const hasAmount = amountMatch && amountMatch[1] - console.log('Invoice amount match:', amountMatch ? amountMatch.slice(0, 3) : null) - console.log('Has embedded amount:', hasAmount) - - // Build body - amount is always required (use 0 for invoices with embedded amounts) - const body = { - invoice: TEST_INVOICE, - amount: hasAmount ? 0 : 2000, // 0 means "use invoice amount" - } - console.log('Body amount:', body.amount, hasAmount ? '(use invoice amount)' : '(explicit amount)') - - const rpcRequest = { - rpcName: 'PayInvoice', - params: {}, - query: {}, - body, - authIdentifier: identity.publicKey, - requestId, - } - - console.log('\nRequest structure:', JSON.stringify(rpcRequest, null, 2)) - - const encryptedContent = encryptContent(identity, LIGHTNING_PUB_PUBKEY, rpcRequest) - - const event = finalizeEvent( - { - kind: 21000, - created_at: Math.floor(Date.now() / 1000), - tags: [['p', LIGHTNING_PUB_PUBKEY]], - content: encryptedContent, - }, - identity.privateKey - ) - - console.log('\nPublishing PayInvoice request (event id:', event.id.substring(0, 16) + '...)...') - - // Subscribe to responses - const filter = { - kinds: [21000], - authors: [LIGHTNING_PUB_PUBKEY], - since: Math.floor(Date.now() / 1000) - 5, - } - - let responseReceived = false - const sub = relay.subscribe([filter], { - onevent(evt) { - // Check if for us - const pTags = evt.tags.filter((t) => t[0] === 'p') - if (!pTags.some((t) => t[1] === identity.publicKey)) return - - console.log('\nGot response event:', evt.id.substring(0, 16) + '...') - try { - const response = decryptJSON(identity, LIGHTNING_PUB_PUBKEY, evt.content) - console.log('Response:', JSON.stringify(response, null, 2)) - if (response.requestId === requestId) { - responseReceived = true - } - } catch (err) { - console.log('Failed to decrypt:', err.message) - } - }, - }) - - await relay.publish(event) - console.log('Request published, waiting for response...') - - // Wait for response - for (let i = 0; i < 20; i++) { - await new Promise((r) => setTimeout(r, 500)) - if (responseReceived) break - } - - if (!responseReceived) { - console.log('\nNo response received for our requestId within timeout') - } - - sub.close() - relay.close() -} - -main().catch(console.error) diff --git a/packages/nostr-client/package.json b/packages/nostr-client/package.json index e235660..ef5ff2b 100644 --- a/packages/nostr-client/package.json +++ b/packages/nostr-client/package.json @@ -21,20 +21,14 @@ "validate-schemas": "tsx scripts/validate-schemas.ts" }, "dependencies": { - "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", - "@scure/base": "^1.2.6", - "@shocknet/clink-sdk": "^1.5.4", - "@stablelib/xchacha20": "^2.0.1", "nostr-tools": "^2.10.0" }, "devDependencies": { "@types/node": "^22.19.7", - "qrcode": "^1.5.4", "tsx": "^4.19.0", "typescript": "^5.7.0", - "vitest": "^2.1.0", - "ws": "^8.19.0" + "vitest": "^2.1.0" }, "peerDependencies": { "typescript": "^5.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f91de1..0fe5e88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -211,21 +211,9 @@ importers: packages/nostr-client: dependencies: - '@noble/curves': - specifier: ^2.0.1 - version: 2.0.1 '@noble/hashes': specifier: ^2.0.1 version: 2.0.1 - '@scure/base': - specifier: ^1.2.6 - version: 1.2.6 - '@shocknet/clink-sdk': - specifier: ^1.5.4 - version: 1.5.4 - '@stablelib/xchacha20': - specifier: ^2.0.1 - version: 2.0.1 nostr-tools: specifier: ^2.10.0 version: 2.19.4(typescript@5.9.3) @@ -233,9 +221,6 @@ importers: '@types/node': specifier: ^22.19.7 version: 22.19.7 - qrcode: - specifier: ^1.5.4 - version: 1.5.4 tsx: specifier: ^4.19.0 version: 4.21.0 @@ -245,9 +230,6 @@ importers: vitest: specifier: ^2.1.0 version: 2.1.9(@types/node@22.19.7)(lightningcss@1.30.2) - ws: - specifier: ^8.19.0 - version: 8.19.0 packages/state-machine: dependencies: @@ -1107,28 +1089,10 @@ packages: resolution: {integrity: sha512-9On64rhzuqKdOQyiYLYv2lQOh3TZU/D3+IWCR5gk0alPel2nwpp4YwDEGiUBfrQZEdQ6xww0PWkzqth4wqwX3Q==} engines: {node: '>=12.0.0'} - '@shocknet/clink-sdk@1.5.4': - resolution: {integrity: sha512-YrKR7oFjzUmBhm4p8pT6mP3YC7UdWbIlh5PMXHSwSLfJhL8Ddx91NLIfOHUTcQa1cDf27uvillMFXYrVUMCRuQ==} - '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} - '@stablelib/binary@2.0.1': - resolution: {integrity: sha512-U9iAO8lXgEDONsA0zPPSgcf3HUBNAqHiJmSHgZz62OvC3Hi2Bhc5kTnQ3S1/L+sthDTHtCMhcEiklmIly6uQ3w==} - - '@stablelib/chacha@2.0.1': - resolution: {integrity: sha512-lS1FqtNqofxe2vLkRsLli2m3x/XanUyAYRphLhdHumKeIsLbjbCXdCq3Pf/eWiO7G3QlSG5ViqnoVjktzfLWMg==} - - '@stablelib/int@2.0.1': - resolution: {integrity: sha512-Ht63fQp3wz/F8U4AlXEPb7hfJOIILs8Lq55jgtD7KueWtyjhVuzcsGLSTAWtZs3XJDZYdF1WcSKn+kBtbzupww==} - - '@stablelib/wipe@2.0.1': - resolution: {integrity: sha512-1eU2K9EgOcV4qc9jcP6G72xxZxEm5PfeI5H55l08W95b4oRJaqhmlWRc4xZAm6IVSKhVNxMi66V67hCzzuMTAg==} - - '@stablelib/xchacha20@2.0.1': - resolution: {integrity: sha512-k55pNv7gIM4mUPU00+nJYTxKiUVNwAtsgrridC0aIU5cVbw9u6qP99x8ENu5eiwOEhZUNg+p3tTOLooCeAOJQA==} - '@swc/helpers@0.5.18': resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==} @@ -1590,10 +1554,6 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -1639,9 +1599,6 @@ packages: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} - cliui@6.0.0: - resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} - cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1744,10 +1701,6 @@ packages: supports-color: optional: true - decamelize@1.2.0: - resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} - engines: {node: '>=0.10.0'} - decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -1792,9 +1745,6 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} - dijkstrajs@1.0.3: - resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} - dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -1965,10 +1915,6 @@ packages: filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -2043,10 +1989,6 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@13.0.0: - resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} - engines: {node: 20 || >=22} - glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -2317,10 +2259,6 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} @@ -2356,10 +2294,6 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.4: - resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} - engines: {node: 20 || >=22} - lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} @@ -2543,14 +2477,6 @@ packages: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} - nostr-tools@2.15.1: - resolution: {integrity: sha512-LpetHDR9ltnkpJDkva/SONgyKBbsoV+5yLB8DWc0/U3lCWGtoWJw6Nbc2vR2Ai67RIQYrBQeZLyMlhwVZRK/9A==} - peerDependencies: - typescript: '>=5.0.0' - peerDependenciesMeta: - typescript: - optional: true - nostr-tools@2.19.4: resolution: {integrity: sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==} peerDependencies: @@ -2589,36 +2515,20 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - p-map@4.0.0: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -2631,10 +2541,6 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.1: - resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} - engines: {node: 20 || >=22} - pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -2669,10 +2575,6 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} - pngjs@5.0.0: - resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} - engines: {node: '>=10.13.0'} - postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -2723,11 +2625,6 @@ packages: peerDependencies: vue: ^3.0.0 - qrcode@1.5.4: - resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} - engines: {node: '>=10.13.0'} - hasBin: true - quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -2759,9 +2656,6 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - require-main-filename@2.0.0: - resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -2788,11 +2682,6 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true - rimraf@6.1.2: - resolution: {integrity: sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==} - engines: {node: 20 || >=22} - hasBin: true - roarr@2.15.4: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} @@ -3245,9 +3134,6 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - which-module@2.0.1: - resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3261,10 +3147,6 @@ packages: wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3276,18 +3158,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -3295,9 +3165,6 @@ packages: xstate@5.25.1: resolution: {integrity: sha512-oyvsNH5pF2qkHmiHEMdWqc3OjDtoZOH2MTAI35r01f/ZQWOD+VLOiYqo65UgQET0XMA5s9eRm8fnsIo+82biEw==} - y18n@4.0.3: - resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} - y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3305,18 +3172,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - yargs-parser@18.1.3: - resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} - engines: {node: '>=6'} - yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} - yargs@15.4.1: - resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} - engines: {node: '>=8'} - yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -3927,35 +3786,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@shocknet/clink-sdk@1.5.4': - dependencies: - '@noble/hashes': 1.8.0 - '@scure/base': 1.2.6 - nostr-tools: 2.15.1(typescript@5.9.3) - rimraf: 6.1.2 - typescript: 5.9.3 - '@sindresorhus/is@4.6.0': {} - '@stablelib/binary@2.0.1': - dependencies: - '@stablelib/int': 2.0.1 - - '@stablelib/chacha@2.0.1': - dependencies: - '@stablelib/binary': 2.0.1 - '@stablelib/wipe': 2.0.1 - - '@stablelib/int@2.0.1': {} - - '@stablelib/wipe@2.0.1': {} - - '@stablelib/xchacha20@2.0.1': - dependencies: - '@stablelib/binary': 2.0.1 - '@stablelib/chacha': 2.0.1 - '@stablelib/wipe': 2.0.1 - '@swc/helpers@0.5.18': dependencies: tslib: 2.8.1 @@ -4539,8 +4371,6 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - camelcase@5.3.1: {} - chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -4582,12 +4412,6 @@ snapshots: string-width: 4.2.3 optional: true - cliui@6.0.0: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -4678,8 +4502,6 @@ snapshots: dependencies: ms: 2.1.3 - decamelize@1.2.0: {} - decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -4719,8 +4541,6 @@ snapshots: detect-node@2.1.0: optional: true - dijkstrajs@1.0.3: {} - dir-compare@4.2.0: dependencies: minimatch: 3.1.2 @@ -4995,11 +4815,6 @@ snapshots: dependencies: minimatch: 5.1.6 - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -5101,12 +4916,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@13.0.0: - dependencies: - minimatch: 10.1.1 - minipass: 7.1.2 - path-scurry: 2.0.1 - glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -5368,10 +5177,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 - locate-path@5.0.0: - dependencies: - p-locate: 4.1.0 - lodash-es@4.17.23: {} lodash.defaults@4.2.0: {} @@ -5397,8 +5202,6 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.4: {} - lru-cache@6.0.0: dependencies: yallist: 4.0.0 @@ -5575,18 +5378,6 @@ snapshots: normalize-url@6.1.0: {} - nostr-tools@2.15.1(typescript@5.9.3): - dependencies: - '@noble/ciphers': 0.5.3 - '@noble/curves': 1.2.0 - '@noble/hashes': 1.3.1 - '@scure/base': 1.1.1 - '@scure/bip32': 1.3.1 - '@scure/bip39': 1.2.1 - nostr-wasm: 0.1.0 - optionalDependencies: - typescript: 5.9.3 - nostr-tools@2.19.4(typescript@5.9.3): dependencies: '@noble/ciphers': 0.5.3 @@ -5635,30 +5426,18 @@ snapshots: p-cancelable@2.1.1: {} - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 - p-map@4.0.0: dependencies: aggregate-error: 3.1.0 - p-try@2.2.0: {} - package-json-from-dist@1.0.1: {} path-browserify@1.0.1: {} - path-exists@4.0.0: {} - path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -5668,11 +5447,6 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 - path-scurry@2.0.1: - dependencies: - lru-cache: 11.2.4 - minipass: 7.1.2 - pathe@1.1.2: {} pathval@2.0.1: {} @@ -5701,8 +5475,6 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 - pngjs@5.0.0: {} - postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -5750,12 +5522,6 @@ snapshots: dependencies: vue: 3.5.27(typescript@5.9.3) - qrcode@1.5.4: - dependencies: - dijkstrajs: 1.0.3 - pngjs: 5.0.0 - yargs: 15.4.1 - quick-lru@5.1.1: {} rc@1.2.8: @@ -5810,8 +5576,6 @@ snapshots: require-directory@2.1.1: {} - require-main-filename@2.0.0: {} - resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -5835,11 +5599,6 @@ snapshots: dependencies: glob: 7.2.3 - rimraf@6.1.2: - dependencies: - glob: 13.0.0 - package-json-from-dist: 1.0.1 - roarr@2.15.4: dependencies: boolean: 3.2.0 @@ -6291,8 +6050,6 @@ snapshots: dependencies: defaults: 1.0.4 - which-module@2.0.1: {} - which@2.0.2: dependencies: isexe: 2.0.0 @@ -6306,12 +6063,6 @@ snapshots: dependencies: string-width: 4.2.3 - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -6326,39 +6077,16 @@ snapshots: wrappy@1.0.2: {} - ws@8.19.0: {} - xmlbuilder@15.1.1: {} xstate@5.25.1: {} - y18n@4.0.3: {} - y18n@5.0.8: {} yallist@4.0.0: {} - yargs-parser@18.1.3: - dependencies: - camelcase: 5.3.1 - decamelize: 1.2.0 - yargs-parser@21.1.1: {} - yargs@15.4.1: - dependencies: - cliui: 6.0.0 - decamelize: 1.2.0 - find-up: 4.1.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - require-main-filename: 2.0.0 - set-blocking: 2.0.0 - string-width: 4.2.3 - which-module: 2.0.1 - y18n: 4.0.3 - yargs-parser: 18.1.3 - yargs@17.7.2: dependencies: cliui: 8.0.1 From e907bcc08551bf1ac0c759fb84c47b518f480eda Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 9 Oct 2026 22:25:07 +0200 Subject: [PATCH 149/164] chore: stop tracking the generated .devenv.flake.nix devenv regenerates this file on every `devenv shell`; the committed copy was pinned to a directory that no longer exists (~/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next). Untracked and gitignored beside .devenv/. The file stays on disk. --- .devenv.flake.nix | 513 ---------------------------------------------- .gitignore | 1 + 2 files changed, 1 insertion(+), 513 deletions(-) delete mode 100644 .devenv.flake.nix diff --git a/.devenv.flake.nix b/.devenv.flake.nix deleted file mode 100644 index aeeee91..0000000 --- a/.devenv.flake.nix +++ /dev/null @@ -1,513 +0,0 @@ -{ - inputs = - let - vars = { - version = "1.11.2"; - system = "x86_64-linux"; - devenv_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next"; - project_input_ref = "path:/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next"; - devenv_dotfile = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next/.devenv"; - devenv_dotfile_path = ./.devenv; - devenv_tmpdir = "/run/user/1000"; - devenv_runtime = "/run/user/1000/devenv-f4ba770"; - devenv_istesting = false; - devenv_direnvrc_latest_version = 1; - container_name = null; - active_profiles = [ - ]; - hostname = "gizmo"; - username = "padreug"; - git_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages"; - secretspec = null; -}; - in - { - git-hooks.url = "github:cachix/git-hooks.nix"; - git-hooks.inputs.nixpkgs.follows = "nixpkgs"; - pre-commit-hooks.follows = "git-hooks"; - nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling"; - devenv.url = "github:cachix/devenv?dir=src/modules"; - } - // ( - if builtins.pathExists (vars.devenv_dotfile_path + "/flake.json") then - builtins.fromJSON (builtins.readFile (vars.devenv_dotfile_path + "/flake.json")) - else - { } - ); - - outputs = - { nixpkgs, ... }@inputs: - let - vars = { - version = "1.11.2"; - system = "x86_64-linux"; - devenv_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next"; - project_input_ref = "path:/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next"; - devenv_dotfile = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/lamassu-next/.devenv"; - devenv_dotfile_path = ./.devenv; - devenv_tmpdir = "/run/user/1000"; - devenv_runtime = "/run/user/1000/devenv-f4ba770"; - devenv_istesting = false; - devenv_direnvrc_latest_version = 1; - container_name = null; - active_profiles = [ - ]; - hostname = "gizmo"; - username = "padreug"; - git_root = "/home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages"; - secretspec = null; -}; - devenv = - if builtins.pathExists (vars.devenv_dotfile_path + "/devenv.json") then - builtins.fromJSON (builtins.readFile (vars.devenv_dotfile_path + "/devenv.json")) - else - { }; - - systems = [ - "x86_64-linux" - "aarch64-linux" - "x86_64-darwin" - "aarch64-darwin" - ]; - - # Function to create devenv configuration for a specific system with profiles support - mkDevenvForSystem = - targetSystem: - let - getOverlays = - inputName: inputAttrs: - map ( - overlay: - let - input = - inputs.${inputName} or (throw "No such input `${inputName}` while trying to configure overlays."); - in - input.overlays.${overlay} - or (throw "Input `${inputName}` has no overlay called `${overlay}`. Supported overlays: ${nixpkgs.lib.concatStringsSep ", " (builtins.attrNames input.overlays)}") - ) inputAttrs.overlays or [ ]; - overlays = nixpkgs.lib.flatten (nixpkgs.lib.mapAttrsToList getOverlays (devenv.inputs or { })); - permittedUnfreePackages = - devenv.nixpkgs.per-platform."${targetSystem}".permittedUnfreePackages - or devenv.nixpkgs.permittedUnfreePackages or [ ]; - pkgs = import nixpkgs { - system = targetSystem; - config = { - allowUnfree = - devenv.nixpkgs.per-platform."${targetSystem}".allowUnfree or devenv.nixpkgs.allowUnfree - or devenv.allowUnfree or false; - allowBroken = - devenv.nixpkgs.per-platform."${targetSystem}".allowBroken or devenv.nixpkgs.allowBroken - or devenv.allowBroken or false; - cudaSupport = - devenv.nixpkgs.per-platform."${targetSystem}".cudaSupport or devenv.nixpkgs.cudaSupport or false; - cudaCapabilities = - devenv.nixpkgs.per-platform."${targetSystem}".cudaCapabilities or devenv.nixpkgs.cudaCapabilities - or [ ]; - permittedInsecurePackages = - devenv.nixpkgs.per-platform."${targetSystem}".permittedInsecurePackages - or devenv.nixpkgs.permittedInsecurePackages or devenv.permittedInsecurePackages or [ ]; - allowUnfreePredicate = - if (permittedUnfreePackages != [ ]) then - (pkg: builtins.elem (nixpkgs.lib.getName pkg) permittedUnfreePackages) - else - (_: false); - }; - inherit overlays; - }; - inherit (pkgs) lib; - importModule = - path: - if lib.hasPrefix "./" path then - if lib.hasSuffix ".nix" path then - ./. + (builtins.substring 1 255 path) - else - ./. + (builtins.substring 1 255 path) + "/devenv.nix" - else if lib.hasPrefix "../" path then - # For parent directory paths, concatenate with /. - # ./. refers to the directory containing this file (project root) - # So ./. + "/../shared" = /../shared - if lib.hasSuffix ".nix" path then ./. + "/${path}" else ./. + "/${path}/devenv.nix" - else - let - paths = lib.splitString "/" path; - name = builtins.head paths; - input = inputs.${name} or (throw "Unknown input ${name}"); - subpath = "/${lib.concatStringsSep "/" (builtins.tail paths)}"; - devenvpath = "${input}" + subpath; - devenvdefaultpath = devenvpath + "/devenv.nix"; - in - if lib.hasSuffix ".nix" devenvpath then - devenvpath - else if builtins.pathExists devenvdefaultpath then - devenvdefaultpath - else - throw (devenvdefaultpath + " file does not exist for input ${name}."); - - # Phase 1: Base evaluation to extract profile definitions - baseProject = pkgs.lib.evalModules { - specialArgs = inputs // { - inherit inputs; - }; - modules = [ - ( - { config, ... }: - { - _module.args.pkgs = pkgs.appendOverlays (config.overlays or [ ]); - } - ) - (inputs.devenv.modules + /top-level.nix) - ( - { options, ... }: - { - config.devenv = lib.mkMerge [ - { - cliVersion = vars.version; - root = vars.devenv_root; - dotfile = vars.devenv_dotfile; - } - (pkgs.lib.optionalAttrs (builtins.hasAttr "tmpdir" options.devenv) { - tmpdir = vars.devenv_tmpdir; - }) - (pkgs.lib.optionalAttrs (builtins.hasAttr "isTesting" options.devenv) { - isTesting = vars.devenv_istesting; - }) - (pkgs.lib.optionalAttrs (builtins.hasAttr "runtime" options.devenv) { - runtime = vars.devenv_runtime; - }) - (pkgs.lib.optionalAttrs (builtins.hasAttr "direnvrcLatestVersion" options.devenv) { - direnvrcLatestVersion = vars.devenv_direnvrc_latest_version; - }) - ]; - } - ) - ( - { options, ... }: - { - config = lib.mkMerge [ - (pkgs.lib.optionalAttrs (builtins.hasAttr "git" options) { - git.root = vars.git_root; - }) - ]; - } - ) - (pkgs.lib.optionalAttrs (vars.container_name != null) { - container.isBuilding = pkgs.lib.mkForce true; - containers.${vars.container_name}.isBuilding = true; - }) - ] - ++ (map importModule (devenv.imports or [ ])) - ++ [ - (if builtins.pathExists ./devenv.nix then ./devenv.nix else { }) - (devenv.devenv or { }) - (if builtins.pathExists ./devenv.local.nix then ./devenv.local.nix else { }) - ( - if builtins.pathExists (vars.devenv_dotfile_path + "/cli-options.nix") then - import (vars.devenv_dotfile_path + "/cli-options.nix") - else - { } - ) - ]; - }; - - # Phase 2: Extract and apply profiles using extendModules with priority overrides - project = - let - # Build ordered list of profile names: hostname -> user -> manual - manualProfiles = vars.active_profiles; - currentHostname = vars.hostname; - currentUsername = vars.username; - hostnameProfiles = lib.optional ( - currentHostname != "" - && builtins.hasAttr currentHostname (baseProject.config.profiles.hostname or { }) - ) "hostname.${currentHostname}"; - userProfiles = lib.optional ( - currentUsername != "" && builtins.hasAttr currentUsername (baseProject.config.profiles.user or { }) - ) "user.${currentUsername}"; - - # Ordered list of profiles to activate - orderedProfiles = hostnameProfiles ++ userProfiles ++ manualProfiles; - - # Resolve profile extends with cycle detection - resolveProfileExtends = - profileName: visited: - if builtins.elem profileName visited then - throw "Circular dependency detected in profile extends: ${lib.concatStringsSep " -> " visited} -> ${profileName}" - else - let - profile = getProfileConfig profileName; - extends = profile.extends or [ ]; - newVisited = visited ++ [ profileName ]; - extendedProfiles = lib.flatten (map (name: resolveProfileExtends name newVisited) extends); - in - extendedProfiles ++ [ profileName ]; - - # Get profile configuration by name from baseProject - getProfileConfig = - profileName: - if lib.hasPrefix "hostname." profileName then - let - name = lib.removePrefix "hostname." profileName; - in - baseProject.config.profiles.hostname.${name} - else if lib.hasPrefix "user." profileName then - let - name = lib.removePrefix "user." profileName; - in - baseProject.config.profiles.user.${name} - else - let - availableProfiles = builtins.attrNames (baseProject.config.profiles or { }); - hostnameProfiles = map (n: "hostname.${n}") ( - builtins.attrNames (baseProject.config.profiles.hostname or { }) - ); - userProfiles = map (n: "user.${n}") (builtins.attrNames (baseProject.config.profiles.user or { })); - allAvailableProfiles = availableProfiles ++ hostnameProfiles ++ userProfiles; - in - baseProject.config.profiles.${profileName} - or (throw "Profile '${profileName}' not found. Available profiles: ${lib.concatStringsSep ", " allAvailableProfiles}"); - - # Fold over ordered profiles to build final list with extends - expandedProfiles = lib.foldl' ( - acc: profileName: - let - allProfileNames = resolveProfileExtends profileName [ ]; - in - acc ++ allProfileNames - ) [ ] orderedProfiles; - - # Map over expanded profiles and apply priorities - allPrioritizedModules = lib.imap0 ( - index: profileName: - let - # Decrement priority for each profile (lower = higher precedence) - # Start with the next lowest priority after the default priority for values (100) - profilePriority = (lib.modules.defaultOverridePriority - 1) - index; - profileConfig = getProfileConfig profileName; - - # Check if an option type needs explicit override to resolve conflicts - # Only apply overrides to LEAF values (scalars), not collection types that can merge - typeNeedsOverride = - type: - if type == null then - false - else - let - typeName = type.name or type._type or ""; - - # True leaf types that need priority resolution when they conflict - isLeafType = builtins.elem typeName [ - "str" - "int" - "bool" - "enum" - "path" - "package" - "float" - "anything" - ]; - in - if isLeafType then - true - else if typeName == "nullOr" then - # For nullOr, check the wrapped type recursively - let - innerType = - type.elemType - or (if type ? nestedTypes && type.nestedTypes ? elemType then type.nestedTypes.elemType else null); - in - if innerType != null then typeNeedsOverride innerType else false - else - # Everything else (collections, submodules, etc.) should merge naturally - false; - - # Check if a config path needs explicit override - pathNeedsOverride = - optionPath: - let - # Try direct option first - directOption = lib.attrByPath optionPath null baseProject.options; - in - if directOption != null && lib.isOption directOption then - typeNeedsOverride directOption.type - else if optionPath != [ ] then - # Check parent for freeform type - let - parentPath = lib.init optionPath; - parentOption = lib.attrByPath parentPath null baseProject.options; - in - if parentOption != null && lib.isOption parentOption then - let - # Look for freeform type: - # 1. Standard location: type.freeformType (primary) - # 2. Nested location: type.nestedTypes.freeformType (evaluated form) - freeformType = parentOption.type.freeformType or parentOption.type.nestedTypes.freeformType or null; - elementType = - if freeformType ? elemType then - freeformType.elemType - else if freeformType ? nestedTypes && freeformType.nestedTypes ? elemType then - freeformType.nestedTypes.elemType - else - freeformType; - in - typeNeedsOverride elementType - else - false - else - false; - - # Support overriding both plain attrset modules and functions - applyModuleOverride = - config: - if builtins.isFunction config then - let - wrapper = args: applyOverrideRecursive (config args) [ ]; - in - lib.mirrorFunctionArgs config wrapper - else - applyOverrideRecursive config [ ]; - - # Apply overrides recursively based on option types - applyOverrideRecursive = - config: optionPath: - if lib.isAttrs config && config ? _type then - config # Don't touch values with existing type metadata - else if lib.isAttrs config then - lib.mapAttrs (name: value: applyOverrideRecursive value (optionPath ++ [ name ])) config - else if pathNeedsOverride optionPath then - lib.mkOverride profilePriority config - else - config; - - # Apply priority overrides recursively to the deferredModule imports structure - prioritizedConfig = ( - profileConfig.module - // { - imports = lib.map ( - importItem: - importItem - // { - imports = lib.map (nestedImport: applyModuleOverride nestedImport) (importItem.imports or [ ]); - } - ) (profileConfig.module.imports or [ ]); - } - ); - in - prioritizedConfig - ) expandedProfiles; - in - if allPrioritizedModules == [ ] then - baseProject - else - baseProject.extendModules { modules = allPrioritizedModules; }; - - config = project.config; - - options = pkgs.nixosOptionsDoc { - options = builtins.removeAttrs project.options [ "_module" ]; - warningsAreErrors = false; - # Unpack Nix types, e.g. literalExpression, mDoc. - transformOptions = - let - isDocType = - v: - builtins.elem v [ - "literalDocBook" - "literalExpression" - "literalMD" - "mdDoc" - ]; - in - lib.attrsets.mapAttrs ( - _: v: - if v ? _type && isDocType v._type then - v.text - else if v ? _type && v._type == "derivation" then - v.name - else - v - ); - }; - - # Recursively search for outputs in the config. - # This is used when not building a specific output by attrpath. - build = - options: config: - lib.concatMapAttrs ( - name: option: - if lib.isOption option then - let - typeName = option.type.name or ""; - in - if - builtins.elem typeName [ - "output" - "outputOf" - ] - then - { ${name} = config.${name}; } - else - { } - else if builtins.isAttrs option && !lib.isDerivation option then - let - v = build option config.${name}; - in - if v != { } then - { - ${name} = v; - } - else - { } - else - { } - ) options; - in - { - inherit - config - options - build - project - ; - shell = config.shell; - packages = { - optionsJSON = options.optionsJSON; - # deprecated - inherit (config) - info - procfileScript - procfileEnv - procfile - ; - ci = config.ciDerivation; - }; - }; - - # Generate per-system devenv configurations - perSystem = nixpkgs.lib.genAttrs systems mkDevenvForSystem; - - # Default devenv for the current system - currentSystemDevenv = perSystem.${vars.system}; - in - { - devShell = nixpkgs.lib.genAttrs systems (s: perSystem.${s}.shell); - packages = nixpkgs.lib.genAttrs systems (s: perSystem.${s}.packages); - - # Per-system devenv configurations - devenv = { - # Default devenv for the current system - inherit (currentSystemDevenv) - config - options - build - shell - packages - project - ; - # Per-system devenv configurations - inherit perSystem; - }; - - # Legacy build output - build = currentSystemDevenv.build currentSystemDevenv.options currentSystemDevenv.config; - }; -} diff --git a/.gitignore b/.gitignore index ae58eb9..4c16c4e 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ apps/machine/src/services/*.js # devenv .devenv/ +.devenv.flake.nix .direnv/ .pre-commit-config.yaml From fa4858ed66673d7b576cbf8e7092ec309e574a5a Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 9 Oct 2026 22:25:42 +0200 Subject: [PATCH 150/164] chore: drop stragglers from the regtest-tooling removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .gitignore still carried rules for docker/**/data/ and docker/.state/ — paths that no longer exist — under a now-empty "# Docker" header. The docs skill's example sync report named LIGHTNING_PUB_URL as its sample env var; swapped for a variable that exists. --- .claude/skills/docs.md | 2 +- .gitignore | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.claude/skills/docs.md b/.claude/skills/docs.md index a9052d5..e9909ba 100644 --- a/.claude/skills/docs.md +++ b/.claude/skills/docs.md @@ -175,7 +175,7 @@ From git commits since last release: ### Suggested Updates 1. `api/clink.md:45` - Add `timeout` parameter to createOffer -2. `guides/development.md` - Add LIGHTNING_PUB_URL env var +2. `guides/development.md` - Add VITE_BITSPIRE_CASSETTES env var ### Missing Documentation - `packages/cashu/src/wallet.ts` - No API docs diff --git a/.gitignore b/.gitignore index 4c16c4e..3feb238 100644 --- a/.gitignore +++ b/.gitignore @@ -59,10 +59,6 @@ apps/machine/src/services/*.js .direnv/ .pre-commit-config.yaml -# Docker -docker/**/data/ -docker/.state/ - # Nix build outputs result result-* From 1a24bfdda8ccac5b083339af0a9fc80181049693 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:06:08 +0200 Subject: [PATCH 151/164] =?UTF-8?q?docs(adr):=20ADR-005=20=E2=80=94=20a=20?= =?UTF-8?q?partial=20dispense=20distributes=20once,=20when=20the=20outcome?= =?UTF-8?q?=20is=20final?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision 1's partial row said "operator confirms → distribute scaled" and left the undispensed remainder's fate implicit. Made explicit: partial_pending holds everything — including the share of the notes that did dispense — until the operator records how the shortfall was resolved (remediated → full amount; vouchered or written off → scaled), then one distribution runs at that amount with the existing scaling arithmetic. A vouchered remainder waits for redemption or expiry. The alternative (scaled part now, remainder on resolution) is recorded as deferred, not rejected: it needs a second additive distribution pass the repo lacks, and a partial is almost always a terminal fault that has latched cash-out off, so resolution is hours. Decided 2026-10-10. Also carries the hold-invoice decision rule that fell out of the same analysis: dispensed > 0 → settle, dispensed == 0 → cancel. An exit jam that reports zero cancels cleanly — the customer is charged nothing and the stuck note is the operator's to recover — so hold invoices remove owed-cash for full faults and exit jams, not for true partials. --- docs/adr/005-cash-out-dispense-outcome.md | 37 +++++++++++++++++++---- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/adr/005-cash-out-dispense-outcome.md b/docs/adr/005-cash-out-dispense-outcome.md index c68a89d..e4820c5 100644 --- a/docs/adr/005-cash-out-dispense-outcome.md +++ b/docs/adr/005-cash-out-dispense-outcome.md @@ -88,10 +88,30 @@ stays there until the machine reports. | Machine reports | Settlement becomes | Then | | ----------------------------------------- | ------------------ | --------------------------------------- | | `dispense_confirmed: true` | `pending` | claim + distribute → `processed` | -| partial (some notes out, value short) | `partial_pending` | operator confirms → distribute scaled | +| partial (some notes out, value short) | `partial_pending` | nothing moves until the shortfall is resolved (below) | | `dispense_confirmed: false`, nothing out | `cash_owed` | legs never run; funds stay in wallet | | no report within `DISPENSE_REPORT_TTL` | `dispense_unreported` | worklist; operator investigates | +**A partial dispense distributes once, when the outcome is final.** Some notes reached the +customer and some did not, so the sale's true amount is not yet known: it is the full amount +if the shortfall is remediated (an on-machine `manual_dispense` against the `txid`, or an +off-machine payout recorded with `settle_cash_owed`), and the scaled amount if the shortfall +is vouchered or written off. `partial_pending` therefore holds *everything* — including the +operator's and LPs' share of the notes that did dispense — until the operator records which +of those it was. Then one distribution runs, at that amount, using the existing +`apply_partial_dispense_and_redistribute` arithmetic for the scaled case (linear scale; the +fee split by the ratio locked at landing; operator absorbs rounding). A vouchered remainder +stays undistributed until redemption or expiry, per the voucher rules under *Future +directions*. + +The alternative — distribute the scaled part immediately and the remainder on resolution — +pays the LPs the same afternoon but needs a second, additive distribution pass keyed to the +same settlement, which the repo does not have and which the completed-legs guard in the +current tool would fight. It is deferred, not rejected: a partial is almost always a +terminal-class fault that has also latched cash-out off, so the operator is coming to the +machine anyway and resolution is hours, not weeks. Revisit if prompt LP payout ever matters +more than one-distribution-per-settlement. (Decided 2026-10-10.) + This is the card-processing shape — authorize, then capture — and it is the same ordering lamassu-server enforces between `dispense_confirmed` and `updateCassettes`. The cost is that operator and DCA legs land seconds later than they do today, which is the dispense time. @@ -302,11 +322,16 @@ cancels) is identical. Two properties bound what this buys: -- **Settlement is all-or-nothing per HTLC.** A hold invoice cannot be partially settled. A - **full** fault (nothing dispensed) is cleanly cancelled. A **partial** fault — notes out, - value short — cannot be: cancelling would refund a customer who is holding cash, and - settling takes the full amount. Partial therefore still needs the voucher or refund path - below. Hold invoices eliminate owed-cash for the common full-fault case, not for every case. +- **Settlement is all-or-nothing per HTLC.** A hold invoice cannot be partially settled, so + the machine's rule is **`dispensed > 0 → settle; dispensed == 0 → cancel`**. A fault with + nothing presented cancels cleanly — and that includes an exit jam like sintra's 2026-10-09, + where the note stopped in the transport and the counter read zero: the customer is charged + nothing and the note is the operator's to recover, with `countsUncertainSince` covering the + inventory side. A **partial** — notes actually in the customer's hand — cannot cancel without + refunding someone holding cash, so it settles the full amount and from that instant is + identical to a plain-BOLT11 partial: `partial_pending`, one distribution when the shortfall + is resolved (Decision 1). Hold invoices eliminate owed-cash for full faults and exit jams, + not for true partials. - **The hold window locks the payer's funds and route liquidity**, and some wallets surface a long-pending payment as a failure. The window should equal the dispense window — seconds, capped at a minute or two — with an automatic `cancel` on timeout, never an open-ended hold. From f3c333c0bb71b0cc57112c89d338b52fe100fd66 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:07:43 +0200 Subject: [PATCH 152/164] docs(adr): ADR-005 accepted --- docs/adr/005-cash-out-dispense-outcome.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/005-cash-out-dispense-outcome.md b/docs/adr/005-cash-out-dispense-outcome.md index e4820c5..58a3b1b 100644 --- a/docs/adr/005-cash-out-dispense-outcome.md +++ b/docs/adr/005-cash-out-dispense-outcome.md @@ -1,6 +1,6 @@ # ADR-005: Cash-Out Dispense Outcome and Settlement Capture -**Status:** Proposed +**Status:** Accepted (2026-10-10) **Date:** 2026-10-09 **Context:** On 2026-10-09 a customer paid a 40 EUR cash-out on sintra, a note jammed at the cassette exit, and the operator dashboard showed the settlement as `processed` with no sign From cbff654856efad3091f2725c543b34b05db2ddea Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:37:17 +0200 Subject: [PATCH 153/164] fix(machine): declare qrcode where electron/fund-atm.ts actually uses it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fund-atm esbuild bundle imports `qrcode`, but apps/machine never declared it — it resolved only through packages/nostr-client's devDependency, which 763817b removed along with the dead scripts that were the only reason it was there. The full `pnpm build` then failed at its last step ("Could not resolve qrcode"), which is what the nix image build runs. Declared (with @types/qrcode) in the package that imports it. --- apps/machine/package.json | 2 + pnpm-lock.yaml | 155 +++++++++++++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/apps/machine/package.json b/apps/machine/package.json index cefc699..b448641 100644 --- a/apps/machine/package.json +++ b/apps/machine/package.json @@ -50,11 +50,13 @@ "@tailwindcss/vite": "^4.0.0", "@types/better-sqlite3": "^7.0.0", "@types/node": "^22.0.0", + "@types/qrcode": "^1.5.6", "@vitejs/plugin-vue": "^5.2.0", "concurrently": "^9.0.0", "electron": "^33.0.0", "electron-builder": "^25.0.0", "esbuild": "^0.27.4", + "qrcode": "^1.5.4", "tailwindcss": "^4.0.0", "tw-animate-css": "^1.4.0", "typescript": "^5.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fe5e88..5285d75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.7 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@vitejs/plugin-vue': specifier: ^5.2.0 version: 5.2.4(vite@6.4.1(@types/node@22.19.7)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))(vue@3.5.27(typescript@5.9.3)) @@ -111,6 +114,9 @@ importers: esbuild: specifier: ^0.27.4 version: 0.27.4 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 tailwindcss: specifier: ^4.0.0 version: 4.1.18 @@ -1251,6 +1257,9 @@ packages: '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} @@ -1554,6 +1563,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -1599,6 +1612,9 @@ packages: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1701,6 +1717,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -1745,6 +1765,9 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -1915,6 +1938,10 @@ packages: filelist@1.0.4: resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -2259,6 +2286,10 @@ packages: resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} engines: {node: '>= 12.0.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash-es@4.17.23: resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} @@ -2515,20 +2546,36 @@ packages: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-map@4.0.0: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -2575,6 +2622,10 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -2625,6 +2676,11 @@ packages: peerDependencies: vue: ^3.0.0 + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} @@ -2656,6 +2712,9 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resedit@1.7.2: resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} engines: {node: '>=12', npm: '>=6'} @@ -2867,7 +2926,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me temp-file@3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} @@ -3134,6 +3193,9 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3147,6 +3209,10 @@ packages: wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3165,6 +3231,9 @@ packages: xstate@5.25.1: resolution: {integrity: sha512-oyvsNH5pF2qkHmiHEMdWqc3OjDtoZOH2MTAI35r01f/ZQWOD+VLOiYqo65UgQET0XMA5s9eRm8fnsIo+82biEw==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3172,10 +3241,18 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -3929,6 +4006,10 @@ snapshots: xmlbuilder: 15.1.1 optional: true + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.19.7 + '@types/responselike@1.0.3': dependencies: '@types/node': 22.19.7 @@ -4371,6 +4452,8 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + camelcase@5.3.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -4412,6 +4495,12 @@ snapshots: string-width: 4.2.3 optional: true + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -4502,6 +4591,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -4541,6 +4632,8 @@ snapshots: detect-node@2.1.0: optional: true + dijkstrajs@1.0.3: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.2 @@ -4815,6 +4908,11 @@ snapshots: dependencies: minimatch: 5.1.6 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -5177,6 +5275,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.2 lightningcss-win32-x64-msvc: 1.30.2 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash-es@4.17.23: {} lodash.defaults@4.2.0: {} @@ -5426,18 +5528,30 @@ snapshots: p-cancelable@2.1.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-map@4.0.0: dependencies: aggregate-error: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} path-browserify@1.0.1: {} + path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -5475,6 +5589,8 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pngjs@5.0.0: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -5522,6 +5638,12 @@ snapshots: dependencies: vue: 3.5.27(typescript@5.9.3) + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + quick-lru@5.1.1: {} rc@1.2.8: @@ -5576,6 +5698,8 @@ snapshots: require-directory@2.1.1: {} + require-main-filename@2.0.0: {} + resedit@1.7.2: dependencies: pe-library: 0.4.1 @@ -6050,6 +6174,8 @@ snapshots: dependencies: defaults: 1.0.4 + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -6063,6 +6189,12 @@ snapshots: dependencies: string-width: 4.2.3 + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -6081,12 +6213,33 @@ snapshots: xstate@5.25.1: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@4.0.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 From c70d43523c9246519e7f7e3f5a1ef7e2b2dffa6e Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:25:51 +0200 Subject: [PATCH 154/164] =?UTF-8?q?feat(hal):=20dispense=20error=20taxonom?= =?UTF-8?q?y,=20F56=20decode=20table,=20and=20the=20first=20HAL=20tests=20?= =?UTF-8?q?(ADR-005=20=C2=A77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every dispenser now returns a tagged DispenseError: errorCode (the family name, e.g. F56DispenseError), rawCode (driver-native, '78 42'), errorClass (terminal | recoverable | inventory) and a human decode. The class is what the state machine routes on: terminal latches cash-out off, recoverable shows the fault screen but stays in service, inventory means nothing was asked of the hardware. The F56 table is built empirically and from the Fujitsu F56-BDU Error Code List, seeded with sintra's 78 42 (note stopped at the cassette exit, terminal) and the Tejo's 82 00 (long-bill reject, recoverable), plus the 83/84/86 00 checks and the 85 0n / B5 .. families. An unknown code fails SAFE — terminal — so an unfamiliar fault latches rather than letting the next customer pay into it. f56-rs232 surfaces the raw code structurally instead of only inside the message string. Drops the borrowed statusCode 570 from the F56 driver: lamassu-server read 570 as "insufficient funds", so a jam told operators to refill full cassettes (their 34ba9203 fix). Puloon gets the same contract with every fault terminal until it has a decode table. packages/hal had no tests at all (ADR-005 finding 10). Adds the first two: the decode table, and the bill-length table — every window [hi, lo] sane, and GTQ/USD(/HNL when added) sharing one window for what is physically the same 156 mm note. That second test would have caught the GTQ fault months ago. --- packages/hal/src/dispensers/error-codes.ts | 60 ++++++++++ .../dispensers/f56/__tests__/bills.test.ts | 64 ++++++++++ .../f56/__tests__/error-codes.test.ts | 66 +++++++++++ .../hal/src/dispensers/f56/error-codes.ts | 109 ++++++++++++++++++ packages/hal/src/dispensers/f56/f56-rs232.ts | 9 +- packages/hal/src/dispensers/f56/index.ts | 25 ++-- packages/hal/src/dispensers/puloon/index.ts | 17 ++- packages/hal/src/index.ts | 15 +++ packages/hal/src/types.ts | 5 +- 9 files changed, 354 insertions(+), 16 deletions(-) create mode 100644 packages/hal/src/dispensers/error-codes.ts create mode 100644 packages/hal/src/dispensers/f56/__tests__/bills.test.ts create mode 100644 packages/hal/src/dispensers/f56/__tests__/error-codes.test.ts create mode 100644 packages/hal/src/dispensers/f56/error-codes.ts diff --git a/packages/hal/src/dispensers/error-codes.ts b/packages/hal/src/dispensers/error-codes.ts new file mode 100644 index 0000000..8da360f --- /dev/null +++ b/packages/hal/src/dispensers/error-codes.ts @@ -0,0 +1,60 @@ +/** + * Dispense error taxonomy shared by every dispenser driver (ADR-005 §7). + * + * Three fields travel with a failed dispense, mirroring the shape + * lamassu-server kept on cash_out_txs — `error` (human), `error_code` + * (the error's NAME), plus the boolean the caller computes on value: + * + * errorCode the family, e.g. 'F56DispenseError' — stable, machine-readable + * rawCode the driver-native code, e.g. '78 42' — for the decode table + * errorClass how the caller should route it (below) + * human what an operator will find when they open the machine + * + * errorClass decides what the state machine does next: + * + * terminal the transport path is compromised (jam, motor, diverter, + * sensor, comm timeout). Retrying from another bay would jam + * too, and re-initialising does not move a stuck note. The + * machine latches cash-out off until an operator clears it. + * recoverable this bay or this note (pick failure, length/thickness + * reject, bill-end). The customer-facing fault screen still + * shows — they have paid — but the machine stays in service. + * inventory nothing was asked of the hardware: the request could not be + * met from the bays. Not a fault; routes to the out-of-cash + * screen, and nothing was charged beyond what was dispensed. + * + * No code here is borrowed from another layer's vocabulary. lamassu tagged + * every F56 fault with statusCode 570, which its server read as "insufficient + * funds" — a jam told the operator to refill a cassette that was not empty. + */ + +export type DispenseErrorClass = 'terminal' | 'recoverable' | 'inventory' + +export interface DispenseErrorInfo { + errorCode: string + rawCode?: string + errorClass: DispenseErrorClass + human: string +} + +/** An Error carrying the taxonomy. Drivers return these from `dispense()`. */ +export interface DispenseError extends Error, DispenseErrorInfo {} + +/** Stamp the taxonomy onto an existing Error without losing its stack. */ +export function tagDispenseError(error: Error, info: DispenseErrorInfo): DispenseError { + const tagged = error as DispenseError + tagged.name = info.errorCode + tagged.errorCode = info.errorCode + tagged.rawCode = info.rawCode + tagged.errorClass = info.errorClass + tagged.human = info.human + return tagged +} + +export function isDispenseError(err: unknown): err is DispenseError { + return ( + err instanceof Error && + typeof (err as Partial).errorCode === 'string' && + typeof (err as Partial).errorClass === 'string' + ) +} diff --git a/packages/hal/src/dispensers/f56/__tests__/bills.test.ts b/packages/hal/src/dispensers/f56/__tests__/bills.test.ts new file mode 100644 index 0000000..1ed588e --- /dev/null +++ b/packages/hal/src/dispensers/f56/__tests__/bills.test.ts @@ -0,0 +1,64 @@ +/** + * The F56 bill table is a list of [hi, lo] accept windows in millimetres per + * denomination, sent to the BDU at initialise. It was carried byte for byte + * from lamassu with nothing over it, and a wrong GTQ window produced a + * production fault (5/5 notes rejected, error 82 00) for weeks. These tests + * are the "no value table without a test" rule from ADR-005 finding 10. + */ +import { describe, it, expect } from 'vitest' +import { bills } from '../bills.js' + +/** + * Every quetzal, dollar and lempira note is 156 × 67 mm. HNL is in lamassu's + * 37-currency table but not (yet) in ours — include it only if present so the + * invariant holds the day it is added. + */ +const SAME_PHYSICAL_NOTE = (['USD', 'GTQ', 'HNL'] as const).filter((c) => c in bills) + +describe('F56 bill table', () => { + const currencies = Object.keys(bills) + + it('has entries', () => { + expect(currencies.length).toBeGreaterThan(0) + }) + + it.each(currencies)('%s: every window is [hi, lo] with hi > lo and a plausible note length', (cur) => { + const data = bills[cur]! + expect(typeof data.thickness).toBe('number') + expect(typeof data.polymer).toBe('boolean') + for (const [denom, window] of Object.entries(data.lengths)) { + const [hi, lo] = window + expect(hi, `${cur} ${denom} hi`).toBeGreaterThan(lo) + // Real banknotes are roughly 110–180 mm long; a window outside that + // means a typo, not a note. + expect(lo, `${cur} ${denom} lo`).toBeGreaterThanOrEqual(100) + expect(hi, `${cur} ${denom} hi`).toBeLessThanOrEqual(190) + // A window narrower than ±5 rejects real notes on sensor noise; wider + // than ±15 stops catching offset double-picks. + expect(hi - lo, `${cur} ${denom} width`).toBeGreaterThanOrEqual(10) + expect(hi - lo, `${cur} ${denom} width`).toBeLessThanOrEqual(30) + } + }) + + it('GTQ, USD and HNL — physically the same 156 mm note — share one window', () => { + const windows = SAME_PHYSICAL_NOTE.map((cur) => { + const lengths = bills[cur]!.lengths + const all = Object.values(lengths).map(([hi, lo]) => `${hi}-${lo}`) + return { cur, distinct: [...new Set(all)] } + }) + for (const w of windows) { + expect(w.distinct, `${w.cur} has one window for all denominations`).toHaveLength(1) + } + const usd = windows.find((w) => w.cur === 'USD')!.distinct[0] + for (const w of windows) { + expect(w.distinct[0], `${w.cur} matches USD (lamassu b1cc3622)`).toBe(usd) + } + }) + + it('the 156 mm window is 146–166 (±10)', () => { + const [hi, lo] = bills.USD!.lengths[20]! + expect(hi).toBe(166) + expect(lo).toBe(146) + expect((hi + lo) / 2).toBe(156) + }) +}) diff --git a/packages/hal/src/dispensers/f56/__tests__/error-codes.test.ts b/packages/hal/src/dispensers/f56/__tests__/error-codes.test.ts new file mode 100644 index 0000000..f7d7375 --- /dev/null +++ b/packages/hal/src/dispensers/f56/__tests__/error-codes.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { decodeF56Error, listF56ErrorCodes, normaliseF56Code, F56_ERROR_CODE } from '../error-codes.js' + +describe('F56 error-code decode (ADR-005 §7)', () => { + it("decodes sintra's exit jam as terminal", () => { + const info = decodeF56Error('78 42') + expect(info.errorCode).toBe(F56_ERROR_CODE) + expect(info.rawCode).toBe('78 42') + expect(info.errorClass).toBe('terminal') + expect(info.human).toMatch(/cassette exit/i) + }) + + it("decodes the Tejo's long-bill reject as recoverable", () => { + const info = decodeF56Error('82 00') + expect(info.errorClass).toBe('recoverable') + expect(info.human).toMatch(/length/i) + }) + + it('decodes parameterised families by first byte', () => { + expect(decodeF56Error('85 03').errorClass).toBe('recoverable') + expect(decodeF56Error('85 03').human).toMatch(/another safe/i) + expect(decodeF56Error('B5 01').errorClass).toBe('terminal') + expect(decodeF56Error('b5 7f').errorClass).toBe('terminal') + }) + + it('fails SAFE on an unknown code: terminal, named, code preserved', () => { + const info = decodeF56Error('99 99') + expect(info.errorCode).toBe(F56_ERROR_CODE) + expect(info.errorClass).toBe('terminal') + expect(info.rawCode).toBe('99 99') + expect(info.human).toContain('99 99') + }) + + it('treats a missing frame (serial timeout) as terminal', () => { + const info = decodeF56Error(undefined) + expect(info.errorClass).toBe('terminal') + expect(info.rawCode).toBeUndefined() + }) + + it('normalises spelling variants to "XX YY"', () => { + expect(normaliseF56Code('7842')).toBe('78 42') + expect(normaliseF56Code('78-42')).toBe('78 42') + expect(normaliseF56Code('78 42')).toBe('78 42') + expect(normaliseF56Code('b5 01')).toBe('B5 01') + expect(decodeF56Error('7842')).toEqual(decodeF56Error('78 42')) + }) + + it('never borrows another layer\'s code (the lamassu 570 lesson)', () => { + for (const row of listF56ErrorCodes()) { + expect(row).not.toHaveProperty('statusCode') + } + expect(decodeF56Error('78 42')).not.toHaveProperty('statusCode') + }) + + it('lists every known code for the operator glossary', () => { + const codes = listF56ErrorCodes().map((r) => r.code) + expect(codes).toContain('78 42') + expect(codes).toContain('82 00') + expect(codes).toContain('85 ..') + expect(codes).toContain('B5 ..') + for (const row of listF56ErrorCodes()) { + expect(['terminal', 'recoverable', 'inventory']).toContain(row.errorClass) + expect(row.human.length).toBeGreaterThan(10) + } + }) +}) diff --git a/packages/hal/src/dispensers/f56/error-codes.ts b/packages/hal/src/dispensers/f56/error-codes.ts new file mode 100644 index 0000000..2b6af4d --- /dev/null +++ b/packages/hal/src/dispensers/f56/error-codes.ts @@ -0,0 +1,109 @@ +/** + * Fujitsu F53/F56 BDU error-code decode table (ADR-005 §7). + * + * The BDU answers a failed bill-count with an 0xF0 frame whose bytes 3–4 are + * the error code; `f56-rs232.billCount` surfaces them as `rawCode` in the + * form prettyHex produces, e.g. '78 42'. Neither lamassu codebase ever + * decoded these — both collapsed every fault into one opaque string. + * + * Built empirically and from the Fujitsu Frontech F56-BDU Error Code List + * (K3KD03234–K3KD03236-0001, ed. E02). Entries are keyed by the full two-byte + * code, or by the first byte for families where the second byte is a + * parameter (`85 0n` = pick from another safe n; `B5 ..` = reject-box + * overflow). An unknown code fails SAFE: terminal, so an unfamiliar fault + * latches cash-out off rather than letting the next customer pay into it. + * + * Add a row when a code occurs. Each row's `observed` is the first machine + * and date we saw it, so the table doubles as the incident log. + */ + +import type { DispenseErrorClass, DispenseErrorInfo } from '../error-codes.js' + +export const F56_ERROR_CODE = 'F56DispenseError' + +interface F56ErrorEntry { + errorClass: DispenseErrorClass + human: string + /** First observed — machine, date. Empty for spec-only entries. */ + observed?: string +} + +/** Exact two-byte codes. */ +const EXACT: Record = { + '78 42': { + errorClass: 'terminal', + human: 'Note stopped at the cassette exit — open the unit and clear the transport path', + observed: 'sintra, 2026-10-09', + }, + '82 00': { + errorClass: 'recoverable', + human: 'Bill length check failed (long) — note read longer than the configured window', + observed: 'tejo (GTQ), 2026-09-26', + }, + '83 00': { + errorClass: 'recoverable', + human: 'Bill length check failed (short)', + }, + '84 00': { + errorClass: 'recoverable', + human: 'Bill thickness check failed — possible double pick or damaged note', + }, + '86 00': { + errorClass: 'recoverable', + human: 'Bill spacing error — notes too close together on the transport', + }, +} + +/** Families keyed by the first byte; the second byte is a parameter. */ +const FAMILY: Record = { + '85': { + errorClass: 'recoverable', + human: 'Pick from another safe — the note came from a different cassette than commanded', + }, + B5: { + errorClass: 'terminal', + human: 'Reject box overflow — empty the reject tray', + }, +} + +/** Normalise '78 42', '7842', '78-42', lowercase, etc. to 'XX YY'. */ +export function normaliseF56Code(raw: string): string { + const hex = raw.replace(/[^0-9a-fA-F]/g, '').toUpperCase() + if (hex.length !== 4) return raw.trim().toUpperCase() + return `${hex.slice(0, 2)} ${hex.slice(2, 4)}` +} + +export function decodeF56Error(rawCode: string | undefined): DispenseErrorInfo { + if (!rawCode) { + // No frame came back at all — serial timeout, port closed, framing error. + // The transport is in an unknown state; treat as terminal. + return { + errorCode: F56_ERROR_CODE, + errorClass: 'terminal', + human: 'Dispenser did not answer — serial timeout or framing error', + } + } + const code = normaliseF56Code(rawCode) + const exact = EXACT[code] + if (exact) { + return { errorCode: F56_ERROR_CODE, rawCode: code, errorClass: exact.errorClass, human: exact.human } + } + const family = FAMILY[code.slice(0, 2)] + if (family) { + return { errorCode: F56_ERROR_CODE, rawCode: code, errorClass: family.errorClass, human: family.human } + } + return { + errorCode: F56_ERROR_CODE, + rawCode: code, + errorClass: 'terminal', + human: `Unrecognised dispenser error ${code} — treat as a jam until decoded`, + } +} + +/** For the operator glossary: every known code with its class and meaning. */ +export function listF56ErrorCodes(): Array<{ code: string; errorClass: DispenseErrorClass; human: string; observed?: string }> { + return [ + ...Object.entries(EXACT).map(([code, e]) => ({ code, ...e })), + ...Object.entries(FAMILY).map(([code, e]) => ({ code: `${code} ..`, ...e })), + ] +} diff --git a/packages/hal/src/dispensers/f56/f56-rs232.ts b/packages/hal/src/dispensers/f56/f56-rs232.ts index 24e1d60..397f4e7 100644 --- a/packages/hal/src/dispensers/f56/f56-rs232.ts +++ b/packages/hal/src/dispensers/f56/f56-rs232.ts @@ -134,6 +134,8 @@ export async function initialize(currency: string, denominations: number[]): Pro export interface BillCountResult { bills: Array<{ dispensed: number; rejected: number }> error?: Error + /** BDU error code from bytes 3–4 of an 0xF0 frame, e.g. '78 42'. */ + rawCode?: string } export async function billCount(counts: number[]): Promise { @@ -172,9 +174,10 @@ export async function billCount(counts: number[]): Promise { if (res[0] === 0xf0) { console.log('response', res) - const errorCode = res.subarray(3, 5) - response.error = new Error(`Dispensing, code: ${prettyHex(errorCode)}`) - console.error(`found error code: ${prettyHex(errorCode)}`) + const rawCode = prettyHex(res.subarray(3, 5)) + response.rawCode = rawCode + response.error = new Error(`Dispensing, code: ${rawCode}`) + console.error(`found error code: ${rawCode}`) } return response diff --git a/packages/hal/src/dispensers/f56/index.ts b/packages/hal/src/dispensers/f56/index.ts index c64882d..52a5c03 100644 --- a/packages/hal/src/dispensers/f56/index.ts +++ b/packages/hal/src/dispensers/f56/index.ts @@ -8,6 +8,8 @@ */ import * as f56 from './f56-rs232.js' +import { decodeF56Error } from './error-codes.js' +import { tagDispenseError, type DispenseError } from '../error-codes.js' import type { BillDispenser, DispenserConfig, @@ -51,23 +53,28 @@ export class F56Dispenser implements BillDispenser { } } - async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: Error }> { + /** + * On any failure the port is closed so the next attempt re-initialises; + * the error is tagged with the ADR-005 taxonomy (class + decoded meaning) + * so the caller can route it. No statusCode: lamassu's 570 meant + * "insufficient funds" to its server and sent operators to refill full + * cassettes after jams. + */ + async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: DispenseError }> { try { - const { bills, error } = await f56.billCount(notes) + const { bills, error, rawCode } = await f56.billCount(notes) if (error) { await this.close() - ;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError' - ;(error as Error & { statusCode: number }).statusCode = 570 + return { value: bills, error: tagDispenseError(error, decodeF56Error(rawCode)) } } - return { value: bills, error } + return { value: bills } } catch (err) { await this.close() - const error = err as Error - ;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError' - ;(error as Error & { statusCode: number }).statusCode = 570 - return { value: [], error } + const error = err instanceof Error ? err : new Error(String(err)) + // No frame: serial timeout / framing. decodeF56Error(undefined) → terminal. + return { value: [], error: tagDispenseError(error, decodeF56Error(undefined)) } } } diff --git a/packages/hal/src/dispensers/puloon/index.ts b/packages/hal/src/dispensers/puloon/index.ts index f065fd4..034c3ce 100644 --- a/packages/hal/src/dispensers/puloon/index.ts +++ b/packages/hal/src/dispensers/puloon/index.ts @@ -14,6 +14,7 @@ import type { DispenserInitData, DispenseResult, } from '../../types.js' +import { tagDispenseError, type DispenseError } from '../error-codes.js' export class PuloonDispenser implements BillDispenser { public type: string = 'Puloon' @@ -46,16 +47,26 @@ export class PuloonDispenser implements BillDispenser { } } - async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: Error }> { + async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: DispenseError }> { const { bills, error } = await this.device.dispense(notes) if (error) { await this.close() - error.name = 'PuloonDispenseError' console.log('PULOON | dispense error', error) + // No decode table for the LCDM yet: every fault is terminal until one + // exists, so an unknown Puloon error latches cash-out off (ADR-005 §7). + return { + value: bills, + error: tagDispenseError(error, { + errorCode: 'PuloonDispenseError', + rawCode: (error as Error & { code?: string }).code, + errorClass: 'terminal', + human: `Puloon dispense error: ${error.message}`, + }), + } } - return { value: bills, error } + return { value: bills } } async close(): Promise { diff --git a/packages/hal/src/index.ts b/packages/hal/src/index.ts index dca91f5..46a76ed 100644 --- a/packages/hal/src/index.ts +++ b/packages/hal/src/index.ts @@ -57,5 +57,20 @@ export type { DispenserFactory, } from './types.js' +// Dispense error taxonomy (ADR-005 §7) +export { + tagDispenseError, + isDispenseError, + type DispenseError, + type DispenseErrorClass, + type DispenseErrorInfo, +} from './dispensers/error-codes.js' +export { + decodeF56Error, + listF56ErrorCodes, + normaliseF56Code, + F56_ERROR_CODE, +} from './dispensers/f56/error-codes.js' + // Utilities export { compute as computeCrc } from './utils/crc.js' diff --git a/packages/hal/src/types.ts b/packages/hal/src/types.ts index bf0615f..96c21dc 100644 --- a/packages/hal/src/types.ts +++ b/packages/hal/src/types.ts @@ -1,3 +1,5 @@ +import type { DispenseError } from './dispensers/error-codes.js' + import { EventEmitter } from 'node:events' /** @@ -176,7 +178,8 @@ export interface BillDispenser { */ dispense(notes: number[]): Promise<{ value: DispenseResult[] - error?: Error + /** Tagged with the ADR-005 taxonomy — see dispensers/error-codes.ts */ + error?: DispenseError }> /** From 3ff86de4edb7e5f50930f04e9788d011c3dd0c0a Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:25:51 +0200 Subject: [PATCH 155/164] =?UTF-8?q?feat(state-machine):=20dispenseConfirme?= =?UTF-8?q?d=20on=20value,=20dispenseFault=20vs=20outOfCash,=20cash-out=20?= =?UTF-8?q?latch=20(ADR-005=20=C2=A73=E2=80=93=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DispenseCashResult.dispensed (a driver boolean) is replaced by dispenseConfirmed — Σ(denomination × dispensed) equals the requested value, computed by the HAL — plus errorCode / rawCode / errorClass. dispensingCash.onDone guards on dispenseConfirmed and nothing else. The single dispenseError state becomes two. dispenseFault: the dispenser reported an error, the customer has paid and is owed — 120 s screen with evidence, ACKNOWLEDGE_FAULT to dismiss. outOfCash: a shortfall with no hardware error or an inventory refusal — 30 s. A hung dispense is a terminal fault. A terminal errorClass latches cash-out off: context.cashOutHeld, set by latchCashOutIfTerminal, preserved across resetContext (it is machine health, not transaction state), guarding idle's SELECT_CASH_OUT. Cash-in is unaffected. Only CASH_OUT_RELEASED clears it — the store sends that when an operator recount or resume_cash_out op lands; re-initialising the dispenser never does, because re-init does not move a stuck note. CASH_OUT_HELD lets the store restore a persisted hold on boot. PAYMENT_RECEIVED now carries the payment hash into context.paymentHash so the fault screen can show the reference the server indexes. Tests: the dispense section is rewritten around outcomes — value confirmation, fault vs out-of-cash routing, terminal latch + release, recoverable does not latch, partial-with-error is a fault, inventory refusal is out-of-cash, boot-restored hold gates, 30 s vs 120 s timers, acknowledge/cancel, timeout latches. 46/46. --- .../src/__tests__/machine.test.ts | 305 ++++++++++++------ packages/state-machine/src/machine.ts | 122 +++++-- packages/state-machine/src/types.ts | 61 +++- 3 files changed, 360 insertions(+), 128 deletions(-) diff --git a/packages/state-machine/src/__tests__/machine.test.ts b/packages/state-machine/src/__tests__/machine.test.ts index 9b9bf91..e56b2b7 100644 --- a/packages/state-machine/src/__tests__/machine.test.ts +++ b/packages/state-machine/src/__tests__/machine.test.ts @@ -12,7 +12,7 @@ describe('ATM State Machine', () => { sendNostrReceipt: vi.fn().mockResolvedValue(undefined), dispenseCash: vi.fn().mockResolvedValue({ bills: [{ denomination: 20, dispensed: 1, rejected: 0 }], - dispensed: true, + dispenseConfirmed: true, } satisfies DispenseCashResult), getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD getAvailableBalance: vi.fn().mockResolvedValue(1_000_000), // 1M sats available @@ -397,127 +397,224 @@ describe('ATM State Machine', () => { }) }) - describe('dispense error handling', () => { - it('should route to waitingForCashTaken when dispenseCash returns dispensed: true', async () => { - const machine = createATMMachine(mockServices) - const actor = createActor(machine) + describe('dispense outcome (ADR-005 §3–§5)', () => { + /** Drive a 1 × $20 cash-out to the dispense and return the actor. */ + async function dispenseWith(result: DispenseCashResult, fake = false) { + const services: ATMServices = { ...mockServices, dispenseCash: vi.fn().mockResolvedValue(result) } + const actor = createActor(createATMMachine(services)) actor.start() - + const tick = async (ms: number) => + fake ? vi.advanceTimersByTimeAsync(ms) : new Promise((r) => setTimeout(r, ms)) actor.send({ type: 'SELECT_CASH_OUT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - + await tick(100) actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) actor.send({ type: 'CONFIRM_AMOUNT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - + await tick(100) actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) - await new Promise((resolve) => setTimeout(resolve, 100)) + await tick(100) + return actor + } + it('completes only on dispenseConfirmed (value equality), never on a driver boolean', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 1, rejected: 0 }], + dispenseConfirmed: true, + }) const state = actor.getSnapshot() - // dispenseCash mock returns { dispensed: true }, so should go to waitingForCashTaken expect(state.value).toMatchObject({ cashOut: 'waitingForCashTaken' }) expect(state.context.cashDispensed).toBe(true) - expect(state.context.dispenseResult?.dispensed).toBe(true) + expect(state.context.dispenseResult?.dispenseConfirmed).toBe(true) + expect(state.context.cashOutHeld).toBeNull() }) - it('should route to dispenseError when dispenseCash returns dispensed: false', async () => { - const failDispenseServices: ATMServices = { - ...mockServices, - dispenseCash: vi.fn().mockResolvedValue({ - bills: [{ denomination: 20, dispensed: 0, rejected: 1 }], - dispensed: false, - error: 'Cassette jam', - } satisfies DispenseCashResult), - } - - const machine = createATMMachine(failDispenseServices) - const actor = createActor(machine) - actor.start() - - actor.send({ type: 'SELECT_CASH_OUT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) - actor.send({ type: 'CONFIRM_AMOUNT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - + it('routes a hardware error to dispenseFault — the customer has paid and is owed', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'Note stopped at the cassette exit', + errorCode: 'F56DispenseError', + rawCode: '78 42', + errorClass: 'terminal', + }) const state = actor.getSnapshot() - expect(state.value).toMatchObject({ cashOut: 'dispenseError' }) + expect(state.value).toMatchObject({ cashOut: 'dispenseFault' }) expect(state.context.cashDispensed).toBe(false) - expect(state.context.dispenseResult?.dispensed).toBe(false) - expect(state.context.dispenseResult?.error).toBe('Cassette jam') - expect(state.context.error).toBe('Cassette jam') + expect(state.context.error).toBe('Note stopped at the cassette exit') + expect(state.context.dispenseResult?.rawCode).toBe('78 42') }) - it('should auto-idle after 30s in dispenseError state', async () => { - vi.useFakeTimers() - - const failDispenseServices: ATMServices = { - ...mockServices, - dispenseCash: vi.fn().mockResolvedValue({ - bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], - dispensed: false, - error: 'Out of cash', - } satisfies DispenseCashResult), - } - - const machine = createATMMachine(failDispenseServices) - const actor = createActor(machine) - actor.start() - - actor.send({ type: 'SELECT_CASH_OUT' }) - await vi.advanceTimersByTimeAsync(100) - - actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) - actor.send({ type: 'CONFIRM_AMOUNT' }) - await vi.advanceTimersByTimeAsync(100) - - actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) - await vi.advanceTimersByTimeAsync(100) - - // Should be in dispenseError - expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseError' }) - - // Advance 30s - await vi.advanceTimersByTimeAsync(30000) - - // Should have auto-idled - expect(actor.getSnapshot().value).toBe('idle') - - vi.useRealTimers() - }) - - it('should allow CANCEL from dispenseError to go to idle immediately', async () => { - const failDispenseServices: ATMServices = { - ...mockServices, - dispenseCash: vi.fn().mockResolvedValue({ - bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], - dispensed: false, - error: 'Jam', - } satisfies DispenseCashResult), - } - - const machine = createATMMachine(failDispenseServices) - const actor = createActor(machine) - actor.start() - - actor.send({ type: 'SELECT_CASH_OUT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) - actor.send({ type: 'CONFIRM_AMOUNT' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' }) - await new Promise((resolve) => setTimeout(resolve, 100)) - - expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseError' }) + it('a terminal fault latches cash-out off; idle refuses SELECT_CASH_OUT until released', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'jam', + errorCode: 'F56DispenseError', + rawCode: '78 42', + errorClass: 'terminal', + }) + const hold = actor.getSnapshot().context.cashOutHeld + expect(hold).not.toBeNull() + expect(hold?.errorCode).toBe('F56DispenseError') + expect(hold?.rawCode).toBe('78 42') + expect(typeof hold?.since).toBe('number') actor.send({ type: 'CANCEL' }) expect(actor.getSnapshot().value).toBe('idle') + // The hold survives resetContext — it is machine health, not transaction state. + expect(actor.getSnapshot().context.cashOutHeld).not.toBeNull() + + actor.send({ type: 'SELECT_CASH_OUT' }) + expect(actor.getSnapshot().value).toBe('idle') + + // Only an operator op releases it (the store sends this on recount / resume_cash_out). + actor.send({ type: 'CASH_OUT_RELEASED' }) + expect(actor.getSnapshot().context.cashOutHeld).toBeNull() + actor.send({ type: 'SELECT_CASH_OUT' }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: expect.anything() }) + }) + + it('a hold gates cash-out only — cash-in is unaffected by a dispenser fault', () => { + const actor = createActor(createATMMachine(mockServices)) + actor.start() + actor.send({ + type: 'CASH_OUT_HELD', + hold: { reason: 'jam', errorCode: 'F56DispenseError', rawCode: '78 42', since: 1 }, + }) + actor.send({ type: 'SELECT_CASH_IN' }) + expect(actor.getSnapshot().value).toMatchObject({ cashIn: expect.anything() }) + }) + + it('a recoverable fault shows the fault screen but does NOT latch', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 1 }], + dispenseConfirmed: false, + error: 'Bill length check failed (long)', + errorCode: 'F56DispenseError', + rawCode: '82 00', + errorClass: 'recoverable', + }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' }) + expect(actor.getSnapshot().context.cashOutHeld).toBeNull() + }) + + it('a partial WITH an error is a fault (owed the shortfall), not out-of-cash', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 1, rejected: 1 }], + dispenseConfirmed: false, + error: 'jam after first note', + errorCode: 'F56DispenseError', + rawCode: '78 42', + errorClass: 'terminal', + }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' }) + }) + + it('an inventory refusal (nothing asked of the hardware) is outOfCash, and does not latch', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'Insufficient inventory for denomination 20: short 1', + errorCode: 'InsufficientInventory', + errorClass: 'inventory', + }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' }) + expect(actor.getSnapshot().context.cashOutHeld).toBeNull() + }) + + it('a shortfall with NO error is outOfCash', async () => { + const actor = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + }) + expect(actor.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' }) + }) + + it('a persisted hold restored on boot gates cash-out before any dispense', () => { + const actor = createActor(createATMMachine(mockServices)) + actor.start() + actor.send({ + type: 'CASH_OUT_HELD', + hold: { reason: 'jam', errorCode: 'F56DispenseError', rawCode: '78 42', since: 1791529353 }, + }) + actor.send({ type: 'SELECT_CASH_OUT' }) + expect(actor.getSnapshot().value).toBe('idle') + expect(actor.getSnapshot().context.cashOutHeld?.since).toBe(1791529353) + }) + + it('outOfCash auto-returns after 30s; dispenseFault gives the customer 120s', async () => { + vi.useFakeTimers() + try { + const ooc = await dispenseWith( + { bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], dispenseConfirmed: false }, + true + ) + expect(ooc.getSnapshot().value).toMatchObject({ cashOut: 'outOfCash' }) + await vi.advanceTimersByTimeAsync(30000) + expect(ooc.getSnapshot().value).toBe('idle') + + const fault = await dispenseWith( + { + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'jam', + errorCode: 'F56DispenseError', + errorClass: 'recoverable', + }, + true + ) + expect(fault.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' }) + await vi.advanceTimersByTimeAsync(30000) + expect(fault.getSnapshot().value).toMatchObject({ cashOut: 'dispenseFault' }) + await vi.advanceTimersByTimeAsync(90000) + expect(fault.getSnapshot().value).toBe('idle') + } finally { + vi.useRealTimers() + } + }) + + it('the customer can acknowledge the fault screen or cancel; both return to idle', async () => { + const a = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'jam', + errorClass: 'recoverable', + }) + a.send({ type: 'ACKNOWLEDGE_FAULT' }) + expect(a.getSnapshot().value).toBe('idle') + + const b = await dispenseWith({ + bills: [{ denomination: 20, dispensed: 0, rejected: 0 }], + dispenseConfirmed: false, + error: 'jam', + errorClass: 'recoverable', + }) + b.send({ type: 'CANCEL' }) + expect(b.getSnapshot().value).toBe('idle') + }) + + it('a hung dispense (timeout) is a terminal fault and latches', async () => { + vi.useFakeTimers() + try { + const hang: ATMServices = { + ...mockServices, + dispenseCash: vi.fn().mockReturnValue(new Promise(() => {})), + } + const actor = createActor(createATMMachine(hang)) + actor.start() + actor.send({ type: 'SELECT_CASH_OUT' }) + await vi.advanceTimersByTimeAsync(100) + actor.send({ type: 'ADD_DENOMINATION', denomination: 20 }) + actor.send({ type: 'CONFIRM_AMOUNT' }) + await vi.advanceTimersByTimeAsync(100) + actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'p' }) + await vi.advanceTimersByTimeAsync(120000 + 10) + const s = actor.getSnapshot() + expect(s.value).toMatchObject({ cashOut: 'dispenseFault' }) + expect(s.context.dispenseResult?.errorCode).toBe('DispenseTimeout') + expect(s.context.cashOutHeld).not.toBeNull() + } finally { + vi.useRealTimers() + } }) }) diff --git a/packages/state-machine/src/machine.ts b/packages/state-machine/src/machine.ts index c5d4c1a..c64a87d 100644 --- a/packages/state-machine/src/machine.ts +++ b/packages/state-machine/src/machine.ts @@ -10,6 +10,7 @@ import { type ATMContext, type ATMEvent, type DispenseCashResult, + type CashOutHold, initialContext, type ATMServices, type OfferRequestEvent, @@ -148,8 +149,8 @@ export function createATMMachine( } // Extract payment hash from invoice (simplified - real impl would decode BOLT11) // The service handles the actual extraction - const cleanup = services.watchInvoice(input.invoice, (preimage: string) => { - sendBack({ type: 'PAYMENT_RECEIVED', preimage }) + const cleanup = services.watchInvoice(input.invoice, (preimage, paymentHash) => { + sendBack({ type: 'PAYMENT_RECEIVED', preimage, paymentHash }) }) return cleanup }), @@ -184,6 +185,9 @@ export function createATMMachine( // without this the just-granted session would be wiped. The session is // cleared instead on re-lock (locked's entry), i.e. when access ends. accessSession: context.accessSession, + // The cash-out latch is machine health, not transaction state — it + // survives every reset until an operator op releases it. + cashOutHeld: context.cashOutHeld, cashInSessionId: null, dispenseResult: null, })), @@ -315,6 +319,10 @@ export function createATMMachine( if (event.type !== 'PAYMENT_RECEIVED') return null return event.preimage }, + paymentHash: ({ event }) => { + if (event.type !== 'PAYMENT_RECEIVED') return null + return event.paymentHash ?? null + }, }), setPaymentFailed: assign({ paymentStatus: () => 'failed' as const, @@ -332,6 +340,37 @@ export function createATMMachine( return output?.error ?? null }, }), + // ADR-005 §5: a terminal fault latches cash-out off. Idempotent — an + // existing hold is kept (its `since` is the first fault, which is what + // the operator wants to know). + latchCashOutIfTerminal: assign({ + cashOutHeld: ({ context }) => { + if (context.cashOutHeld) return context.cashOutHeld + const dr = context.dispenseResult + if (dr?.errorClass !== 'terminal') return null + return { + reason: dr.error ?? 'terminal dispenser fault', + errorCode: dr.errorCode ?? null, + rawCode: dr.rawCode ?? null, + since: Math.floor(Date.now() / 1000), + } satisfies CashOutHold + }, + }), + setCashOutHeld: assign({ + cashOutHeld: ({ event }) => (event.type === 'CASH_OUT_HELD' ? event.hold : null), + }), + clearCashOutHeld: assign({ cashOutHeld: null }), + setDispenseTimeoutError: assign({ + error: () => 'Dispense timed out — hardware may be jammed', + dispenseResult: ({ context }) => + context.dispenseResult ?? { + bills: [], + dispenseConfirmed: false, + error: 'Dispense timed out — hardware may be jammed', + errorCode: 'DispenseTimeout', + errorClass: 'terminal' as const, + }, + }), setAmount: assign({ fiatCents: ({ event }) => { if (event.type !== 'SELECT_AMOUNT') return 0 @@ -458,6 +497,18 @@ export function createATMMachine( return principalSats - fee <= context.availableBalance }, // Cash-out guards + // ADR-005 §5: no cash-out while latched. The idle screen shows why. + cashOutAvailable: ({ context }) => context.cashOutHeld === null, + // ADR-005 §3/§4: only value equality completes a dispense. + dispenseConfirmed: ({ event }) => + (event as unknown as { output?: DispenseCashResult }).output?.dispenseConfirmed === true, + // A shortfall WITH a hardware error is a fault (customer paid, is owed); + // a shortfall with none, or an inventory refusal, is out-of-cash. + dispenseHadFault: ({ event }) => { + const out = (event as unknown as { output?: DispenseCashResult }).output + if (!out?.error) return false + return out.errorClass !== 'inventory' + }, hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0, canAddDenomination: ({ context, event }) => { if (event.type !== 'ADD_DENOMINATION') return false @@ -477,7 +528,10 @@ export function createATMMachine( INVOICE_TIMEOUT: 300000, // 5 minutes — waiting for payment COMPLETE_DELAY: 60000, DISPENSE_TIMEOUT: 120000, // 2 min max for hardware to respond - DISPENSE_ERROR_TIMEOUT: 30000, // 30s like brain.js _timedState + DISPENSE_ERROR_TIMEOUT: 30000, // out-of-cash: 30s like brain.js _timedState + // Fault screen: the customer has paid and is owed money; give them time + // to photograph/write down the reference (ADR-005 §4). + DISPENSE_FAULT_TIMEOUT: 120000, // NOTE: idle inactivity re-lock + hard session cap are enforced at the // DOM layer (useSessionSecurity), not as XState `after` delays — see the // idle state comment. No IDLE_LOCK_TIMEOUT delay here by design. @@ -513,6 +567,11 @@ export function createATMMachine( cashOutFeeFraction: ({ event }) => event.cashOutFeeFraction, }), }, + // ADR-005 §5 — the store restores a persisted hold on boot and + // releases it when an operator op lands. Root-level so it applies in + // any state; it only gates entry to cashOut, never an in-flight sale. + CASH_OUT_HELD: { actions: 'setCashOutHeld' }, + CASH_OUT_RELEASED: { actions: 'clearCashOutHeld' }, }, states: { // === ACCESS GATE (ADR-003) === @@ -557,6 +616,7 @@ export function createATMMachine( actions: ['setStartTime', 'setCashInFee'], }, SELECT_CASH_OUT: { + guard: 'cashOutAvailable', target: 'cashOut', actions: ['setStartTime', 'setCashOutFee'], }, @@ -861,13 +921,12 @@ export function createATMMachine( }, dispensingCash: { // Safety timeout: if dispenseCash promise hangs (hardware jam, - // serial port freeze), don't stay here forever. + // serial port freeze), don't stay here forever. A hang is a + // terminal fault — the transport state is unknown. after: { DISPENSE_TIMEOUT: { - target: 'dispenseError', - actions: assign({ - error: () => 'Dispense timed out — hardware may be jammed', - }), + target: 'dispenseFault', + actions: ['setDispenseTimeoutError', 'latchCashOutIfTerminal'], }, }, invoke: { @@ -875,21 +934,31 @@ export function createATMMachine( input: ({ context }) => context.dispenseAmounts, onDone: [ { - guard: ({ event }) => - (event.output as unknown as DispenseCashResult | undefined)?.dispensed === true, + // ADR-005 §3: value equality, nothing else, completes. + guard: 'dispenseConfirmed', target: 'waitingForCashTaken', actions: ['setCashDispensed', 'setDispenseResult'], }, { - // Partial or failed dispense - target: 'dispenseError', + // ADR-005 §4: a hardware error means the customer has paid + // and is owed — the fault screen, with evidence. A terminal + // class also latches cash-out off (§5). + guard: 'dispenseHadFault', + target: 'dispenseFault', + actions: ['setDispenseResult', 'latchCashOutIfTerminal'], + }, + { + // Shortfall with no hardware error, or an inventory refusal + // before anything was asked of the dispenser. + target: 'outOfCash', actions: 'setDispenseResult', }, ], onError: { - // Unexpected crash (not a dispense failure) - target: 'dispenseError', - actions: 'setError', + // The service threw (not a reported dispense failure). No + // per-bay report exists — the store flags counts unverified. + target: 'dispenseFault', + actions: ['setError', 'latchCashOutIfTerminal'], }, }, }, @@ -930,9 +999,26 @@ export function createATMMachine( CANCEL: '#atm.locked', }, }, - dispenseError: { - // Payment received but cash not (fully) dispensed. - // Show error + txid for 30s, then auto-idle (matches brain.js _timedState). + // ADR-005 §4 — two terminal states replace the old dispenseError. + // + // dispenseFault: the dispenser reported an error. The customer HAS + // PAID and is owed the shortfall. The screen carries the txid, the + // payment hash, the amounts and a statement that the operator has + // been notified — this is lamassu's fiatTransactionError ("the + // right prompt when they have paid and are owed money"), not the + // out-of-cash screen a jam used to show. + dispenseFault: { + after: { + DISPENSE_FAULT_TIMEOUT: '#atm.locked', + }, + on: { + ACKNOWLEDGE_FAULT: '#atm.locked', + CANCEL: '#atm.locked', + }, + }, + // outOfCash: the request could not be met and the dispenser + // reported NO error — nothing was charged beyond what was dispensed. + outOfCash: { after: { DISPENSE_ERROR_TIMEOUT: '#atm.locked', }, diff --git a/packages/state-machine/src/types.ts b/packages/state-machine/src/types.ts index fc8d4a7..6935a95 100644 --- a/packages/state-machine/src/types.ts +++ b/packages/state-machine/src/types.ts @@ -21,18 +21,48 @@ export interface CassetteBillResult { rejected: number } -/** Result of a dispense operation (always resolves, never throws) */ +/** How a dispense error should be routed — see @bitSpire/hal dispensers/error-codes.ts */ +export type DispenseErrorClass = 'terminal' | 'recoverable' | 'inventory' + +/** Result of a dispense operation (always resolves, never throws) — ADR-005 §3 */ export interface DispenseCashResult { /** Per-denomination results (what was actually dispensed) */ bills: { denomination: number; dispensed: number; rejected: number }[] - /** Whether the full requested amount was dispensed */ - dispensed: boolean - /** Error message if dispense failed or was partial */ + /** + * Σ(denomination × dispensed) equals the requested fiat value. Computed by + * the HAL on VALUE, never taken from a driver boolean. This is lamassu's + * `dispenseConfirmed` and it is the only thing that routes to `complete`. + */ + dispenseConfirmed: boolean + /** Human-readable message if dispense failed or was partial */ error?: string + /** The error's NAME, machine-readable — e.g. 'F56DispenseError' */ + errorCode?: string + /** Driver-native code for the decode table — e.g. '78 42' */ + rawCode?: string + /** + * terminal → dispenseFault + latch cash-out; recoverable → dispenseFault; + * inventory → outOfCash (nothing was asked of the hardware). + */ + errorClass?: DispenseErrorClass /** Per-cassette detail (position-aware, produced by HAL) */ cassettes?: CassetteBillResult[] } +/** + * Cash-out is latched off after a terminal dispenser fault (ADR-005 §5). + * Set by the machine when a fault lands; persisted and restored by the + * store; cleared only by an operator `recount` or `resume_cash_out` op — + * never by re-initialising the dispenser, which does not move a stuck note. + */ +export interface CashOutHold { + reason: string + errorCode: string | null + rawCode: string | null + /** unix seconds */ + since: number +} + /** Payment methods supported */ export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu' @@ -122,6 +152,12 @@ export interface ATMContext { paymentStatus: PaymentStatus /** Payment preimage (proof of payment) */ preimage: string | null + /** + * Payment hash of the settled invoice — the join key to the LNbits payment + * the operator sees. Shown on the dispense-fault screen (ADR-005 §4) so a + * customer who is owed money leaves with the reference the server indexes. + */ + paymentHash: string | null /** Payment method used */ paymentMethod: PaymentMethod | null /** Pending offer request (Kind 21001 from user's wallet) */ @@ -157,6 +193,8 @@ export interface ATMContext { retryCount: number /** Result from the last dispense operation */ dispenseResult: DispenseCashResult | null + /** Cash-out latched off after a terminal fault; null = available (ADR-005 §5) */ + cashOutHeld: CashOutHold | null // Transaction metadata /** Unique transaction ID */ @@ -199,8 +237,14 @@ export type ATMEvent = | { type: 'BILL_REJECTED'; reason: string } | { type: 'CASH_DISPENSED' } | { type: 'DISPENSE_ERROR'; error: string } + // Customer acknowledges the fault screen ("I've saved this reference") + | { type: 'ACKNOWLEDGE_FAULT' } + // Cash-out latch (ADR-005 §5): the store restores a persisted hold on boot + // and releases it when an operator recount / resume_cash_out op lands. + | { type: 'CASH_OUT_HELD'; hold: CashOutHold } + | { type: 'CASH_OUT_RELEASED' } // Payment events - | { type: 'PAYMENT_RECEIVED'; preimage: string } + | { type: 'PAYMENT_RECEIVED'; preimage: string; paymentHash?: string } | { type: 'PAYMENT_FAILED'; error: string } | { type: 'INVOICE_GENERATED'; invoice: string } | { type: 'OFFER_GENERATED'; offer: string } @@ -245,6 +289,7 @@ export const initialContext: ATMContext = { lnurlWithdraw: null, paymentStatus: null, preimage: null, + paymentHash: null, paymentMethod: null, pendingOfferRequest: null, billsInserted: [], @@ -258,6 +303,7 @@ export const initialContext: ATMContext = { error: null, retryCount: 0, dispenseResult: null, + cashOutHeld: null, txid: null, startedAt: null, cashInSessionId: null, @@ -306,7 +352,10 @@ export interface ATMServices { * Watch an invoice for payment (polling-based) * Calls the callback when paid, returns cleanup function */ - watchInvoice: (paymentHash: string, callback: (preimage: string) => void) => () => void + watchInvoice: ( + invoice: string, + callback: (preimage: string, paymentHash?: string) => void + ) => () => void /** * Get available inventory: denomination -> count */ From 888870d01ac73dbde6973036e1d981981af6a74e Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:25:51 +0200 Subject: [PATCH 156/164] =?UTF-8?q?feat(machine):=20value-confirmed=20disp?= =?UTF-8?q?ense,=20cash-out=20hold,=20fault=20screens,=20counts-uncertain?= =?UTF-8?q?=20on=20zero-with-error=20(ADR-005=20=C2=A73=E2=80=93=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HAL glue (electron/hal-service.ts and the renderer-side services/hal.ts): dispenseConfirmed is Σ(denomination × dispensed) === Σ(denomination × requested), computed on value. The driver's tagged error is carried through as errorCode / rawCode / errorClass / human; pre-dispense inventory refusals are errorClass 'inventory' so they route to outOfCash rather than the fault screen. The manual-dispense command result keeps its wire key `dispensed` (spirekeeper's poller reads it) and gains the new fields alongside. Cash-out hold: state-store persists it in meta as one JSON value beside countsUncertainSince, idempotent on set (the first fault's `since` is kept); IPC get/set/clear through preload. The store persists the hold the moment the machine sets it and restores it into the machine on boot. A recount clears it in the store (same gesture that clears counts- uncertain); operator-config also honours a new resume_cash_out op — not a cassette op, split off before applyOperatorCassetteOps, and honoured only when stamped after the hold began so a re-delivered old resume cannot clear a fresh fault. Either release calls back into the store, which sends CASH_OUT_RELEASED. The cassettes-state document carries cash_out_held_since / _reason / _code (additive, like counts_uncertain_since); the availability beacon reports cash_out false while held; the idle Sell button is disabled with the reason. Store watcher: dispenseFault and outOfCash both record dispense_error / partial (the customer has paid either way). A report of zero dispensed WITH a hardware error now sets countsUncertainSince instead of being trusted as zero — a note stopped in the transport completes neither counter (sintra 2026-10-09: bay read 66, held 65, one in the transport). Fault screen: both terminal states show "your payment went through", amount paid, per-denomination dispensed, the txid as QR and text, the payment hash (threaded from the settlement watch through PAYMENT_RECEIVED) and the time, with "keep this reference" and an acknowledge button. The raw dispenser code is not shown; it travels in the report. --- apps/machine/electron/hal-service.ts | 79 +++++++++++-- apps/machine/electron/main.ts | 25 ++++- apps/machine/electron/preload.ts | 16 +++ apps/machine/electron/state-store.ts | 70 +++++++++++- .../composables/useAvailabilityBroadcast.ts | 14 ++- apps/machine/src/services/hal.ts | 33 +++++- apps/machine/src/services/lightning.ts | 15 ++- apps/machine/src/services/operator-config.ts | 63 ++++++++++- apps/machine/src/stores/atm.ts | 78 +++++++++++-- apps/machine/src/types/electron.d.ts | 14 +++ apps/machine/src/views/CashOutView.vue | 106 +++++++++++++----- apps/machine/src/views/IdleView.vue | 28 ++++- 12 files changed, 469 insertions(+), 72 deletions(-) diff --git a/apps/machine/electron/hal-service.ts b/apps/machine/electron/hal-service.ts index 1259c77..636d7f2 100644 --- a/apps/machine/electron/hal-service.ts +++ b/apps/machine/electron/hal-service.ts @@ -6,7 +6,8 @@ * so it's available at runtime (unlike src/ which is only for Vite). */ -import type { BillValidator, BillDispenser } from '@bitSpire/hal' +import type { BillValidator, BillDispenser, DispenseErrorClass } from '@bitSpire/hal' +import { isDispenseError } from '@bitSpire/hal' export interface CassetteConfig { /** @@ -48,8 +49,20 @@ export interface ValidatorCallbacks { export interface DispenseResult { bills: { denomination: number; dispensed: number; rejected: number }[] - dispensed: boolean + /** + * Σ(denomination × dispensed) === Σ(denomination × requested). Computed + * here on VALUE (ADR-005 §3) — never a driver boolean. Only this routes + * the state machine to `complete`. + */ + dispenseConfirmed: boolean + /** Human message when not confirmed */ error?: string + /** The error's NAME — 'F56DispenseError', 'InsufficientInventory', … */ + errorCode?: string + /** Driver-native code, e.g. '78 42' */ + rawCode?: string + /** terminal | recoverable | inventory — see @bitSpire/hal error-codes */ + errorClass?: DispenseErrorClass cassettes?: { name: string position: number @@ -277,6 +290,8 @@ export async function initializeHal(config: HalConfig): Promise { notes[i] = (notes[i] ?? 0) + take remaining -= take } + // Nothing has been asked of the hardware in either refusal below: + // errorClass 'inventory' routes to outOfCash, not the fault screen. if (!matched) { return { bills: amounts.map((a) => ({ @@ -284,8 +299,10 @@ export async function initializeHal(config: HalConfig): Promise { dispensed: 0, rejected: 0, })), - dispensed: false, + dispenseConfirmed: false, error: `No cassette loaded with denomination: ${denomination}`, + errorCode: 'NoCassetteForDenomination', + errorClass: 'inventory', } } if (remaining > 0) { @@ -295,8 +312,10 @@ export async function initializeHal(config: HalConfig): Promise { dispensed: 0, rejected: 0, })), - dispensed: false, + dispenseConfirmed: false, error: `Insufficient inventory for denomination ${denomination}: short ${remaining}`, + errorCode: 'InsufficientInventory', + errorClass: 'inventory', } } } @@ -344,11 +363,44 @@ export async function initializeHal(config: HalConfig): Promise { } const bills = Array.from(billsByDenom.values()) - const totalRequested = amounts.reduce((s, a) => s + a.count, 0) + // ADR-005 §3: confirmation is VALUE equality — what left the bays is + // worth exactly what was asked — not a count, and not the driver's + // opinion. lamassu computed the same thing (`tx.fiat.eq(Σ denomination + // × dispensed)`); our previous count-based check was only equivalent + // while every bay dispensed its own denomination. + const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0) + const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0) const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0) + const dispenseConfirmed = requestedValue === dispensedValue if (result.error) { - return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message } + const e = result.error + const info = isDispenseError(e) + ? { errorCode: e.errorCode, rawCode: e.rawCode, errorClass: e.errorClass, human: e.human } + : { + // Unreachable by type (drivers always tag), kept as a defensive + // fallback for a driver that slips an untagged Error through. + errorCode: (e as Error).name || 'DispenseError', + rawCode: undefined, + errorClass: 'terminal' as const, + human: (e as Error).message, + } + console.error( + `[HAL] Dispense error ${info.errorCode}${info.rawCode ? ` ${info.rawCode}` : ''} (${info.errorClass}): ${info.human} — requested ${requestedValue}, dispensed ${dispensedValue}` + ) + // A dispensed value of zero WITH an error is not evidence that nothing + // left the bay — a note stopped in the transport completes neither + // counter (sintra, 2026-10-09). The store reads this combination and + // flags counts unverified; we just report faithfully here. + return { + bills, + cassettes: cassetteResults, + dispenseConfirmed: false, + error: info.human, + errorCode: info.errorCode, + rawCode: info.rawCode, + errorClass: info.errorClass, + } } // Wait for customer to take bills @@ -357,7 +409,20 @@ export async function initializeHal(config: HalConfig): Promise { console.log('[HAL] Bills removed by customer') } - return { bills, cassettes: cassetteResults, dispensed: totalRequested === totalDispensed } + if (!dispenseConfirmed) { + // Short with no hardware error — the dispenser simply gave less. + console.warn(`[HAL] Dispense short with no error: requested ${requestedValue}, dispensed ${dispensedValue}`) + return { + bills, + cassettes: cassetteResults, + dispenseConfirmed: false, + error: `Dispensed ${dispensedValue} of ${requestedValue} with no dispenser error`, + errorCode: 'DispenseShort', + errorClass: 'inventory', + } + } + + return { bills, cassettes: cassetteResults, dispenseConfirmed: true } }, /** diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 7a87ac2..0295894 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -27,6 +27,10 @@ import { getCountsUncertainSince, getLastStatePublishedAt, markCountsUncertain, + getCashOutHold, + setCashOutHold, + clearCashOutHold, + type CashOutHold, markStatePublished, resetStatePublishWatermark, resetForRepair, @@ -565,6 +569,15 @@ ipcMain.handle('state:get-counts-uncertain-since', (): number | null => getCount ipcMain.handle('state:mark-counts-uncertain', (_event, unixTimestamp: number): void => { markCountsUncertain(unixTimestamp) }) +// Cash-out hold (ADR-005 §5) +ipcMain.handle('state:get-cash-out-hold', (): CashOutHold | null => getCashOutHold()) +ipcMain.handle('state:set-cash-out-hold', (_event, hold: CashOutHold): CashOutHold => { + if (!hold || typeof hold.reason !== 'string' || typeof hold.since !== 'number') { + throw new Error('Invalid cash-out hold') + } + return setCashOutHold(hold) +}) +ipcMain.handle('state:clear-cash-out-hold', (): boolean => clearCashOutHold()) ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => { markStatePublished(unixTimestamp) }) @@ -841,7 +854,7 @@ function startCommandPoller(): void { recordTransaction({ txid, type: 'manual_dispense', - status: result.dispensed ? 'complete' : 'dispense_error', + status: result.dispenseConfirmed ? 'complete' : 'dispense_error', fiatCents: totalFiatCents, sats: 0, feeSats: 0, @@ -860,7 +873,7 @@ function startCommandPoller(): void { // Only remediate the original tx if ALL requested bills were dispensed let refRemediated = false - if (parsed.ref_txid && result.dispensed) { + if (parsed.ref_txid && result.dispenseConfirmed) { refRemediated = remediateTransaction(parsed.ref_txid, txid) } @@ -868,7 +881,13 @@ function startCommandPoller(): void { cmd.id, JSON.stringify({ txid, - dispensed: result.dispensed, + // Wire key kept as `dispensed` — spirekeeper's command poller + // reads it. Value is the ADR-005 value-equality confirmation. + dispensed: result.dispenseConfirmed, + dispense_confirmed: result.dispenseConfirmed, + error_code: result.errorCode, + raw_code: result.rawCode, + error_class: result.errorClass, ref_remediated: refRemediated, error: result.error, }) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 8bebc13..83ab409 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -7,6 +7,14 @@ import { contextBridge, ipcRenderer } from 'electron' +/** Mirrors state-store.CashOutHold (ADR-005 §5) — preload can't import main-process modules. */ +interface CashOutHold { + reason: string + errorCode: string | null + rawCode: string | null + since: number +} + /** * Runtime configuration interface (public info only) * These values are read from environment variables at runtime (not build time) @@ -114,6 +122,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('state:get-counts-uncertain-since'), markCountsUncertain: (unixTimestamp: number): Promise => ipcRenderer.invoke('state:mark-counts-uncertain', unixTimestamp), + // Cash-out hold (ADR-005 §5) + getCashOutHold: (): Promise => ipcRenderer.invoke('state:get-cash-out-hold'), + setCashOutHold: (hold: CashOutHold): Promise => + ipcRenderer.invoke('state:set-cash-out-hold', hold), + clearCashOutHold: (): Promise => ipcRenderer.invoke('state:clear-cash-out-hold'), markStatePublished: (unixTimestamp: number): Promise => ipcRenderer.invoke('state:mark-state-published', unixTimestamp), @@ -307,6 +320,9 @@ declare global { getLastStatePublishedAt: () => Promise getCountsUncertainSince: () => Promise markCountsUncertain: (unixTimestamp: number) => Promise + getCashOutHold: () => Promise + setCashOutHold: (hold: CashOutHold) => Promise + clearCashOutHold: () => Promise markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index fa6f17f..ec4d682 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -517,6 +517,69 @@ export function clearCountsUncertain(): void { ).run('countsUncertainSince', '') } +// --------------------------------------------------------------------------- +// Cash-out hold (ADR-005 §5) +// --------------------------------------------------------------------------- +// +// A terminal dispenser fault latches cash-out off. The latch is machine +// health, so it lives in `meta` (one JSON value) and survives restarts; the +// renderer restores it into the state machine on boot and the operator +// releases it with a `recount` or `resume_cash_out` op. Re-initialising the +// dispenser never clears it — re-init does not move a stuck note. + +export interface CashOutHold { + reason: string + errorCode: string | null + rawCode: string | null + /** unix seconds of the FIRST fault — kept across repeat faults */ + since: number +} + +export function getCashOutHold(): CashOutHold | null { + if (!db) throw new Error('Database not initialized') + const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('cashOutHeld') as + | { value: string } + | undefined + if (!row || row.value === '') return null + try { + const parsed = JSON.parse(row.value) as Partial + if (typeof parsed.since !== 'number' || typeof parsed.reason !== 'string') return null + return { + reason: parsed.reason, + errorCode: typeof parsed.errorCode === 'string' ? parsed.errorCode : null, + rawCode: typeof parsed.rawCode === 'string' ? parsed.rawCode : null, + since: parsed.since, + } + } catch { + return null + } +} + +/** Latch cash-out off. Idempotent: an existing hold (and its `since`) is kept. */ +export function setCashOutHold(hold: CashOutHold): CashOutHold { + if (!db) throw new Error('Database not initialized') + const existing = getCashOutHold() + if (existing) return existing + db.prepare( + 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ).run('cashOutHeld', JSON.stringify(hold)) + console.warn( + `[StateStore] Cash-out HELD: ${hold.errorCode ?? 'fault'}${hold.rawCode ? ` ${hold.rawCode}` : ''} — ${hold.reason}` + ) + return hold +} + +/** Release the latch — an operator has cleared the machine. */ +export function clearCashOutHold(): boolean { + if (!db) throw new Error('Database not initialized') + const had = getCashOutHold() !== null + db.prepare( + 'INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ).run('cashOutHeld', '') + if (had) console.log('[StateStore] Cash-out hold released') + return had +} + /** * A counter bumped on every local change to a bay count, from any cause. * @@ -851,7 +914,12 @@ export function applyOperatorCassetteOps(ops: CassetteOp[]): ApplyOpsResult { // A recount is an operator opening the bay and counting it, which is // exactly what resolves an unverified count. Nothing else does: a refill // adds to a number still known to be wrong. - if (sawRecount) upsertMeta.run('countsUncertainSince', '') + if (sawRecount) { + upsertMeta.run('countsUncertainSince', '') + // ADR-005 §5: a recount is an operator at the open machine — the one + // gesture that also releases a cash-out hold. + upsertMeta.run('cashOutHeld', '') + } })() console.log( diff --git a/apps/machine/src/composables/useAvailabilityBroadcast.ts b/apps/machine/src/composables/useAvailabilityBroadcast.ts index 20517e9..ce9c2e9 100644 --- a/apps/machine/src/composables/useAvailabilityBroadcast.ts +++ b/apps/machine/src/composables/useAvailabilityBroadcast.ts @@ -29,8 +29,14 @@ interface UseAvailabilityBroadcastOptions { signer: Signer /** Reactive inventory: denomination -> count */ inventory: Ref> - /** Reactive Lightning.Pub balance in sats (null = unknown) */ + /** Reactive wallet balance in sats (null = unknown) */ balanceSats: Ref + /** + * ADR-005 §5: cash-out is latched off after a terminal dispenser fault. + * A machine with full bays and a jammed transport must not advertise + * cash-out — that is exactly what sintra did for an hour on 2026-10-09. + */ + cashOutHeld?: Ref /** Fiat currency code */ fiatCode: string /** Machine model */ @@ -38,7 +44,7 @@ interface UseAvailabilityBroadcastOptions { } export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOptions) { - const { nostrClient, signer, inventory, balanceSats, fiatCode, model } = options + const { nostrClient, signer, inventory, balanceSats, cashOutHeld, fiatCode, model } = options let lastSnapshot: AvailabilitySnapshot | null = null @@ -53,7 +59,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption function computeSnapshot(): AvailabilitySnapshot { const totalBills = Object.values(inventory.value).reduce((s, c) => s + c, 0) return { - cashOut: totalBills > 0, + cashOut: totalBills > 0 && !(cashOutHeld?.value ?? false), cashIn: (balanceSats.value ?? 0) > 0, cashLevel: computeCashLevel(), } @@ -101,7 +107,7 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption // Watch reactive sources watch( - [inventory, balanceSats], + cashOutHeld ? [inventory, balanceSats, cashOutHeld] : [inventory, balanceSats], () => { debouncedPublish() }, diff --git a/apps/machine/src/services/hal.ts b/apps/machine/src/services/hal.ts index 723bbc3..c793aec 100644 --- a/apps/machine/src/services/hal.ts +++ b/apps/machine/src/services/hal.ts @@ -147,8 +147,10 @@ export async function initializeHalServices(config: HalConfig): Promise s + a.count, 0) + // ADR-005 §3: confirmation on VALUE. Same contract as electron/hal-service.ts. + const requestedValue = amounts.reduce((s, a) => s + a.denomination * a.count, 0) + const dispensedValue = cassetteResults.reduce((s, c) => s + c.denomination * c.dispensed, 0) const totalDispensed = bills.reduce((s, b) => s + b.dispensed, 0) + const dispenseConfirmed = requestedValue === dispensedValue if (result.error) { - return { bills, cassettes: cassetteResults, dispensed: false, error: result.error.message } + const e = result.error + return { + bills, + cassettes: cassetteResults, + dispenseConfirmed: false, + error: e.human ?? e.message, + errorCode: e.errorCode ?? e.name, + rawCode: e.rawCode, + errorClass: e.errorClass ?? 'terminal', + } } // Wait for customer to take bills (only if bills were dispensed) @@ -195,7 +209,18 @@ export async function initializeHalServices(config: HalConfig): Promise { diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 8140791..3a72f63 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -742,7 +742,7 @@ export function createATMServices( subId: string | null /** Preimage seen before a consumer attached; replayed on attach. */ settled: string | null - consumer: ((preimage: string) => void) | null + consumer: ((preimage: string, paymentHash: string) => void) | null poll: ReturnType | null released: boolean } @@ -765,7 +765,7 @@ export function createATMServices( watch.settled = preimage stopInvoiceWatchPoll(watch) console.log(`[ATM Service] Invoice paid (${via})!`) - watch.consumer?.(preimage) + watch.consumer?.(preimage, watch.paymentHash) } function startInvoiceWatchPoll(watch: InvoiceWatch): void { @@ -1106,7 +1106,7 @@ export function createATMServices( dispensed: a.count, rejected: 0, })), - dispensed: true, + dispenseConfirmed: true, } }, @@ -1206,7 +1206,10 @@ export function createATMServices( * Watch a BOLT11 invoice for payment via LNbits subscribe_payments * push, filtered by payment_hash. Returns a cleanup function. */ - watchInvoice: (invoice: string, callback: (preimage: string) => void): (() => void) => { + watchInvoice: ( + invoice: string, + callback: (preimage: string, paymentHash?: string) => void + ): (() => void) => { if (!invoice.toLowerCase().startsWith('ln')) { console.error('[ATM Service] Invalid invoice format - expected BOLT11') return () => {} @@ -1221,7 +1224,7 @@ export function createATMServices( // on a push that has already come and gone. if (armed.settled) { const preimage = armed.settled - queueMicrotask(() => callback(preimage)) + queueMicrotask(() => callback(preimage, armed.paymentHash)) } return () => releaseInvoiceWatch(invoice) } @@ -1244,7 +1247,7 @@ export function createATMServices( const late = invoiceWatches.get(invoice) if (!late || cancelled) return late.consumer = callback - if (late.settled) callback(late.settled) + if (late.settled) callback(late.settled, late.paymentHash) } catch (e) { console.error('[ATM Service] LNbits watchInvoice failed:', e) } diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts index f81b5d5..e9f38b9 100644 --- a/apps/machine/src/services/operator-config.ts +++ b/apps/machine/src/services/operator-config.ts @@ -44,7 +44,7 @@ const KIND_NIP78 = 30078 /** The wire schema this machine speaks. Operations, not counts (ADR-004). */ const CASSETTE_SCHEMA_VERSION = 2 -/** One operator-authored operation, as it arrives on the wire. */ +/** One operator-authored cassette operation, as it arrives on the wire. */ type CassetteOp = { id: string at: number @@ -55,6 +55,18 @@ type CassetteOp = { denomination?: number } +/** + * ADR-005 §5: the operator releases a cash-out hold without touching a bay + * count. Rides the same operator event as the cassette ops (same id/at shape) + * but is NOT a cassette op: it never reaches `applyOperatorCassetteOps`, which + * would reject the type. Honoured only when stamped AFTER the hold began, so + * a re-delivered resume from before a fresh fault cannot clear that fault. + * A `recount` releases the hold too — it is the same "operator at the open + * machine" gesture and already clears counts-uncertain. + */ +type ResumeCashOutOp = { id: string; at: number; type: 'resume_cash_out' } +type OperatorOp = CassetteOp | ResumeCashOutOp + /** Accept operator events stamped up to this many seconds in the future. */ const MAX_FUTURE_SKEW_S = 60 @@ -85,6 +97,12 @@ export interface OperatorConfigServiceConfig { operatorPubkeys: string[] /** Machine identifier for the d-tag. Defaults to signer.pubkey when omitted. */ machineId?: string + /** + * ADR-005 §5: called when an operator op (recount, resume_cash_out) has + * released a persisted cash-out hold, so the store can lift the state + * machine's latch. The store wires this to `CASH_OUT_RELEASED`. + */ + onCashOutHoldReleased?: () => void } export interface OperatorConfigService { @@ -222,7 +240,28 @@ async function handleOperatorConfigEvent( console.error('[OperatorConfig] Payload missing `ops` array — dropped') return } - const ops = parsed.ops as CassetteOp[] + const allOps = parsed.ops as OperatorOp[] + + // 4b. ADR-005 §5 — split out resume_cash_out before the cassette apply. + // Release only if a resume is stamped after the hold began; an idempotent + // re-delivery of an older resume must not clear a newer fault. + const holdBefore = await api.getCashOutHold() + const resumeOps = allOps.filter( + (o): o is ResumeCashOutOp => !!o && o.type === 'resume_cash_out' + ) + const ops = allOps.filter((o): o is CassetteOp => !!o && o.type !== 'resume_cash_out') + if (holdBefore && resumeOps.some((o) => typeof o.at === 'number' && o.at > holdBefore.since)) { + await api.clearCashOutHold() + console.log( + `[OperatorConfig] Cash-out hold released by operator resume op ` + + `(held since ${holdBefore.since}, ${resumeOps.length} resume op(s))` + ) + } else if (resumeOps.length > 0) { + console.log( + `[OperatorConfig] ${resumeOps.length} resume_cash_out op(s) ignored — ` + + (holdBefore ? 'all stamped before the current hold began' : 'no hold in place') + ) + } // 5. Apply the ones we have not seen, in one transaction with the sequence // bump. No `created_at` watermark: each op carries an operator-minted id @@ -230,10 +269,19 @@ async function handleOperatorConfigEvent( // no-op on its own merits. The watermark would be strictly weaker and // actively harmful — an event arriving out of order can still carry an // operation this machine has never seen. - const result = await api.applyOperatorCassetteOps(ops) + const result = ops.length + ? await api.applyOperatorCassetteOps(ops) + : { applied: [] as string[], rejected: [] as { id: string; reason: string }[] } for (const bad of result.rejected) { console.warn(`[OperatorConfig] Op ${bad.id} rejected: ${bad.reason}`) } + + // A recount (applied in the store, which also clears the hold) or the resume + // above may have released the latch: tell the store so the state machine + // lifts its guard. The republishes below carry the cleared state up. + if (holdBefore && (await api.getCashOutHold()) === null) { + cfg.onCashOutHoldReleased?.() + } if (result.applied.length === 0) { console.log(`[OperatorConfig] No new ops in event ${event.id.slice(0, 12)}…`) // Still republish: the operator learns from our applied_ops echo that @@ -318,6 +366,15 @@ async function publishCassettesState( applied_ops: appliedOps, } if (countsUncertainSince) payload.counts_uncertain_since = countsUncertainSince + // ADR-005 §5 — additive, same contract as counts_uncertain_since: an old + // consumer ignores it. When present, this machine is refusing cash-out + // until an operator recount or resume_cash_out op. + const hold = await api.getCashOutHold() + if (hold) { + payload.cash_out_held_since = hold.since + payload.cash_out_held_reason = hold.reason + payload.cash_out_held_code = hold.rawCode ?? hold.errorCode ?? null + } const ciphertext = await cfg.signer.nip44Encrypt(operatorPubkey, JSON.stringify(payload)) // Force the stamp strictly above our last one. Addressable events are ordered diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 1a13349..8131c85 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -126,7 +126,7 @@ async function handleManagementCommand( await persistTransaction({ txid, type: 'manual_dispense', - status: result.dispensed ? 'complete' : 'dispense_error', + status: result.dispenseConfirmed ? 'complete' : 'dispense_error', fiatCents: totalFiatCents, sats: 0, feeSats: 0, @@ -141,7 +141,7 @@ async function handleManagementCommand( // Only remediate the original tx if ALL requested bills were dispensed let refRemediated = false - if (request.ref_txid && result.dispensed && isElectron && window.electronAPI) { + if (request.ref_txid && result.dispenseConfirmed && isElectron && window.electronAPI) { refRemediated = await window.electronAPI.remediateTransaction(request.ref_txid, txid) if (refRemediated) { console.log('[ATM] Remediated failed tx:', request.ref_txid) @@ -254,7 +254,7 @@ const mockServices: ATMServices = { dispensed: a.count, rejected: 0, })), - dispensed: true, + dispenseConfirmed: true, } }, @@ -618,13 +618,31 @@ export const useAtmStore = defineStore('atm', () => { send({ type: 'CASH_DISPENSED' }) } - // Record failed cash-out dispenses (sats debited but cash not dispensed) - if (currentNested === 'dispenseError' && prevNestedState !== 'dispenseError') { + // ADR-005 §5: persist the cash-out hold the moment the machine sets it, + // and republish the cassette state so the operator sees it. The hold is + // machine health, not transaction state — it must survive a restart. + const heldNow = newSnapshot.context.cashOutHeld + const heldBefore = prevSnapshot?.context.cashOutHeld ?? null + if (heldNow && !heldBefore && isElectron && window.electronAPI) { + void window.electronAPI + .setCashOutHold(heldNow) + .then(() => operatorConfigSvc?.publishCassettesState()) + .catch((e) => console.error('[ATM] Could not persist cash-out hold:', e)) + } + + // Record a cash-out that did not confirm (ADR-005 §3/§4). Both terminal + // states mean the customer has PAID and received less than they paid + // for — dispenseFault because the dispenser reported an error, outOfCash + // because it reported none (an inventory refusal, or simply short). + // Either way the row is dispense_error / partial and the server learns + // of it; the difference is only the customer screen and the latch. + const isDispenseTerminal = currentNested === 'dispenseFault' || currentNested === 'outOfCash' + const wasDispenseTerminal = prevNestedState === 'dispenseFault' || prevNestedState === 'outOfCash' + if (isDispenseTerminal && !wasDispenseTerminal) { const ctx = newSnapshot.context if (ctx.txid) { const dr = ctx.dispenseResult - // Determine status from dispense result (if available) let status: 'dispense_error' | 'partial' = 'dispense_error' let bills: { denomination: number; count: number }[] = [] @@ -634,6 +652,23 @@ export const useAtmStore = defineStore('atm', () => { bills = dr.bills .filter((b) => b.dispensed > 0) .map((b) => ({ denomination: b.denomination, count: b.dispensed })) + + // ADR-005 §3 — the deviation from both bitSpire-before and lamassu: + // a report of ZERO dispensed that arrives WITH a hardware error is + // not evidence that nothing left the bay. A note that stops in the + // transport path completes neither the dispensed nor the rejected + // counter (sintra, 2026-10-09: bay read 66, held 65, one in the + // transport). Flag the counts unverified so the next recount is + // what resolves them, instead of trusting a zero. + if (totalDispensed === 0 && dr.error && dr.errorClass !== 'inventory') { + console.error( + `[ATM] Dispense reported 0 notes WITH an error (${dr.errorCode ?? 'unknown'}` + + `${dr.rawCode ? ` ${dr.rawCode}` : ''}) — bay counts are unverified (txid=${ctx.txid})` + ) + void window.electronAPI + ?.markCountsUncertain(Math.floor(Date.now() / 1000)) + .catch((e) => console.warn('[ATM] Could not flag counts unverified:', e)) + } } else { // The dispenser threw, or the dispense timed out, so there is no // per-bay report. Bills may well have reached the customer, and @@ -664,7 +699,8 @@ export const useAtmStore = defineStore('atm', () => { error: dr?.error ?? ctx.error, }) .then(() => reloadPersistedInventory()) - // Republish cassette state — a partial dispense changed counts. + // Republish cassette state — a partial dispense changed counts, and + // the payload now carries the hold / unverified flags. .then(() => operatorConfigSvc?.publishCassettesState()) } } @@ -742,6 +778,23 @@ export const useAtmStore = defineStore('atm', () => { setupNfcListener() setupCassettesChangedListener() console.log('[ATM] State machine initialized') + + // ADR-005 §5: a cash-out hold persisted by a previous run gates cash-out + // before any dispense — a restart must not quietly put a jammed machine + // back in service. Released only by an operator recount / resume op. + if (isElectron && window.electronAPI) { + void window.electronAPI + .getCashOutHold() + .then((hold) => { + if (!hold) return + console.warn( + `[ATM] Cash-out HELD since ${new Date(hold.since * 1000).toISOString()} ` + + `(${hold.errorCode ?? 'fault'}${hold.rawCode ? ` ${hold.rawCode}` : ''}): ${hold.reason}` + ) + send({ type: 'CASH_OUT_HELD', hold }) + }) + .catch((e) => console.warn('[ATM] Could not read cash-out hold:', e)) + } } // ── Bolt Card cash-out (NFC tap-to-pay) ─────────────────────────────────── @@ -1135,6 +1188,7 @@ export const useAtmStore = defineStore('atm', () => { nostrClient: services.nostrClient, signer: services.signer, operatorPubkeys: services.operatorPubkeys, + onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }), }) // Start operator-fees consumer (aiolabs/lamassu-next#57) — subscribes @@ -1461,6 +1515,7 @@ export const useAtmStore = defineStore('atm', () => { nostrClient: lightning.nostrClient, signer: lightning.signer, operatorPubkeys: lightning.operatorPubkeys, + onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }), }) // Operator-fees consumer (aiolabs/lamassu-next#57) @@ -1797,6 +1852,7 @@ export const useAtmStore = defineStore('atm', () => { nostrClient: lightning.nostrClient, signer: lightning.signer, operatorPubkeys: lightning.operatorPubkeys, + onCashOutHoldReleased: () => send({ type: 'CASH_OUT_RELEASED' }), }) // Operator-fees consumer (aiolabs/lamassu-next#57) @@ -1899,6 +1955,11 @@ export const useAtmStore = defineStore('atm', () => { send({ type: 'SELECT_CASH_IN' }) } + /** Customer dismisses the dispense-fault screen ("I've saved this reference"). */ + function acknowledgeFault() { + send({ type: 'ACKNOWLEDGE_FAULT' }) + } + function selectCashOut() { settlementError.value = null send({ type: 'SELECT_CASH_OUT' }) @@ -2004,6 +2065,8 @@ export const useAtmStore = defineStore('atm', () => { signer, inventory: persistedInventory, balanceSats, + // ADR-005 §5: a latched machine must not advertise cash-out. + cashOutHeld: computed(() => snapshot.value?.context.cashOutHeld != null), fiatCode: fiatCode.value, model, }) @@ -2069,6 +2132,7 @@ export const useAtmStore = defineStore('atm', () => { send, selectCashIn, selectCashOut, + acknowledgeFault, cancel, insertBill, finishInserting, diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 8e4092e..9c54e75 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -150,6 +150,20 @@ declare global { /** When the bay counts became unverified (a dispense that reported nothing), or null. */ getCountsUncertainSince: () => Promise markCountsUncertain: (unixTimestamp: number) => Promise + // Cash-out hold (ADR-005 §5) + getCashOutHold: () => Promise<{ + reason: string + errorCode: string | null + rawCode: string | null + since: number + } | null> + setCashOutHold: (hold: { + reason: string + errorCode: string | null + rawCode: string | null + since: number + }) => Promise<{ reason: string; errorCode: string | null; rawCode: string | null; since: number }> + clearCashOutHold: () => Promise markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise diff --git a/apps/machine/src/views/CashOutView.vue b/apps/machine/src/views/CashOutView.vue index 896f165..8fb0bd1 100644 --- a/apps/machine/src/views/CashOutView.vue +++ b/apps/machine/src/views/CashOutView.vue @@ -63,13 +63,19 @@ watch( const nestedState = computed(() => atmStore.nestedState) const context = computed(() => atmStore.context) -// Dispense error 30s countdown +// Terminal-screen countdowns (ADR-005 §4). The machine owns the real timers +// (DISPENSE_FAULT_TIMEOUT 120 s, DISPENSE_ERROR_TIMEOUT 30 s); this mirrors +// them for display only. +const TERMINAL_SECONDS: Record = { dispenseFault: 120, outOfCash: 30 } const dispenseErrorCountdown = ref(30) let countdownTimer: ReturnType | null = null watch(nestedState, (newState, oldState) => { - if (newState === 'dispenseError' && oldState !== 'dispenseError') { - dispenseErrorCountdown.value = 30 + const entering = typeof newState === 'string' && newState in TERMINAL_SECONDS + const leaving = typeof oldState === 'string' && oldState in TERMINAL_SECONDS + if (entering && newState !== oldState) { + if (countdownTimer) clearInterval(countdownTimer) + dispenseErrorCountdown.value = TERMINAL_SECONDS[newState as string] ?? 30 countdownTimer = setInterval(() => { dispenseErrorCountdown.value-- if (dispenseErrorCountdown.value <= 0 && countdownTimer) { @@ -77,12 +83,21 @@ watch(nestedState, (newState, oldState) => { countdownTimer = null } }, 1000) - } else if (oldState === 'dispenseError' && countdownTimer) { + } else if (leaving && !entering && countdownTimer) { clearInterval(countdownTimer) countdownTimer = null } }) +function acknowledgeFault() { + atmStore.acknowledgeFault() +} + +const faultTime = computed(() => { + const t = context.value?.startedAt + return t ? new Date(t).toLocaleString() : '' +}) + // Available denominations from inventory const availableDenominations = computed(() => { if (!context.value?.inventory) return [] @@ -535,63 +550,92 @@ function formatFiat(cents: number): string { - +
- -
+
⚠️
-

Dispense Error

-

- {{ context?.error || 'Cash could not be dispensed' }} +

+ {{ nestedState === 'dispenseFault' ? 'Dispenser fault' : 'Could not dispense' }} +

+

+ Your payment went through. The cash below could not be dispensed.

- -
+
+
+ You paid + {{ atmStore.fiatSymbol }}{{ ((context?.fiatCents ?? 0) / 100).toFixed(2) }} + ({{ (context?.satsAmount ?? 0).toLocaleString() }} sats) +
{{ atmStore.fiatSymbol }}{{ bill.denomination }}{{ atmStore.fiatSymbol }}{{ bill.denomination }} notes {{ bill.dispensed }} dispensed - - ({{ bill.rejected }} rejected) -
-

- Please contact support with the transaction ID below. +

+ The operator has been notified and holds a record of this transaction. + Keep this reference — photograph it or write it down. +

+

+ Technical detail: {{ context.error }}

- +
+ + +

Returning to start in {{ dispenseErrorCountdown }}s

- -
- -
- +
+ +

Transaction

{{ context.txid }}

+ +

{{ faultTime }}

diff --git a/apps/machine/src/views/IdleView.vue b/apps/machine/src/views/IdleView.vue index c88da60..0cdc884 100644 --- a/apps/machine/src/views/IdleView.vue +++ b/apps/machine/src/views/IdleView.vue @@ -121,16 +121,32 @@ function handleCashOut() { > - +
From 7f055cd4c69cdeb7be977accf532426655490b62 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:26:53 +0200 Subject: [PATCH 157/164] =?UTF-8?q?docs(adr):=20ADR-005=20=C2=A74=20?= =?UTF-8?q?=E2=80=94=20outOfCash=20after=20payment=20is=20owed=20too;=20on?= =?UTF-8?q?ly=20the=20cause=20and=20the=20latch=20differ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adr/005-cash-out-dispense-outcome.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/adr/005-cash-out-dispense-outcome.md b/docs/adr/005-cash-out-dispense-outcome.md index 58a3b1b..22bc03a 100644 --- a/docs/adr/005-cash-out-dispense-outcome.md +++ b/docs/adr/005-cash-out-dispense-outcome.md @@ -195,10 +195,16 @@ instead of a clean ledger. Two distinct terminal states replace the single `dispenseError`: -- **`outOfCash`** — the request could not be met from inventory and the dispenser reported - **no error**. Nothing was charged beyond what was dispensed. +- **`outOfCash`** — the request could not be met and the dispenser reported **no error** + (an inventory refusal, or simply short). In the cash-out flow this state is reached *after* + payment, so the customer **has paid** and is owed the shortfall exactly as below; the + difference is the cause — no hardware fault, so the machine stays in service and nothing + latches. (An earlier draft said "nothing was charged beyond what was dispensed"; that is + only true of the inventory check *before* payment, which already prevents the sale.) - **`dispenseFault`** — the dispenser reported an error. The customer **has paid** and is - owed the shortfall. + owed the shortfall, and a `terminal` class also latches cash-out off (Decision 5). + +Both screens therefore show the same evidence; the heading and the latch differ. `dispenseFault` shows: the amount paid, the amount dispensed (per denomination, as now), the txid as QR (as now) **and as text**, the first 12 characters of the payment hash, the time, From 7120f306b6886648273b8ff45d9a62870bee4c28 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:31:26 +0200 Subject: [PATCH 158/164] =?UTF-8?q?feat(lnbits):=20report=5Fdispense=20RPC?= =?UTF-8?q?=20and=20the=20dispense-report=20wire=20types=20(ADR-005=20?= =?UTF-8?q?=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One cash-out's dispense outcome, sent on success as well as failure — the success report is what captures the settlement server-side. Field names follow lamassu-server's cash_out_txs / cash_out_actions (dispense_confirmed, error, error_code) with raw_code and error_class alongside, per-denomination bills with `requested`, per-bay cassettes verbatim, the payment hash as the join key, and counts_uncertain. Idempotent on txid (the server upserts), so the call is wrapped in idempotent() and safe for the machine's outbox to retry. Until spirekeeper registers the RPC it rejects with LnbitsRpcError, which the outbox treats like any other transient failure. --- packages/lnbits/src/client.ts | 14 ++++++++ packages/lnbits/src/index.ts | 4 +++ packages/lnbits/src/types.ts | 63 +++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/packages/lnbits/src/client.ts b/packages/lnbits/src/client.ts index ad212a8..ece7bf3 100644 --- a/packages/lnbits/src/client.ts +++ b/packages/lnbits/src/client.ts @@ -48,6 +48,8 @@ import type { CreateWithdrawResult, LnbitsWithdrawLink, UniqueHashesResponse, + DispenseReportBody, + DispenseReportAck, } from './types.js' const LNBITS_KIND_RPC = 21000 @@ -234,6 +236,18 @@ export class LnbitsClient { ) } + /** + * Report a cash-out's dispense outcome (ADR-005 §2). Sent on success and on + * failure; the success report is what captures the settlement server-side. + * Idempotent on `txid` (the server upserts), so it is safe to retry — and the + * caller keeps it in a durable outbox and resends until this resolves. + * Rejects with LnbitsRpcError while spirekeeper has not registered the RPC; + * the outbox treats that like any other transient failure. + */ + async reportDispense(body: DispenseReportBody): Promise { + return this.idempotent(() => this.sendRpc('report_dispense', { body })) + } + // ============================================================================ // Invoices // ============================================================================ diff --git a/packages/lnbits/src/index.ts b/packages/lnbits/src/index.ts index e1e87c1..c37d73f 100644 --- a/packages/lnbits/src/index.ts +++ b/packages/lnbits/src/index.ts @@ -80,4 +80,8 @@ export type { UniqueHashEntry, UniqueHashesResponse, LnbitsPayLink, + DispenseReportBody, + DispenseReportAck, + DispenseReportBill, + DispenseReportCassette, } from './types.js' diff --git a/packages/lnbits/src/types.ts b/packages/lnbits/src/types.ts index fdebb95..135e0a8 100644 --- a/packages/lnbits/src/types.ts +++ b/packages/lnbits/src/types.ts @@ -310,3 +310,66 @@ export interface MachineConfigResponse { /** Freshness watermark (unix s) for the consumer's fee-config replay guard. */ created_at: number } + + +// ============================================================================ +// Dispense outcome report (ADR-005 §2) — machine → spirekeeper `report_dispense` +// ============================================================================ + +/** Per-denomination outcome. `requested` is what the sale asked for. */ +export interface DispenseReportBill { + denomination: number + requested: number + dispensed: number + rejected: number +} + +/** Per-bay outcome, verbatim from the machine's cassette_bills row. */ +export interface DispenseReportCassette { + position: number + denomination: number + provisioned: number + dispensed: number + rejected: number +} + +/** + * One cash-out's dispense outcome, sent on SUCCESS as well as failure — the + * success report is what captures the settlement server-side. Field names + * follow lamassu-server's cash_out_txs / cash_out_actions (dispense_confirmed, + * error, error_code) so the server's model lines up with ten years of prior + * art. Idempotent on `txid`: the machine resends until acked, the server + * upserts. + */ +export interface DispenseReportBody { + txid: string + /** Hash of the invoice the customer paid — the join key to the LNbits payment. */ + payment_hash: string | null + tx_type: 'cash_out' + /** Σ(denomination × dispensed) === requested fiat value (computed on value). */ + dispense_confirmed: boolean + /** Human message; null on success. */ + error: string | null + /** The error's NAME, e.g. 'F56DispenseError'; null on success. */ + error_code: string | null + /** Driver-native code, e.g. '78 42'; null when none. */ + raw_code: string | null + error_class: 'terminal' | 'recoverable' | 'inventory' | null + fiat_cents: number + currency: string + bills: DispenseReportBill[] + cassettes: DispenseReportCassette[] + /** The machine could not vouch for its bay counts after this dispense. */ + counts_uncertain: boolean + /** Set when this report closes an earlier failed txid via manual dispense. */ + remediates_txid?: string + /** unix seconds the outcome was recorded on the machine */ + at: number +} + +/** Server acknowledgement. `settlement_status` is what the server moved the settlement to. */ +export interface DispenseReportAck { + txid: string + received: boolean + settlement_status?: string +} From e8106b665a3f38d28720fd1466a759886b4758b9 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 21:37:17 +0200 Subject: [PATCH 159/164] =?UTF-8?q?feat(machine):=20durable=20dispense-rep?= =?UTF-8?q?ort=20outbox=20to=20spirekeeper=20(ADR-005=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every cash-out now produces one report_dispense — on success as well as failure — and the machine does not stop sending it until spirekeeper acknowledges it. state.db gains a dispense_reports table (migration v13 → v14): the report is written INSIDE recordTransaction's SQLite transaction, alongside the transactions row, so a crash between the two cannot lose it. Rows carry attempts / last_attempt_at / last_error / acked_at. Three IPC calls (pending / ack / note-attempt) expose it to the renderer. The store builds the report when a cash-out reaches complete, dispenseFault or outOfCash: txid, payment hash, dispense_confirmed, error / error_code / raw_code / error_class, per-denomination requested vs dispensed vs rejected, the per-bay cassette record verbatim, and counts_uncertain. The success report is what lets the server capture (distribute) the settlement; the failure report is what puts a customer on the owed-cash worklist instead of leaving the only record on the ATM. Delivery is at-least-once: a flusher drains pending rows after each persist, on relay (re)connect, and every 60 s, acking only on an OK reply and backing off 30 s · 2^attempts (capped 1 h) otherwise. While spirekeeper has not registered the RPC every send fails the same way; the backoff keeps that quiet and the rows wait — this half ships first. The lightning service exposes reportDispense; the function pointer is set at all three lightning-init sites so the flusher works on every path. --- apps/machine/electron/main.ts | 19 ++++ apps/machine/electron/preload.ts | 20 ++++ apps/machine/electron/state-store.ts | 118 ++++++++++++++++++++- apps/machine/src/services/lightning.ts | 9 +- apps/machine/src/stores/atm.ts | 136 +++++++++++++++++++++++++ apps/machine/src/types/electron.d.ts | 13 +++ apps/machine/src/types/state.ts | 6 ++ 7 files changed, 319 insertions(+), 2 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 0295894..68b9f88 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -31,6 +31,9 @@ import { setCashOutHold, clearCashOutHold, type CashOutHold, + pendingDispenseReports, + markDispenseReportAcked, + noteDispenseReportAttempt, markStatePublished, resetStatePublishWatermark, resetForRepair, @@ -578,6 +581,22 @@ ipcMain.handle('state:set-cash-out-hold', (_event, hold: CashOutHold): CashOutHo return setCashOutHold(hold) }) ipcMain.handle('state:clear-cash-out-hold', (): boolean => clearCashOutHold()) + +// Dispense-report outbox (ADR-005 §2) — at-least-once to spirekeeper +ipcMain.handle('state:pending-dispense-reports', (_event, limit?: number) => + pendingDispenseReports(typeof limit === 'number' ? limit : 20) +) +ipcMain.handle('state:ack-dispense-report', (_event, txid: string): boolean => { + if (typeof txid !== 'string' || !txid) throw new Error('Invalid txid') + return markDispenseReportAcked(txid) +}) +ipcMain.handle( + 'state:note-dispense-report-attempt', + (_event, txid: string, error: string | null): void => { + if (typeof txid !== 'string' || !txid) throw new Error('Invalid txid') + noteDispenseReportAttempt(txid, typeof error === 'string' ? error.slice(0, 512) : null) + } +) ipcMain.handle('state:mark-state-published', (_event, unixTimestamp: number): void => { markStatePublished(unixTimestamp) }) diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 83ab409..bc75046 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -15,6 +15,16 @@ interface CashOutHold { since: number } +/** Mirrors state-store.PendingDispenseReport (ADR-005 §2). */ +interface PendingDispenseReport { + txid: string + payload: unknown + createdAt: number + attempts: number + lastAttemptAt: number | null + lastError: string | null +} + /** * Runtime configuration interface (public info only) * These values are read from environment variables at runtime (not build time) @@ -127,6 +137,13 @@ contextBridge.exposeInMainWorld('electronAPI', { setCashOutHold: (hold: CashOutHold): Promise => ipcRenderer.invoke('state:set-cash-out-hold', hold), clearCashOutHold: (): Promise => ipcRenderer.invoke('state:clear-cash-out-hold'), + // Dispense-report outbox (ADR-005 §2) + pendingDispenseReports: (limit?: number): Promise => + ipcRenderer.invoke('state:pending-dispense-reports', limit), + ackDispenseReport: (txid: string): Promise => + ipcRenderer.invoke('state:ack-dispense-report', txid), + noteDispenseReportAttempt: (txid: string, error: string | null): Promise => + ipcRenderer.invoke('state:note-dispense-report-attempt', txid, error), markStatePublished: (unixTimestamp: number): Promise => ipcRenderer.invoke('state:mark-state-published', unixTimestamp), @@ -323,6 +340,9 @@ declare global { getCashOutHold: () => Promise setCashOutHold: (hold: CashOutHold) => Promise clearCashOutHold: () => Promise + pendingDispenseReports: (limit?: number) => Promise + ackDispenseReport: (txid: string) => Promise + noteDispenseReportAttempt: (txid: string, error: string | null) => Promise markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index ec4d682..67afd49 100644 --- a/apps/machine/electron/state-store.ts +++ b/apps/machine/electron/state-store.ts @@ -10,12 +10,13 @@ */ import Database from 'better-sqlite3' +import type { DispenseReportBody } from '@bitSpire/lnbits' import path from 'node:path' import fs from 'node:fs' let db: Database.Database | null = null -const SCHEMA_VERSION = '13' +const SCHEMA_VERSION = '14' function getDbPath(): string { const prodDir = '/var/lib/bitspire' @@ -136,6 +137,16 @@ export function initDatabase(dbPath?: string): void { relays TEXT, lnbits_server_pubkey TEXT ); + + CREATE TABLE IF NOT EXISTS dispense_reports ( + txid TEXT PRIMARY KEY REFERENCES transactions(txid), + payload TEXT NOT NULL, + created_at INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_attempt_at INTEGER, + last_error TEXT, + acked_at INTEGER + ); `) // Seed meta + cashbox if first run, or run migrations @@ -418,6 +429,32 @@ export function initDatabase(dbPath?: string): void { existing.value = '13' } + if (existing && existing.value === '13') { + // Migration v13 → v14: the dispense-report outbox (ADR-005 §2). + // + // Every cash-out's outcome — success or failure — is reported to + // spirekeeper over a kind-21000 RPC, and that report is what lets the + // server capture (distribute) the settlement or surface a customer who + // is owed cash. A relay gives the publisher no delivery guarantee, so the + // report is written here, in the SAME transaction as the transactions + // row, and resent until the server acknowledges it. Idempotent on txid + // server-side; `attempts` / `last_error` drive the resend backoff. + db.exec(` + CREATE TABLE IF NOT EXISTS dispense_reports ( + txid TEXT PRIMARY KEY REFERENCES transactions(txid), + payload TEXT NOT NULL, + created_at INTEGER NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_attempt_at INTEGER, + last_error TEXT, + acked_at INTEGER + ); + `) + db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('14', 'schema_version') + console.log('[StateStore] Migrated schema v13 → v14 (added dispense_reports outbox)') + existing.value = '14' + } + // Defensive: a fresh install at SCHEMA_VERSION skips all migrations. // Seed the operator-config meta rows if they're missing (idempotent). const seedMeta = db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)') @@ -580,6 +617,70 @@ export function clearCashOutHold(): boolean { return had } +// --------------------------------------------------------------------------- +// Dispense-report outbox (ADR-005 §2) +// --------------------------------------------------------------------------- + +export interface PendingDispenseReport { + txid: string + payload: DispenseReportBody + createdAt: number + attempts: number + lastAttemptAt: number | null + lastError: string | null +} + +/** Unacknowledged reports, oldest first. The renderer applies the backoff. */ +export function pendingDispenseReports(limit = 20): PendingDispenseReport[] { + if (!db) throw new Error('Database not initialized') + const rows = db + .prepare( + 'SELECT txid, payload, created_at, attempts, last_attempt_at, last_error FROM dispense_reports WHERE acked_at IS NULL ORDER BY created_at ASC LIMIT ?' + ) + .all(limit) as Array<{ + txid: string + payload: string + created_at: number + attempts: number + last_attempt_at: number | null + last_error: string | null + }> + const out: PendingDispenseReport[] = [] + for (const r of rows) { + try { + out.push({ + txid: r.txid, + payload: JSON.parse(r.payload) as DispenseReportBody, + createdAt: r.created_at, + attempts: r.attempts, + lastAttemptAt: r.last_attempt_at, + lastError: r.last_error, + }) + } catch { + console.error('[StateStore] dispense_reports row has unparseable payload:', r.txid) + } + } + return out +} + +/** The server acknowledged this report. Returns whether a row changed. */ +export function markDispenseReportAcked(txid: string): boolean { + if (!db) throw new Error('Database not initialized') + const res = db + .prepare('UPDATE dispense_reports SET acked_at = ? WHERE txid = ? AND acked_at IS NULL') + .run(Date.now(), txid) + if (res.changes > 0) console.log('[StateStore] Dispense report acked:', txid) + return res.changes > 0 +} + +/** A send was attempted and did not get an OK. Drives the resend backoff. */ +export function noteDispenseReportAttempt(txid: string, error: string | null): void { + if (!db) throw new Error('Database not initialized') + db.prepare( + 'UPDATE dispense_reports SET attempts = attempts + 1, last_attempt_at = ?, last_error = ? WHERE txid = ?' + ).run(Date.now(), error, txid) +} + /** * A counter bumped on every local change to a bay count, from any cause. * @@ -1241,6 +1342,11 @@ interface TransactionInput { rejected: number }[] error?: string | null + /** + * ADR-005 §2: dispense outcome to queue for spirekeeper. Inserted in the + * same transaction as the row so a crash between them cannot lose it. + */ + report?: DispenseReportBody } /** @@ -1262,6 +1368,12 @@ export function recordTransaction(tx: TransactionInput): void { const insertBill = db.prepare( 'INSERT INTO transaction_bills (txid, denomination, count) VALUES (?, ?, ?)' ) + // Outbox row (ADR-005 §2). REPLACE: a re-record of the same txid (should not + // happen, but a crash-replay could) refreshes the payload and resets the + // delivery state rather than failing the whole transaction. + const insertReport = db.prepare( + 'INSERT OR REPLACE INTO dispense_reports (txid, payload, created_at, attempts, last_attempt_at, last_error, acked_at) VALUES (?, ?, ?, 0, NULL, NULL, NULL)' + ) const insertCassetteBill = db.prepare( 'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)' ) @@ -1294,6 +1406,10 @@ export function recordTransaction(tx: TransactionInput): void { insertBill.run(t.txid, bill.denomination, bill.count) } + if (t.report) { + insertReport.run(t.txid, JSON.stringify(t.report), Date.now()) + } + // Insert per-cassette detail when available if (t.cassettes) { for (const c of t.cassettes) { diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 3a72f63..10d2566 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -14,7 +14,7 @@ import { NostrClient, type Signer } from '@bitSpire/nostr-client' import { resolveSigner } from './signer-resolver.js' -import { LnbitsClient } from '@bitSpire/lnbits' +import { LnbitsClient, type DispenseReportBody, type DispenseReportAck } from '@bitSpire/lnbits' import { CLINKClient } from '@bitSpire/clink' import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink' import type { ATMServices, ATMContext } from '@bitSpire/state-machine' @@ -223,6 +223,8 @@ export interface LightningBackend { } interface LightningServices { + /** ADR-005 §2: send one cash-out's dispense outcome to spirekeeper (outbox-driven). */ + reportDispense: (body: DispenseReportBody) => Promise nostrClient: NostrClient lightningPub: LightningBackend clink: CLINKClient @@ -686,6 +688,11 @@ export async function initializeLightningServices(options?: { signer, operatorPubkeys: CONFIG.operatorPubkeys, atmServices, + /** + * ADR-005 §2: send one cash-out's dispense outcome to spirekeeper. The + * store keeps these in a durable outbox and calls this until it resolves. + */ + reportDispense: (body: DispenseReportBody) => lnbits.reportDispense(body), onOfferRequest: (callback: OfferRequestCallback) => { offerRequestCallback = callback }, diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index 8131c85..4ed884f 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -1,4 +1,5 @@ import { defineStore } from 'pinia' +import type { DispenseReportBody } from '@bitSpire/lnbits' import { ref, computed, watch } from 'vue' import { createATMMachine, @@ -9,6 +10,7 @@ import { type SnapshotFrom, type ATMMachine, type AccessRole, + type DispenseCashResult, } from '@bitSpire/state-machine' import type { AccessControlConfig, CardSession } from '@/types/electron' import { initializeLightningServices, fetchBtcPrice } from '@/services/lightning' @@ -192,6 +194,62 @@ async function loadInventoryFromDb(): Promise | null> { /** * Persist a completed transaction to SQLite via IPC. */ +/** + * ADR-005 §2: the dispense outcome spirekeeper captures on. Built from the + * machine context at the moment the terminal state is entered; written in the + * same SQLite transaction as the transactions row (see TransactionRecord.report). + * `requested` per denomination comes from the sale, `dispensed`/`rejected` from + * the hardware report; `cassettes` is the per-bay record verbatim. + */ +function buildDispenseReport( + ctx: ATMContext, + dr: DispenseCashResult | null, + countsUncertain: boolean +): DispenseReportBody { + const requestedByDenom = new Map() + for (const a of ctx.dispenseAmounts) { + requestedByDenom.set(a.denomination, (requestedByDenom.get(a.denomination) ?? 0) + a.count) + } + const seen = new Set() + const bills: DispenseReportBody['bills'] = [] + for (const b of dr?.bills ?? []) { + seen.add(b.denomination) + bills.push({ + denomination: b.denomination, + requested: requestedByDenom.get(b.denomination) ?? 0, + dispensed: b.dispensed, + rejected: b.rejected, + }) + } + // A denomination that was asked for but never appears in the report (the + // dispenser threw before reporting) still needs a row: requested, zero out. + for (const [denomination, requested] of requestedByDenom) { + if (!seen.has(denomination)) bills.push({ denomination, requested, dispensed: 0, rejected: 0 }) + } + return { + txid: ctx.txid ?? '', + payment_hash: ctx.paymentHash, + tx_type: 'cash_out', + dispense_confirmed: dr?.dispenseConfirmed === true, + error: dr?.error ?? ctx.error ?? null, + error_code: dr?.errorCode ?? (ctx.error && !dr ? 'DispenseThrew' : null), + raw_code: dr?.rawCode ?? null, + error_class: dr?.errorClass ?? (ctx.error && !dr ? 'terminal' : null), + fiat_cents: ctx.fiatCents, + currency: ctx.currency, + bills, + cassettes: (dr?.cassettes ?? []).map((c) => ({ + position: c.position, + denomination: c.denomination, + provisioned: c.provisioned, + dispensed: c.dispensed, + rejected: c.rejected, + })), + counts_uncertain: countsUncertain, + at: Math.floor(Date.now() / 1000), + } +} + async function persistTransaction(tx: TransactionRecord): Promise { if (isElectron && window.electronAPI) { try { @@ -401,6 +459,12 @@ export const useAtmStore = defineStore('atm', () => { ) let stopBalanceWatch: (() => void) | null = null let pricePollingInterval: ReturnType | null = null + // ADR-005 §2 — dispense-report outbox. The function pointer is set at every + // lightning-init site; the flusher drains state.db's dispense_reports table + // to spirekeeper with backoff until each row is acked. + let reportDispenseFn: ((body: DispenseReportBody) => Promise) | null = null + let dispenseReportFlushInterval: ReturnType | null = null + let dispenseReportFlushing = false // Store reference to ATM services for direct calls let atmServicesRef: ATMServices | null = null @@ -684,6 +748,11 @@ export const useAtmStore = defineStore('atm', () => { .catch((e) => console.warn('[ATM] Could not flag counts unverified:', e)) } + const countsUncertain = + !dr || + (dr.bills.reduce((sum, b) => sum + b.dispensed, 0) === 0 && + !!dr.error && + dr.errorClass !== 'inventory') persistTransaction({ txid: ctx.txid, type: 'cash_out', @@ -697,11 +766,14 @@ export const useAtmStore = defineStore('atm', () => { bills, cassettes: dr?.cassettes, error: dr?.error ?? ctx.error, + // ADR-005 §2 — queued in the same SQLite transaction as the row. + report: buildDispenseReport(ctx, dr, countsUncertain), }) .then(() => reloadPersistedInventory()) // Republish cassette state — a partial dispense changed counts, and // the payload now carries the hold / unverified flags. .then(() => operatorConfigSvc?.publishCassettesState()) + .then(() => flushDispenseReports()) } } @@ -731,11 +803,15 @@ export const useAtmStore = defineStore('atm', () => { bills, cassettes: dr?.cassettes, error: dr?.error, + // ADR-005 §2 — the SUCCESS report is what lets spirekeeper capture + // (distribute) the settlement. Cash-out only; cash-in has no dispense. + ...(isCashInTx ? {} : { report: buildDispenseReport(ctx, dr, false) }), }) .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())) + .then(() => (isCashInTx ? undefined : flushDispenseReports())) } } @@ -1084,6 +1160,8 @@ export const useAtmStore = defineStore('atm', () => { try { const services = await initializeLightningServices({ strict: !allowMockFallback.value }) + reportDispenseFn = services.reportDispense + startDispenseReportFlusher() useLiveServices.value = true connectionStatus.value = 'connected' console.log('[ATM] Connected to Lightning.Pub!') @@ -1096,6 +1174,8 @@ export const useAtmStore = defineStore('atm', () => { services.nostrClient.on('connect', () => { connectionStatus.value = 'connected' console.log('[ATM] Relay reconnected') + // A report queued during the outage goes now, not at the next tick. + void flushDispenseReports() }) // Store references to clients for direct operations @@ -1383,6 +1463,8 @@ export const useAtmStore = defineStore('atm', () => { // Initialize Lightning services const lightning = await initializeLightningServices({ strict: !allowMockFallback.value }) + reportDispenseFn = lightning.reportDispense + startDispenseReportFlusher() useLiveServices.value = true connectionStatus.value = 'connected' lightningPub.value = lightning.lightningPub @@ -1676,6 +1758,8 @@ export const useAtmStore = defineStore('atm', () => { // Initialize Lightning services const lightning = await initializeLightningServices({ strict: !allowMockFallback.value }) + reportDispenseFn = lightning.reportDispense + startDispenseReportFlusher() useLiveServices.value = true connectionStatus.value = 'connected' lightningPub.value = lightning.lightningPub @@ -2044,7 +2128,59 @@ export const useAtmStore = defineStore('atm', () => { pricePollingInterval = setInterval(poll, 30_000) } + /** + * Drain the dispense-report outbox (ADR-005 §2). At-least-once: a row is + * acked only on an OK reply; anything else bumps `attempts` and the row is + * retried after an exponential backoff (30 s · 2^attempts, capped at 1 h). + * While spirekeeper has not registered `report_dispense` every send fails + * the same way — the backoff keeps that from being noisy, and the rows wait. + * Triggers: right after each persist, on relay (re)connect, every 60 s. + */ + async function flushDispenseReports(): Promise { + if (!isElectron || !window.electronAPI || !reportDispenseFn) return + if (dispenseReportFlushing) return + dispenseReportFlushing = true + try { + const pending = await window.electronAPI.pendingDispenseReports(20) + const now = Date.now() + for (const row of pending) { + const backoffMs = Math.min(30_000 * 2 ** row.attempts, 3_600_000) + if (row.lastAttemptAt && row.lastAttemptAt + backoffMs > now) continue + try { + await reportDispenseFn(row.payload as DispenseReportBody) + await window.electronAPI.ackDispenseReport(row.txid) + console.log(`[ATM] Dispense report delivered: ${row.txid}`) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + await window.electronAPI.noteDispenseReportAttempt(row.txid, msg) + console.warn( + `[ATM] Dispense report ${row.txid} not delivered (attempt ${row.attempts + 1}): ${msg}` + ) + } + } + } catch (e) { + console.warn('[ATM] Dispense-report flush failed:', e) + } finally { + dispenseReportFlushing = false + } + } + + function startDispenseReportFlusher() { + if (dispenseReportFlushInterval) return + dispenseReportFlushInterval = setInterval(() => void flushDispenseReports(), 60_000) + void flushDispenseReports() + } + + function stopDispenseReportFlusher() { + if (dispenseReportFlushInterval) { + clearInterval(dispenseReportFlushInterval) + dispenseReportFlushInterval = null + } + } + function stopPricePolling() { + // Both are store-lifetime intervals; whoever stops one stops the other. + stopDispenseReportFlusher() if (pricePollingInterval) { clearInterval(pricePollingInterval) pricePollingInterval = null diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 9c54e75..f3b4db5 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -164,6 +164,19 @@ declare global { since: number }) => Promise<{ reason: string; errorCode: string | null; rawCode: string | null; since: number }> clearCashOutHold: () => Promise + // Dispense-report outbox (ADR-005 §2) + pendingDispenseReports: (limit?: number) => Promise< + Array<{ + txid: string + payload: unknown + createdAt: number + attempts: number + lastAttemptAt: number | null + lastError: string | null + }> + > + ackDispenseReport: (txid: string) => Promise + noteDispenseReportAttempt: (txid: string, error: string | null) => Promise markStatePublished: (unixTimestamp: number) => Promise saveBunkerBinding: (binding: BunkerBindingRecord) => Promise clearBunkerBinding: () => Promise diff --git a/apps/machine/src/types/state.ts b/apps/machine/src/types/state.ts index 7d0d9c3..9b780c3 100644 --- a/apps/machine/src/types/state.ts +++ b/apps/machine/src/types/state.ts @@ -34,6 +34,12 @@ export interface TransactionRecord { }[] error?: string | null remediatedBy?: string | null + /** + * ADR-005 §2: the dispense outcome to queue for spirekeeper, written in + * the same SQLite transaction as the row so a crash between the two cannot + * lose it. Cash-out only. Shape is @bitSpire/lnbits DispenseReportBody. + */ + report?: import('@bitSpire/lnbits').DispenseReportBody } export interface ATMAvailability { From 65d19f4f1ec573862ffafe63e6e50dadaebf7808 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 22:01:44 +0200 Subject: [PATCH 160/164] fix(nix): pnpmDeps hash for the current lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The atm-app's pnpm store is a fixed-output derivation keyed by this hash. pnpm-lock.yaml changed twice today (the dead-script dependency prune in 763817b, then qrcode declared where fund-atm.ts uses it in cbff654) and the hash was not updated, so nix reused the stale store and the sandboxed `pnpm install --offline` failed on @types/qrcode (ERR_PNPM_NO_OFFLINE_TARBALL). That is what sintra's nightly upgrade and the cachix push were dying on. Rule to carry forward: any commit that touches pnpm-lock.yaml must re-derive this hash (blank it, build, paste the `got:` value) — the local `pnpm build` passing says nothing about the nix build. --- nix/mkAtmApp.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/mkAtmApp.nix b/nix/mkAtmApp.nix index c0ef9cb..48f249d 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-XqpQpFL3PqnFltb4ujAmmnnV0LOqTHKV/bo3riFu9pY="; + hash = "sha256-HIMjQx0REauknZkIU50M4KMU0lyNQ41UG6AUM/3Hn54="; }; nativeBuildInputs = [ From 9b0788092514e3cd3439784c5398db8b72d2851f Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 22:08:48 +0200 Subject: [PATCH 161/164] fix(deploy): the .env migration called sed and grep by bare name on the activation PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NixOS activation scripts run with a minimal PATH that has coreutils but not gnugrep or gnused. On sintra the snippet printed its success line and then failed with "sed: command not found" (127) — the keys were never renamed, the new app booted on fallback config, and switch-to- configuration exited 2. Both binaries are now referenced by store path. --- deploy/nixos/configuration.nix | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/deploy/nixos/configuration.nix b/deploy/nixos/configuration.nix index f016179..20e5742 100644 --- a/deploy/nixos/configuration.nix +++ b/deploy/nixos/configuration.nix @@ -488,8 +488,12 @@ in system.activationScripts.bitspire-env-migration = { deps = [ "users" ]; text = '' - if [ -f /var/lib/bitspire/.env ] && grep -q '^VITE_LAMASSU_' /var/lib/bitspire/.env; then - sed -i 's/^VITE_LAMASSU_/VITE_BITSPIRE_/' /var/lib/bitspire/.env + # Activation scripts run with a minimal PATH (coreutils, not gnugrep / + # gnused) — the first run of this snippet died with "sed: command not + # found" after printing success, so reference both by store path. + if [ -f /var/lib/bitspire/.env ] \ + && ${pkgs.gnugrep}/bin/grep -q '^VITE_LAMASSU_' /var/lib/bitspire/.env; then + ${pkgs.gnused}/bin/sed -i 's/^VITE_LAMASSU_/VITE_BITSPIRE_/' /var/lib/bitspire/.env echo "bitspire: migrated VITE_LAMASSU_* keys in /var/lib/bitspire/.env" fi ''; From 23bd54738d2a8d6f175d80293518d3183fe0870b Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 22:09:39 +0200 Subject: [PATCH 162/164] docs(claude): sintra's nightly also fails on the timeout; the push-cache rule, the pnpm hash rule, the activation PATH rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fleet table called sintra a working dev unit; its nixos-upgrade had failed every night 2026-10-06 → 10-09 on the same 60 s atm-app timeout as batm3, so nothing merged that week reached it. Recorded with the interim rule — push-cache after every dev push, because mkAtmApp's src = self makes any commit invalidate the cached toplevel — and the two traps found while applying it: a lockfile change silently reuses a stale pnpmDeps store unless the hash is re-derived, and activation scripts don't have grep/sed on PATH. --- CLAUDE.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 31c8393..387e108 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ bitSpire is an independent project under AGPL-3.0 and is not affiliated with Lam | Machine | Reachable | Stack | GPU | Notes | |---|---|---|---|---| -| `sintra` | LAN `192.168.0.252` | dev / LNbits | Braswell `8086:22b0` → crocus | dev unit; ethernet `r8169` | +| `sintra` | LAN `192.168.0.252` | dev / LNbits | Braswell `8086:22b0` → crocus | dev unit; ethernet `r8169`. **Its nightly upgrade failed every night 2026-10-06 → 10-09** on the same 60 s timeout as batm3 (below); nothing merged that week reached it until a cachix push on 10-10 | | `batm3` | wg `10.0.0.5` | dev / LNbits | Haswell GT2 `8086:0412` → crocus | **networks over WiFi**, `iwlwifi` 7260; ethernet down | | `douro` | **down** | — | Bay Trail (Gen7) | needs reflashing with the current image and reconnecting to WireGuard | | `tejo` | wg `10.0.0.3` | **Debian** (`ubilinux4`, kernel 4.9) | Braswell `8086:22b0` | never had bitspire installed; a flake target, not a deployment | @@ -71,9 +71,9 @@ iris — sintra's Braswell does so despite being Gen8. And batm3's only working network path is Intel WiFi, so `intel/iwlwifi` firmware is load-bearing there; trimming it would strand the machine with no way back in. -### batm3's nightly upgrade is currently FAILING +### The nightly upgrade fails on any machine that has to build the app (batm3, and sintra too) -Confirmed 2026-09-24. The run dies at: +Confirmed on batm3 2026-09-24 and on sintra 2026-10-10 (failing since 10-06). The run dies at: ``` 04:03:26 building '…-bitspire-atm-app-0.1.0.drv'... @@ -91,6 +91,25 @@ merged to `dev` reaches it. This is the same class of silent-updater failure as #98, in a new form. The fix is pushing `atm-app-*` to the aiolabs cachix as part of releasing, not raising the timeout — a 60s ceiling on ATM hardware is correct. +**Interim rule (2026-10-10): every push to `dev` is followed by +`./deploy/push-cache.sh sintra && ./deploy/push-cache.sh batm3` from bohm** +(cachix is authenticated there). `mkAtmApp` takes `src = self` — the whole flake +tree — so *any* commit, docs included, changes the app derivation and a cached +toplevel no longer matches what `?ref=dev` resolves to. Three more things that +bit on 10-10: + +- **`pnpm-lock.yaml` changes require re-deriving `pnpmDeps.hash` in + `nix/mkAtmApp.nix`** (blank it, build, paste the `got:` value). Nix reuses the + stale fixed-output store otherwise and the sandboxed `pnpm install --offline` + fails with `ERR_PNPM_NO_OFFLINE_TARBALL`. A passing local `pnpm build` says + nothing about the nix build. +- **Activation scripts run with a minimal PATH** — coreutils yes, `grep`/`sed` + no. Reference `${pkgs.gnugrep}/bin/grep` / `${pkgs.gnused}/bin/sed` by store + path; the `.env` migration printed success and then died 127. +- `nixos-rebuild switch --flake .#-installed --target-host --sudo + --use-substitutes` from bohm is the fast manual path once the cache has the + toplevel: store hit here, closure copied, nothing built on the UP board. + ## Architecture ``` From 651c43d7b070cecf686a059a8679d8f9174b00d2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 22:27:39 +0200 Subject: [PATCH 163/164] feat(machine): apply the settle_transaction operator op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-005 §6: the operator paid the customer by hand and recorded it in spirekeeper. The op carries the txid and the note; the machine flips its own dispense_error/partial row to remediated via remediateTransaction, which only touches rows still in an error state, so re-delivery is a no-op. Closes the machine side of the ledger for an owed-cash sale without dispensing anything. --- apps/machine/src/services/operator-config.ts | 33 ++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts index e9f38b9..549719f 100644 --- a/apps/machine/src/services/operator-config.ts +++ b/apps/machine/src/services/operator-config.ts @@ -65,7 +65,21 @@ type CassetteOp = { * machine" gesture and already clears counts-uncertain. */ type ResumeCashOutOp = { id: string; at: number; type: 'resume_cash_out' } -type OperatorOp = CassetteOp | ResumeCashOutOp +/** + * ADR-005 §6: the operator paid the customer by hand, off-machine, for a + * cash-out this machine recorded as dispense_error / partial. Flips that row + * to `remediated` with the note as provenance so both ledgers close on one + * act. Idempotent by nature — remediateTransaction only touches rows still + * in an error state — so re-delivery is harmless. + */ +type SettleTransactionOp = { + id: string + at: number + type: 'settle_transaction' + txid: string + note?: string +} +type OperatorOp = CassetteOp | ResumeCashOutOp | SettleTransactionOp /** Accept operator events stamped up to this many seconds in the future. */ const MAX_FUTURE_SKEW_S = 60 @@ -249,7 +263,22 @@ async function handleOperatorConfigEvent( const resumeOps = allOps.filter( (o): o is ResumeCashOutOp => !!o && o.type === 'resume_cash_out' ) - const ops = allOps.filter((o): o is CassetteOp => !!o && o.type !== 'resume_cash_out') + const settleOps = allOps.filter( + (o): o is SettleTransactionOp => + !!o && o.type === 'settle_transaction' && typeof (o as SettleTransactionOp).txid === 'string' + ) + for (const op of settleOps) { + const provenance = `settled-off-machine:${op.id}${op.note ? `:${op.note}` : ''}` + const changed = await api.remediateTransaction(op.txid, provenance) + console.log( + `[OperatorConfig] settle_transaction ${op.id} for ${op.txid}: ` + + (changed ? 'row marked remediated' : 'no row in an error state (already closed, or unknown)') + ) + } + const ops = allOps.filter( + (o): o is CassetteOp => + !!o && o.type !== 'resume_cash_out' && o.type !== 'settle_transaction' + ) if (holdBefore && resumeOps.some((o) => typeof o.at === 'number' && o.at > holdBefore.since)) { await api.clearCashOutHold() console.log( From 5a4f70c90ea52ff06b720d81e772903f5566bda7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 10 Oct 2026 22:27:39 +0200 Subject: [PATCH 164/164] docs(adr-005): record the NIP-17 alert and the settle-cash-owed path as built --- docs/adr/005-cash-out-dispense-outcome.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/adr/005-cash-out-dispense-outcome.md b/docs/adr/005-cash-out-dispense-outcome.md index 22bc03a..121616a 100644 --- a/docs/adr/005-cash-out-dispense-outcome.md +++ b/docs/adr/005-cash-out-dispense-outcome.md @@ -246,9 +246,13 @@ rather than pausing it. Server: `cash_owed` and `dispense_unreported` are two new buckets on `StuckSettlementsResponse`. They are the only buckets whose meaning is *a customer is owed -money*, and they render first. Arrival in either bucket triggers the operator notification -path (whatever `notifyOperator` equivalent spirekeeper grows; at minimum the dashboard banner -— but the push is the point, and it belongs in the same transaction that writes the row). +money*, and they render first. Arrival in `cash_owed` or `partial_pending` sends the operator +a **NIP-17 gift-wrapped DM** (kind 14 → 13 → 1059) to their own LNbits-account pubkey, or to +`super_config.alerts_pubkey` when set — a note to self any NIP-46 client renders. It is signed +through the operator's signer (no key at rest), is best-effort (a failed publish is logged and +the report is still acked — the worklist is the durable record), and sets +`operator_notified_at` so a report resend never re-alerts. Not email, not NIP-04. +*(Implemented: spirekeeper `notify.py`, slice 2.)* Resolution closes **both** ledgers: @@ -256,11 +260,13 @@ Resolution closes **both** ledgers: to `remediated` via `remediateTransaction`. The machine sends a `report_dispense` for the remediation with `remediates_txid`, and the server moves the settlement from `cash_owed` to `pending` and distributes. -- **Off-machine settlement.** The operator paid the customer by hand. A new `settle_cash_owed` - operator action records provenance (free text, author, time) on the settlement, moves it to - `pending`, and publishes a `settle_transaction { txid, note }` operator op; the machine - applies it by setting `remediated_by` to the note and `status = 'remediated'`. Today there is - no way to record this at all, and the machine's ledger asserts the debt forever. +- **Off-machine settlement.** The operator paid the customer by hand. + `POST /settlements/{id}/settle-cash-owed` records provenance (free text, author, time) on the + settlement, moves it to `pending` and distributes **at the full amount** (the customer is + whole), and publishes a machine-wide `settle_transaction { id, at, txid, note }` operator op; + the machine applies it through `remediateTransaction(txid, "settled-off-machine::")`, + which only touches rows still in an error state, so re-delivery is harmless. Before slice 2 + there was no way to record this at all, and the machine's ledger asserted the debt forever. `PartialDispenseData` is pre-filled from the report's `bills` so the operator confirms a number the hardware produced rather than typing one.