diff --git a/CLAUDE.md b/CLAUDE.md index 20ba0df..1b2e8a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,9 +15,11 @@ Core principles: ## Provenance + legal status -The HAL drivers (validators / dispensers / printers) and the cash-flow state machine derive from Lamassu Industries AG's open-source `lamassu-machine` and `lamassu-server` repositories, **only up to v8.1.5** — the last release published under a fully-open license. Lamassu transitioned to a proprietary, source-available license (their custom "Appendix A SLA") on 2024-01-26 and gated v8.1.6+ behind a paid OSA subscription. +The HAL drivers (validators / dispensers / printers) and the cash-flow state machine derive from Lamassu Industries AG's `lamassu-machine` repository, **only up to commit `c0b69d1ed196d396c5f057478c2ea290babd58ab`** ("chore: v8.6.0-beta.9", 2023-09-19) — the last commit published into the public domain (`UNLICENSE` in tree). The very next commit, `a9234d124d` ("chore: add LICENSE (#1019)", 2023-09-19), removed `UNLICENSE` and added Lamassu's proprietary "Appendix A SLA". **The `v8.1.5` tag (2023-09-21) already ships the Appendix A license** — the previously documented "8.1.5 is the open boundary" was wrong (verified against GitHub history 2026-07-04). Note the public-domain boundary sits on the 8.6-beta line, which is *further along* than 8.1.5 feature-wise. -**Hard rule when working in this repo:** do not pull, port, or copy code from lamassu-machine / lamassu-server at v8.1.6 or later. If a HAL bug fix or feature exists upstream past 8.1.5, either (a) reimplement from protocol docs / hardware specs without looking at v8.1.6+ source, or (b) raise the question with the maintainer first. The 8.1.5 tree is fair game; everything after is licensed code we have no rights to. +**Hard rule when working in this repo:** do not pull, port, or copy lamassu-machine code from `a9234d124d` or later (which includes every 8.1.5+ tag). Reference only `c0b69d1` or earlier. If a HAL bug fix or feature exists upstream past that commit, either (a) reimplement from protocol docs / hardware specs without looking at the licensed source, or (b) raise the question with the maintainer first. To fetch the open tree safely: `git fetch --depth 1 origin c0b69d1ed196d396c5f057478c2ea290babd58ab` — never check out a tag. + +The `lamassu-server` boundary has not been re-verified against its own history and may differ — check its license-change commit before referencing it. bitSpire is an independent project under AGPL-3.0 and is not affiliated with Lamassu Industries AG. @@ -82,13 +84,34 @@ 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_ATM_PRIVATE_KEY` | yes (prod) | 64-char hex. The ATM's nostr identity. Generates ephemeral on first boot if unset (dev only) | +| `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 | 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 @@ -188,7 +211,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..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: @@ -36,16 +40,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/__tests__/state-store-bunker.test.ts b/apps/machine/electron/__tests__/state-store-bunker.test.ts new file mode 100644 index 0000000..a668059 --- /dev/null +++ b/apps/machine/electron/__tests__/state-store-bunker.test.ts @@ -0,0 +1,69 @@ +/** + * Tests for bunker-binding persistence in state-store (aiolabs/bitspire#52, + * transport config added in #70). + * + * Validates the round-trip of the binding singleton, including the v11→v12 + * transport columns (relays JSON + lnbits_server_pubkey) and their absence on + * a pre-#70 binding. + * + * Uses an in-memory SQLite database — fresh per test, no on-disk artifacts. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + clearBunkerBinding, + closeDatabase, + getBunkerBinding, + initDatabase, + saveBunkerBinding, + type StoredBunkerBinding, +} from '../state-store.js' + +const BASE: StoredBunkerBinding = { + clientSecretHex: 'aa'.repeat(32), + spirePubkey: 'bb'.repeat(32), + bunkerUrl: 'bunker://bb?relay=wss%3A%2F%2Fr%2F&secret=deadbeef', + seedFingerprint: 'cc'.repeat(32), + pairedAt: 1_780_000_000, +} + +beforeEach(() => { + initDatabase(':memory:') +}) +afterEach(() => { + closeDatabase() +}) + +describe('bunker binding persistence', () => { + it('round-trips a binding carrying transport config (#70)', () => { + const binding: StoredBunkerBinding = { + ...BASE, + relays: ['wss://one.relay/', 'wss://two.relay/'], + lnbitsServerPubkey: 'dd'.repeat(32), + } + saveBunkerBinding(binding) + expect(getBunkerBinding()).toEqual(binding) + }) + + it('round-trips a pre-#70 binding (no transport config) as undefined fields', () => { + saveBunkerBinding(BASE) + const got = getBunkerBinding() + expect(got).toEqual(BASE) + expect(got?.relays).toBeUndefined() + expect(got?.lnbitsServerPubkey).toBeUndefined() + }) + + it('upserts transport config in place (re-pair overwrites)', () => { + saveBunkerBinding({ ...BASE, relays: ['wss://old/'], lnbitsServerPubkey: 'ee'.repeat(32) }) + saveBunkerBinding({ ...BASE, relays: ['wss://new/'], lnbitsServerPubkey: 'ff'.repeat(32) }) + const got = getBunkerBinding() + expect(got?.relays).toEqual(['wss://new/']) + expect(got?.lnbitsServerPubkey).toBe('ff'.repeat(32)) + }) + + it('returns null after clear', () => { + saveBunkerBinding(BASE) + clearBunkerBinding() + expect(getBunkerBinding()).toBeNull() + }) +}) diff --git a/apps/machine/electron/__tests__/state-store-transactions.test.ts b/apps/machine/electron/__tests__/state-store-transactions.test.ts new file mode 100644 index 0000000..fbb9a87 --- /dev/null +++ b/apps/machine/electron/__tests__/state-store-transactions.test.ts @@ -0,0 +1,170 @@ +/** + * Tests for recordTransaction inventory accounting. + * + * Regression coverage for the position-vs-denomination decrement bug: + * position is the cassettes PK (v9) and duplicate denominations across + * bays are legal, so cash-out decrements MUST address bays by position. + * A denomination-keyed UPDATE would drain every matching bay at once. + * + * Uses an in-memory SQLite database — fresh per test, no on-disk + * artifacts, no parallel-test interference. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + closeDatabase, + getCashbox, + initDatabase, + loadCassettes, + recordTransaction, + setCassettes, +} from '../state-store.js' + +const TX_BASE = { + fiatCents: 4000, + sats: 100_000, + feeSats: 5_000, + feeFraction: 0.05, + exchangeRate: 2500, + currency: 'USD', +} + +/** Two $20 bays plus one $50 bay — the duplicate-denomination layout. */ +function seedDuplicateDenomBays() { + setCassettes([ + { position: 1, denomination: 20, count: 50 }, + { position: 2, denomination: 20, count: 50 }, + { position: 3, denomination: 50, count: 30 }, + ]) +} + +function countsByPosition(): Record { + const out: Record = {} + for (const row of loadCassettes()) out[row.position] = row.count + return out +} + +beforeEach(() => { + initDatabase(':memory:') + seedDuplicateDenomBays() +}) +afterEach(() => { + closeDatabase() +}) + +describe('state-store: recordTransaction cash_out inventory', () => { + it('decrements only the bay that actually dispensed (duplicate denominations)', () => { + recordTransaction({ + ...TX_BASE, + txid: 'tx-single-bay', + type: 'cash_out', + status: 'complete', + bills: [{ denomination: 20, count: 3 }], + cassettes: [ + { + name: 'cassette1', + position: 1, + denomination: 20, + provisioned: 3, + dispensed: 3, + rejected: 0, + }, + { + name: 'cassette2', + position: 2, + denomination: 20, + provisioned: 0, + dispensed: 0, + rejected: 0, + }, + ], + }) + + expect(countsByPosition()).toEqual({ 1: 47, 2: 50, 3: 30 }) + }) + + it('decrements each bay by its own dispensed count on a split dispense', () => { + recordTransaction({ + ...TX_BASE, + txid: 'tx-split-bays', + type: 'cash_out', + status: 'complete', + bills: [{ denomination: 20, count: 60 }], + cassettes: [ + { + name: 'cassette1', + position: 1, + denomination: 20, + provisioned: 50, + dispensed: 50, + rejected: 0, + }, + { + name: 'cassette2', + position: 2, + denomination: 20, + provisioned: 10, + dispensed: 10, + rejected: 0, + }, + ], + }) + + expect(countsByPosition()).toEqual({ 1: 0, 2: 40, 3: 30 }) + }) + + it('fallback without cassette results drains matching bays greedily by position', () => { + recordTransaction({ + ...TX_BASE, + txid: 'tx-fallback', + type: 'cash_out', + status: 'complete', + bills: [{ denomination: 20, count: 60 }], + }) + + // Bay 1 (50 bills) drains fully, bay 2 covers the remaining 10. + expect(countsByPosition()).toEqual({ 1: 0, 2: 40, 3: 30 }) + }) + + it('never drives a bay count below zero', () => { + recordTransaction({ + ...TX_BASE, + txid: 'tx-overdispense', + type: 'cash_out', + status: 'complete', + bills: [{ denomination: 50, count: 35 }], + cassettes: [ + { + name: 'cassette3', + position: 3, + denomination: 50, + provisioned: 35, + dispensed: 35, + rejected: 0, + }, + ], + }) + + expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 0 }) + }) +}) + +describe('state-store: recordTransaction cash_in cashbox', () => { + it('adds inserted bills to the cashbox and leaves cassettes untouched', () => { + recordTransaction({ + ...TX_BASE, + txid: 'tx-cash-in', + type: 'cash_in', + status: 'complete', + bills: [ + { denomination: 20, count: 2 }, + { denomination: 50, count: 1 }, + ], + }) + + const cashbox = getCashbox() + expect(cashbox.totalBills).toBe(3) + expect(cashbox.totalFiatCents).toBe(TX_BASE.fiatCents) + expect(countsByPosition()).toEqual({ 1: 50, 2: 50, 3: 30 }) + }) +}) diff --git a/apps/machine/electron/fund-atm.ts b/apps/machine/electron/fund-atm.ts index ce8b1e0..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, 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,19 +63,38 @@ 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 identity = 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 }], - identity, + signer, }) await nostrClient.connect() @@ -76,7 +102,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/electron/hal-service.ts b/apps/machine/electron/hal-service.ts index 27508b0..5453fea 100644 --- a/apps/machine/electron/hal-service.ts +++ b/apps/machine/electron/hal-service.ts @@ -36,6 +36,11 @@ export interface HalConfig { export interface ValidatorCallbacks { shouldAcceptBill: (denomination: number) => boolean | 'hold' onBillRead?: (denomination: number) => void + /** + * Fires on the validator's stacked-confirmation (`billsValid`) — the + * bill physically reached the stacker. This is the CREDIT event; it is + * NOT emitted at stack-command time (a stack can still fail/return). + */ onBillInserted: (denomination: number) => void onBillRejected: (reason: string) => void onError: (error: string) => void @@ -82,19 +87,40 @@ export async function initializeHal(config: HalConfig): Promise { const { validator: valConfig, dispenser: dispConfig } = config - // Create hardware instances - const dispenser: BillDispenser = hal.createDispenser(dispConfig.type, { - device: dispConfig.device, - }) + // Start dispenser (optional — mirrors the validator handling below). + // + // A cash-in-only machine is a legitimate configuration: the Raspberry Pi + // reference build has a bill acceptor and no dispenser at all. This used to + // create and init the dispenser unconditionally, so a missing device threw + // and aborted the WHOLE of initializeHal — taking the validator with it, + // even though the validator was present and working. The Pi bring-up hit + // exactly that: "cannot open /dev/ttyDispenser-not-fitted", then an endless + // renderer-reload loop, with a perfectly good acceptor on ttyValidator0. + // + // The validator has been optional since it was written; the asymmetry was + // the bug. + let dispenser: BillDispenser | null = null - // Initialize dispenser. `dispenserInitData` is `let` because - // `setCassettes` swaps it in to re-init with a new layout (also used by - // the on-error re-init path at dispenseCash). + // `dispenserInitData` is `let` because `setCassettes` swaps it in to re-init + // with a new layout (also used by the on-error re-init path at dispenseCash). let dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes: dispConfig.cassettes, } - await dispenser.init(dispenserInitData) + + try { + const fs = await import('node:fs') + if (dispConfig.device && fs.existsSync(dispConfig.device)) { + dispenser = hal.createDispenser(dispConfig.type, { device: dispConfig.device }) + await dispenser.init(dispenserInitData) + console.log('[HAL] Dispenser started') + } else { + console.log('[HAL] Dispenser device not found, running cash-in only') + } + } catch (err) { + console.warn('[HAL] Dispenser failed to start, running cash-in only:', err) + dispenser = null + } console.log('[HAL] Dispenser initialized') // Start validator (optional — proceed without if device is missing or fails) @@ -142,6 +168,14 @@ export async function initializeHal(config: HalConfig): Promise { count: c.count ?? 0, })) + // Escrow / in-flight bookkeeping (legacy brain.js `billsRead` interlock): + // `escrowDenomination` = bill held in escrow awaiting a stack/reject + // decision; `inFlightDenomination` = stack commanded, awaiting the + // validator's `billsValid` stacked-confirmation. onBillInserted (the + // credit event) fires only on that confirmation. + let escrowDenomination: number | null = null + let inFlightDenomination: number | null = null + return { connectValidator: (callbacks: ValidatorCallbacks) => { if (!validator) { @@ -153,10 +187,11 @@ export async function initializeHal(config: HalConfig): Promise { const decision = callbacks.shouldAcceptBill(data.denomination) if (decision === 'hold') { console.log('[HAL] Bill in escrow:', data.denomination) + escrowDenomination = data.denomination callbacks.onBillRead?.(data.denomination) } else if (decision) { + inFlightDenomination = data.denomination validator.stack() - callbacks.onBillInserted(data.denomination) } else { console.log('[HAL] Bill rejected: insufficient balance for', data.denomination) validator.reject() @@ -168,7 +203,23 @@ export async function initializeHal(config: HalConfig): Promise { } }) + // Stacked-confirmation → the credit event. + validator.on('billsValid', () => { + if (inFlightDenomination === null) { + console.warn('[HAL] billsValid with no bill in flight — ignoring') + return + } + const denomination = inFlightDenomination + inFlightDenomination = null + console.log('[HAL] Bill stacked (confirmed):', denomination) + callbacks.onBillInserted(denomination) + }) + validator.on('billsRejected', (data?: { reason: string; code: number | null }) => { + // Covers both an escrow refusal and a failed/returned stack — + // either way nothing was credited and nothing is in flight. + escrowDenomination = null + inFlightDenomination = null callbacks.onBillRejected(data?.reason ?? 'unknown') }) @@ -191,16 +242,47 @@ export async function initializeHal(config: HalConfig): Promise { }, disableValidator: () => { + // If a note is sitting in escrow when we disable (inactivity timeout, + // cancel, or leaving the insert screen), return it to the customer. + // Disabling alone does NOT release an escrowed note on EBDS — it would + // be stranded in the transport until the next power cycle. + if (escrowDenomination !== null) { + console.log('[HAL] Returning escrowed bill on disable:', escrowDenomination) + escrowDenomination = null + validator?.reject() + } validator?.disable() validator?.lightOff() }, - stackBill: () => validator?.stack(), - rejectBill: () => validator?.reject(), + stackBill: () => { + if (escrowDenomination === null) { + console.warn('[HAL] stackBill with no bill in escrow — ignoring') + return + } + inFlightDenomination = escrowDenomination + escrowDenomination = null + validator?.stack() + }, + rejectBill: () => { + escrowDenomination = null + validator?.reject() + }, dispenseCash: async (amounts): Promise => { console.log('[HAL] Dispensing:', amounts) + // Cash-in-only machine: refuse the ask rather than throwing a null + // dereference into the renderer's dispense path. + if (!dispenser) { + return { + bills: [], + cassettes: [], + dispensed: false, + error: 'No dispenser fitted on this machine — cash-out unavailable', + } + } + // Re-initialize dispenser if it was closed after a previous error if (!dispenser.initialized) { console.log('[HAL] Dispenser not initialized, re-initializing...') @@ -341,6 +423,12 @@ export async function initializeHal(config: HalConfig): Promise { count: c.count ?? 0, })) dispenserInitData = { fiatCode: valConfig.fiatCode, cassettes } + // Without a dispenser the layout is still worth recording (the operator + // config consumer keeps calling this), but there is nothing to re-init. + if (!dispenser) { + console.log('[HAL] Cassettes recorded; no dispenser fitted, nothing to re-init') + return + } // Close + re-init the dispenser so its internal per-bay state matches // the new layout. Errors here surface to the caller (operator-config // consumer) — the renderer can decide whether to retry. @@ -357,7 +445,7 @@ export async function initializeHal(config: HalConfig): Promise { return new Promise((resolve) => { validator?.disable() validator?.lightOff() - dispenser.close() + dispenser?.close() if (validator) { validator.close((err?: Error) => { if (err) console.error('[HAL] Validator close error:', err) diff --git a/apps/machine/electron/lnurl-pay.test.ts b/apps/machine/electron/lnurl-pay.test.ts new file mode 100644 index 0000000..165688f --- /dev/null +++ b/apps/machine/electron/lnurl-pay.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi } from 'vitest' +import { resolveCardInvoice, scanUrlToResolver, lnAddressToLnurlp } from './lnurl-pay' + +const LNURLW = + 'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788' +const BOLT11 = 'lnbc10u1p3xyz...' + +/** Mock fetch that returns the given JSON bodies per call, in order. */ +function mockFetch(bodies: unknown[]) { + const calls: string[] = [] + const impl = vi.fn(async (url: string | URL) => { + calls.push(url.toString()) + const body = bodies[calls.length - 1] + return { json: async () => body } as Response + }) + return { impl: impl as unknown as typeof fetch, calls } +} + +describe('scanUrlToResolver', () => { + it('rewrites /scan/ to /pay/ and preserves p + c', () => { + const r = scanUrlToResolver(LNURLW) + expect(r).toContain('https://lnbits.l484.com/boltcards/api/v1/pay/abc123') + expect(r).toContain('p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF') + expect(r).toContain('c=1122334455667788') + }) + it('returns null for a non-scan URL', () => { + expect(scanUrlToResolver('lnurlw://host/somethingelse?p=1&c=2')).toBeNull() + expect(scanUrlToResolver('http://host/boltcards/api/v1/scan/x')).toBeNull() + }) +}) + +describe('lnAddressToLnurlp', () => { + it('maps name@host to the well-known lnurlp URL', () => { + expect(lnAddressToLnurlp('cardname@l484.com')).toBe( + 'https://l484.com/.well-known/lnurlp/cardname' + ) + }) + it('rejects non-addresses', () => { + expect(lnAddressToLnurlp('not-an-address')).toBeNull() + expect(lnAddressToLnurlp('')).toBeNull() + }) +}) + +describe('resolveCardInvoice', () => { + const payReq = { + tag: 'payRequest', + callback: 'https://lnbits.l484.com/lnurlp/api/v1/lnurl/cb', + minSendable: 1000, + maxSendable: 100_000_000, + metadata: '[["text/plain","bolt card top-up"]]', + } + + it('resolver returns a payRequest inline → fetches the invoice', async () => { + const { impl, calls } = mockFetch([payReq, { pr: BOLT11 }]) + const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl }) + expect(res).toEqual({ ok: true, bolt11: BOLT11 }) + // 1st call = the /pay resolver; 2nd = the callback with amount in msat. + expect(calls[0]).toContain('/boltcards/api/v1/pay/abc123') + expect(calls[1]).toContain('amount=21000') + }) + + it('resolver returns a Lightning Address → LUD-16 → invoice', async () => { + const { impl, calls } = mockFetch([ + { lightningAddress: 'cardname@l484.com' }, + payReq, + { pr: BOLT11 }, + ]) + const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl }) + expect(res).toEqual({ ok: true, bolt11: BOLT11 }) + expect(calls[1]).toBe('https://l484.com/.well-known/lnurlp/cardname') + expect(calls[2]).toContain('amount=21000') + }) + + it('rejects a non-lnurlw tag', async () => { + const { impl } = mockFetch([]) + const res = await resolveCardInvoice('http://nope', 21_000, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false }) + expect(res.reason).toMatch(/not a valid Bolt Card/i) + }) + + it('rejects a zero amount', async () => { + const { impl } = mockFetch([]) + const res = await resolveCardInvoice(LNURLW, 0, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'no amount to send' }) + }) + + it('surfaces an ERROR from the resolver (bad SUN)', async () => { + const { impl } = mockFetch([{ status: 'ERROR', reason: 'invalid card' }]) + const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'invalid card' }) + }) + + it('rejects (without calling the callback) when the amount exceeds maxSendable', async () => { + const { impl, calls } = mockFetch([{ ...payReq, maxSendable: 5000 }]) + const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'amount is above the card wallet maximum' }) + expect(calls).toHaveLength(1) // callback never hit + }) + + it('surfaces an ERROR from the pay callback', async () => { + const { impl } = mockFetch([payReq, { status: 'ERROR', reason: 'wallet frozen' }]) + const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'wallet frozen' }) + }) + + it('rejects when the card wallet has no receive address', async () => { + const { impl } = mockFetch([{ foo: 'bar' }]) + const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'card wallet has no receive address' }) + }) + + it('handles a network failure gracefully', async () => { + const impl = vi.fn(async () => { + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + const res = await resolveCardInvoice(LNURLW, 21_000, { fetchImpl: impl }) + expect(res.ok).toBe(false) + expect(res.reason).toMatch(/could not reach the card/i) + }) +}) diff --git a/apps/machine/electron/lnurl-pay.ts b/apps/machine/electron/lnurl-pay.ts new file mode 100644 index 0000000..91da90c --- /dev/null +++ b/apps/machine/electron/lnurl-pay.ts @@ -0,0 +1,226 @@ +/** + * LNURL-pay resolver (LUD-06 / LUD-16) — the ATM as the *paying* party. + * + * Bolt Card tap-to-RECEIVE for the cash-in (buy) flow. A Bolt Card only ever + * emits its `lnurlw://…?p=…&c=…` voucher — a *withdraw* (spend) credential — so + * we can't push sats into it directly. Instead the tap is used as an + * authenticated identity (external_id + SUN p/c) to look up the card wallet's + * *pay* target, then the ATM fetches an invoice for the payout amount: + * 1. resolveCardPayTarget — GET the boltcards `/pay/?p=&c=` resolver + * (a sibling of `/scan`); it verifies the same SUN and returns the card + * wallet's Lightning Address / lnurlp (or a LUD-06 payRequest directly). + * 2. toPayRequest → LUD-16 (Lightning Address) or LUD-06 fetch → payRequest. + * 3. requestInvoice — GET `callback?amount=` → a BOLT11 for the amount. + * The returned BOLT11 is handed back to the renderer, which pays it over the + * ATM's existing LNbits/nostr transport (stores/atm.ts `payInvoice`), so + * settlement + PAYMENT_RECEIVED reuse the tested cash-in completion path. + * + * Runs in the MAIN process (Node fetch) to avoid renderer CORS, exactly like + * lnurl-withdraw.ts. + * + * Transport seam: `resolveCardPayTarget()` is the single HTTPS-today / + * Nostr-tomorrow swap point. The rest is standard LNURL-pay against whatever + * pay target it returns and is transport-independent. + */ + +import { lnurlwToHttps } from './lnurl-withdraw.js' + +export interface ResolveCardInvoiceResult { + ok: boolean + /** BOLT11 to pay when ok; the renderer settles it over the nostr transport. */ + bolt11?: string + /** Human-readable reason when ok is false (safe to surface on-screen). */ + reason?: string +} + +type FetchLike = typeof fetch + +export interface ResolveCardInvoiceOptions { + /** Injected for tests; defaults to global fetch. */ + fetchImpl?: FetchLike + /** Per-request timeout (default 15s). */ + timeoutMs?: number +} + +/** Resolver response — any of these shapes is accepted (see the spec doc). */ +interface CardPayTarget { + status?: string + reason?: string + // (a) a LUD-06 payRequest, inline + tag?: string + callback?: string + minSendable?: number + maxSendable?: number + metadata?: string + // (b) a Lightning Address, e.g. "cardname@l484.com" + lightningAddress?: string + // (c) an lnurlp pointer (https or lnurl://) + lnurlp?: string + lnurl?: string +} + +/** LUD-06 payRequest (subset) + error shape. */ +interface PayRequest { + tag?: string + callback?: string + minSendable?: number + maxSendable?: number + metadata?: string + status?: string + reason?: string +} + +/** LUD-06 second-response (the callback body). */ +interface PayValues { + pr?: string + status?: string + reason?: string +} + +interface Ctx { + doFetch: FetchLike + timeoutMs: number +} + +function errMsg(e: unknown): string { + if (e instanceof Error) + return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message + return String(e) +} + +function appendQuery(url: string, params: Record): string { + const u = new URL(url) + for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v) + return u.toString() +} + +/** + * Derive the boltcards *pay* resolver URL from a tapped card's `lnurlw`. + * The card presents `…/boltcards/api/v1/scan/?p=&c=` (a withdraw voucher); + * the receive resolver is its sibling `…/boltcards/api/v1/pay/?p=&c=`, + * carrying the same SUN p/c. This is the HTTPS transport seam — a future + * nostr-native card would resolve the same identity over nostr instead. + */ +export function scanUrlToResolver(lnurlw: string): string | null { + const https = lnurlwToHttps(lnurlw) + if (!https) return null + const u = new URL(https) + if (!u.pathname.includes('/scan/')) return null + u.pathname = u.pathname.replace('/scan/', '/pay/') + return u.toString() +} + +/** LUD-16: map a Lightning Address `name@host` to its lnurlp URL. */ +export function lnAddressToLnurlp(addr: string): string | null { + const m = addr.trim().match(/^([a-z0-9._%+-]+)@([a-z0-9.-]+)$/i) + if (!m) return null + return `https://${m[2]}/.well-known/lnurlp/${m[1]}` +} + +async function fetchPayRequest( + url: string, + ctx: Ctx +): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> { + let body: PayRequest + try { + const res = await ctx.doFetch(url, { signal: AbortSignal.timeout(ctx.timeoutMs) }) + body = (await res.json()) as PayRequest + } catch (e) { + return { ok: false, reason: `could not reach the card wallet: ${errMsg(e)}` } + } + if (body.status === 'ERROR') { + return { ok: false, reason: body.reason || 'card wallet rejected the request' } + } + if (body.tag !== 'payRequest' || !body.callback) { + return { ok: false, reason: 'card wallet did not return a pay request' } + } + return { ok: true, payRequest: body } +} + +/** Turn a resolver response into a LUD-06 payRequest (fetching if needed). */ +async function toPayRequest( + target: CardPayTarget, + ctx: Ctx +): Promise<{ ok: true; payRequest: PayRequest } | { ok: false; reason: string }> { + // (a) resolver returned a LUD-06 payRequest inline. + if (target.tag === 'payRequest' && target.callback) { + return { ok: true, payRequest: target } + } + // (b) resolver returned a Lightning Address (the common case here). + if (typeof target.lightningAddress === 'string') { + const url = lnAddressToLnurlp(target.lightningAddress) + if (!url) return { ok: false, reason: 'card wallet address is invalid' } + return fetchPayRequest(url, ctx) + } + // (c) resolver returned an lnurlp pointer. + const pointer = target.lnurlp ?? target.lnurl + if (typeof pointer === 'string') { + const url = lnurlwToHttps(pointer) + if (!url) return { ok: false, reason: 'card wallet lnurlp is invalid' } + return fetchPayRequest(url, ctx) + } + return { ok: false, reason: 'card wallet has no receive address' } +} + +async function requestInvoice( + pr: PayRequest, + amountMsat: number, + ctx: Ctx +): Promise { + if (typeof pr.minSendable === 'number' && amountMsat < pr.minSendable) { + return { ok: false, reason: 'amount is below the card wallet minimum' } + } + if (typeof pr.maxSendable === 'number' && amountMsat > pr.maxSendable) { + return { ok: false, reason: 'amount is above the card wallet maximum' } + } + const cbUrl = appendQuery(pr.callback!, { amount: String(amountMsat) }) + let vals: PayValues + try { + const res = await ctx.doFetch(cbUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) }) + vals = (await res.json()) as PayValues + } catch (e) { + return { ok: false, reason: `could not fetch the invoice: ${errMsg(e)}` } + } + if (vals.status === 'ERROR') { + return { ok: false, reason: vals.reason || 'card wallet declined' } + } + if (!vals.pr || !/^ln[a-z0-9]/i.test(vals.pr.trim())) { + return { ok: false, reason: 'card wallet returned no invoice' } + } + return { ok: true, bolt11: vals.pr.trim() } +} + +/** + * Resolve a tapped Bolt Card + a payout amount to a BOLT11 the ATM can pay. + * Never throws — every failure returns `{ ok: false, reason }`. + */ +export async function resolveCardInvoice( + lnurlw: string, + amountMsat: number, + opts: ResolveCardInvoiceOptions = {} +): Promise { + const ctx: Ctx = { doFetch: opts.fetchImpl ?? fetch, timeoutMs: opts.timeoutMs ?? 15_000 } + + const resolverUrl = scanUrlToResolver(lnurlw) + if (!resolverUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' } + if (!(amountMsat > 0)) return { ok: false, reason: 'no amount to send' } + + // 1) Resolve card → pay target (the transport seam: HTTPS today). + let target: CardPayTarget + try { + const res = await ctx.doFetch(resolverUrl, { signal: AbortSignal.timeout(ctx.timeoutMs) }) + target = (await res.json()) as CardPayTarget + } catch (e) { + return { ok: false, reason: `could not reach the card: ${errMsg(e)}` } + } + if (target.status === 'ERROR') { + return { ok: false, reason: target.reason || 'card rejected the tap' } + } + + // 2) Normalize to a LUD-06 payRequest. + const pr = await toPayRequest(target, ctx) + if (!pr.ok) return pr + + // 3) Ask for an invoice for the payout amount. + return requestInvoice(pr.payRequest, amountMsat, ctx) +} diff --git a/apps/machine/electron/lnurl-withdraw.test.ts b/apps/machine/electron/lnurl-withdraw.test.ts new file mode 100644 index 0000000..4d14611 --- /dev/null +++ b/apps/machine/electron/lnurl-withdraw.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from 'vitest' +import { executeLnurlWithdraw, lnurlwToHttps } from './lnurl-withdraw' + +const BOLT11 = 'lnbc10u1p3xyz...' +const LNURLW = + 'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788' + +/** Build a mock fetch that returns the given JSON bodies per call, in order. */ +function mockFetch(bodies: unknown[]) { + const calls: string[] = [] + const impl = vi.fn(async (url: string | URL) => { + calls.push(url.toString()) + const body = bodies[calls.length - 1] + return { json: async () => body } as Response + }) + return { impl: impl as unknown as typeof fetch, calls } +} + +describe('lnurlwToHttps', () => { + it('maps lnurlw:// and lnurl:// to https://', () => { + expect(lnurlwToHttps('lnurlw://host/p?x=1')).toBe('https://host/p?x=1') + expect(lnurlwToHttps('lnurl://host/p')).toBe('https://host/p') + }) + it('strips a lightning: prefix', () => { + expect(lnurlwToHttps('lightning:lnurlw://host/p')).toBe('https://host/p') + }) + it('passes https:// through and trims', () => { + expect(lnurlwToHttps(' https://host/p ')).toBe('https://host/p') + }) + it('rejects http://, bech32 lnurl1…, and empty', () => { + expect(lnurlwToHttps('http://host/p')).toBeNull() + expect(lnurlwToHttps('LNURL1DP68GURN8GHJ7')).toBeNull() + expect(lnurlwToHttps('')).toBeNull() + }) +}) + +describe('executeLnurlWithdraw', () => { + const withdrawReq = { + tag: 'withdrawRequest', + callback: 'https://lnbits.l484.com/boltcards/api/v1/scan/cb', + k1: 'K1TOKEN', + minWithdrawable: 1000, + maxWithdrawable: 5_000_000, + } + + it('completes the two-step withdraw and passes k1 + pr to the callback', async () => { + const { impl, calls } = mockFetch([withdrawReq, { status: 'OK' }]) + const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl }) + expect(res).toEqual({ ok: true }) + // First call = the lnurlw as https; second = callback with k1 + pr. + expect(calls[0]).toContain('https://lnbits.l484.com/boltcards/api/v1/scan/abc123') + expect(calls[1]).toContain('k1=K1TOKEN') + expect(calls[1]).toContain(`pr=${encodeURIComponent(BOLT11)}`) + }) + + it('rejects a non-lnurlw tag', async () => { + const { impl } = mockFetch([]) + const res = await executeLnurlWithdraw('http://nope', BOLT11, { fetchImpl: impl }) + expect(res.ok).toBe(false) + expect(res.reason).toMatch(/not a valid Bolt Card/i) + }) + + it('rejects when there is no invoice', async () => { + const { impl } = mockFetch([]) + const res = await executeLnurlWithdraw(LNURLW, '', { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'no invoice to charge' }) + }) + + it('surfaces an ERROR from the withdraw request', async () => { + const { impl } = mockFetch([{ status: 'ERROR', reason: 'spent today limit' }]) + const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'spent today limit' }) + }) + + it('rejects a response that is not a withdrawRequest', async () => { + const { impl } = mockFetch([{ tag: 'payRequest', callback: 'x' }]) + const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false }) + expect(res.reason).toMatch(/withdraw voucher/i) + }) + + it('rejects (without calling the callback) when the amount exceeds the card limit', async () => { + const { impl, calls } = mockFetch([{ ...withdrawReq, maxWithdrawable: 2000 }]) + const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl, amountMsat: 5000 }) + expect(res).toMatchObject({ ok: false, reason: 'card limit is below this amount' }) + expect(calls).toHaveLength(1) // callback never hit + }) + + it('surfaces an ERROR from the callback (card declined)', async () => { + const { impl } = mockFetch([withdrawReq, { status: 'ERROR', reason: 'insufficient funds' }]) + const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl }) + expect(res).toMatchObject({ ok: false, reason: 'insufficient funds' }) + }) + + it('handles a network failure gracefully', async () => { + const impl = vi.fn(async () => { + throw new Error('ECONNREFUSED') + }) as unknown as typeof fetch + const res = await executeLnurlWithdraw(LNURLW, BOLT11, { fetchImpl: impl }) + expect(res.ok).toBe(false) + expect(res.reason).toMatch(/could not reach the card/i) + }) +}) diff --git a/apps/machine/electron/lnurl-withdraw.ts b/apps/machine/electron/lnurl-withdraw.ts new file mode 100644 index 0000000..eb93c08 --- /dev/null +++ b/apps/machine/electron/lnurl-withdraw.ts @@ -0,0 +1,127 @@ +/** + * LNURL-withdraw executor (LUD-03) — the ATM as the *withdrawing* party. + * + * Bolt Card tap-to-pay for the cash-out flow: a Bolt Card presents an + * `lnurlw://…?p=…&c=…` voucher (NTAG424 SUN — fresh p/c per tap). The ATM has + * already generated its cash-out BOLT11; here it asks the card's wallet to pay + * that invoice: + * 1. GET the lnurlw URL → a `withdrawRequest` (callback, k1, max/min). + * 2. GET `callback?k1=…&pr=` → the card's wallet pays it. + * Settlement itself is observed elsewhere (the existing invoice watcher over + * nostr), so a returned `{ ok: true }` means "the card accepted the pull", not + * "cash dispensed" — the state machine still waits for PAYMENT_RECEIVED. + * + * Runs in the MAIN process (Node fetch) to avoid renderer CORS: LNURL + * endpoints don't send CORS headers, so a renderer fetch to the card's host + * would be blocked. + */ + +export interface LnurlWithdrawResult { + ok: boolean + /** Human-readable reason when ok is false (safe to surface on-screen). */ + reason?: string +} + +/** LUD-03 withdrawRequest (subset we consume) + LUD-06 error shape. */ +interface WithdrawRequest { + tag?: string + callback?: string + k1?: string + minWithdrawable?: number + maxWithdrawable?: number + defaultDescription?: string + status?: string + reason?: string +} + +type FetchLike = typeof fetch + +export interface ExecuteLnurlWithdrawOptions { + /** Injected for tests; defaults to global fetch. */ + fetchImpl?: FetchLike + /** + * Our invoice amount in millisats. When set, we reject early if it exceeds + * the voucher's maxWithdrawable (defensive; the callback would reject anyway). + */ + amountMsat?: number + /** Per-request timeout (default 15s). */ + timeoutMs?: number +} + +/** + * Normalize a Bolt Card / LNURL-withdraw pointer to an https URL. + * Bolt Cards emit `lnurlw://host/path?query`; we also accept `lnurl://` and a + * bare `https://`. Bech32 `LNURL1…` is intentionally unsupported (Bolt Cards + * never use it) and rejected with a clear reason. + */ +export function lnurlwToHttps(raw: string): string | null { + let s = raw.trim() + if (!s) return null + if (s.toLowerCase().startsWith('lightning:')) s = s.slice('lightning:'.length) + const lower = s.toLowerCase() + if (lower.startsWith('lnurlw://')) return 'https://' + s.slice('lnurlw://'.length) + if (lower.startsWith('lnurl://')) return 'https://' + s.slice('lnurl://'.length) + if (lower.startsWith('https://')) return s + // Reject http:// (must be TLS) and bech32 lnurl1… (not a Bolt Card). + return null +} + +function appendQuery(url: string, params: Record): string { + const u = new URL(url) + for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v) + return u.toString() +} + +function errMsg(e: unknown): string { + if (e instanceof Error) return e.name === 'TimeoutError' || e.name === 'AbortError' ? 'timed out' : e.message + return String(e) +} + +export async function executeLnurlWithdraw( + lnurlw: string, + bolt11: string, + opts: ExecuteLnurlWithdrawOptions = {} +): Promise { + const doFetch = opts.fetchImpl ?? fetch + const timeoutMs = opts.timeoutMs ?? 15_000 + + const paramsUrl = lnurlwToHttps(lnurlw) + if (!paramsUrl) return { ok: false, reason: 'not a valid Bolt Card (lnurlw) tag' } + if (!bolt11 || !/^ln[a-z0-9]/i.test(bolt11.trim())) { + return { ok: false, reason: 'no invoice to charge' } + } + + // 1) Fetch the withdraw request. + let params: WithdrawRequest + try { + const res = await doFetch(paramsUrl, { signal: AbortSignal.timeout(timeoutMs) }) + params = (await res.json()) as WithdrawRequest + } catch (e) { + return { ok: false, reason: `could not reach the card: ${errMsg(e)}` } + } + if (params.status === 'ERROR') { + return { ok: false, reason: params.reason || 'card rejected the tap' } + } + if (params.tag !== 'withdrawRequest' || !params.callback || !params.k1) { + return { ok: false, reason: 'card did not return a withdraw voucher' } + } + if ( + opts.amountMsat != null && + typeof params.maxWithdrawable === 'number' && + opts.amountMsat > params.maxWithdrawable + ) { + return { ok: false, reason: 'card limit is below this amount' } + } + + // 2) Hand our invoice to the callback — the card's wallet pays it. + const cbUrl = appendQuery(params.callback, { k1: params.k1, pr: bolt11.trim() }) + let cb: { status?: string; reason?: string } + try { + const res = await doFetch(cbUrl, { signal: AbortSignal.timeout(timeoutMs) }) + cb = (await res.json()) as { status?: string; reason?: string } + } catch (e) { + return { ok: false, reason: `card payment failed: ${errMsg(e)}` } + } + if (cb.status === 'OK') return { ok: true } + return { ok: false, reason: cb.reason || 'card declined the payment' } +} diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index 59cbcd5..6cfcb20 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -26,16 +26,25 @@ import { getLastKnownConfigCreatedAt, getBootstrapPublishedAt, markBootstrapPublished, + resetBootstrapGate, + resetForRepair, 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' +import { executeLnurlWithdraw } from './lnurl-withdraw.js' +import { resolveCardInvoice } from './lnurl-pay.js' +import { startNfcReader, type NfcStatus } from './nfc-service.js' // ESM equivalent of __dirname const __filename = fileURLToPath(import.meta.url) @@ -81,16 +90,6 @@ type BrandingConfig = { logoDarkDataUrl: string | null } -const VALID_THEMES = new Set([ - 'gruvbox', - 'catppuccin', - 'cyberpunk', - 'dracula', - 'nord', - 'tokyo-night', - 'custom', -]) - function loadBranding(): BrandingConfig | null { const brandingDir = path.join( fs.existsSync('/var/lib/bitspire') ? '/var/lib/bitspire' : process.cwd(), @@ -110,7 +109,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( @@ -119,9 +121,7 @@ function loadBranding(): BrandingConfig | null { if (Object.keys(colors).length > 0) customColors = colors if (dark && typeof dark === 'object') { const darkColors = Object.fromEntries( - Object.entries(dark as Record).filter( - ([, v]) => typeof v === 'string' - ) + Object.entries(dark as Record).filter(([, v]) => typeof v === 'string') ) as Record if (Object.keys(darkColors).length > 0) customColorsDark = darkColors } @@ -280,8 +280,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 || '', @@ -330,14 +333,117 @@ 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() +}) +ipcMain.handle('state:reset-for-repair', (): void => { + resetForRepair() +}) + +// QR-pairing wizard (aiolabs/bitspire#52): an unpaired machine scans a +// spire-seed off its camera, and we persist it as VITE_SPIRE_SEED in the +// 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) +}) + +// Connectivity recovery: reload the renderer to re-run init from a clean slate +// (fresh JS context → no leaked actors/subscriptions), while preserving HAL in +// this main process (reloadRenderer resets secretsConsumed so get-atm-secrets +// works again, and hal:init is idempotent). The renderer calls this when it's +// stuck on a connectivity-type "ATM Unavailable" and the network returns, or +// when the operator taps the on-screen Retry (ADR-002 amendment 2026-08-04). +ipcMain.handle('app:recover', (): void => { + console.log('[Recovery] Reloading renderer to re-attempt initialization') + reloadRenderer() +}) + +// Bolt Card cash-out: pull payment for the current invoice from a tapped card +// via LNURL-withdraw. Runs in the main process (Node fetch) to dodge renderer +// CORS. Returns once the card accepts; settlement arrives via the invoice +// watcher. See lnurl-withdraw.ts. +ipcMain.handle( + 'lnurl:withdraw', + async ( + _event, + args: { lnurlw: string; bolt11: string; amountMsat?: number } + ): Promise<{ ok: boolean; reason?: string }> => { + return executeLnurlWithdraw(args.lnurlw, args.bolt11, { amountMsat: args.amountMsat }) + } +) + +// Bolt Card cash-in (receive): resolve a tapped card + payout amount to a +// BOLT11 on the card wallet, which the renderer then pays over the nostr +// transport (stores/atm.ts payInvoice). HTTPS to the card host runs here in the +// main process to dodge renderer CORS. See lnurl-pay.ts. +ipcMain.handle( + 'lnurl:pay-card', + async ( + _event, + args: { lnurlw: string; amountMsat: number } + ): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => { + return resolveCardInvoice(args.lnurlw, args.amountMsat) + } +) + // State persistence IPC handlers ipcMain.handle('state:load-cassettes', () => loadCassettes()) ipcMain.handle('state:set-cassettes', (_event, cassettes) => setCassettes(cassettes)) @@ -354,9 +460,7 @@ ipcMain.handle('state:remediate-transaction', (_event, txid: string, remediatedB ipcMain.handle('state:get-last-known-config-created-at', (): number => getLastKnownConfigCreatedAt() ) -ipcMain.handle('state:get-bootstrap-published-at', (): number | null => - getBootstrapPublishedAt() -) +ipcMain.handle('state:get-bootstrap-published-at', (): number | null => getBootstrapPublishedAt()) ipcMain.handle('state:mark-bootstrap-published', (_event, unixTimestamp: number): void => { markBootstrapPublished(unixTimestamp) }) @@ -414,6 +518,17 @@ let pendingBillDenomination: number | null = null ipcMain.handle('hal:init', async (_event, config) => { try { + // Idempotent: HAL lives in this (long-lived) main process, but the renderer + // re-runs full init on every reload — the watchdog's crash-recovery reload + // and the connectivity-recovery reload (app:recover) both re-invoke this. + // initializeHal opens serial ports without closing prior handles, so + // re-entering it would double-open the validator/dispenser. Reuse the + // existing instance instead; its validator event wiring already targets the + // (reloaded) mainWindow, so the reloaded renderer keeps receiving bill events. + if (halInstance) { + console.log('[Electron] HAL already initialized — reusing existing instance') + return { success: true } + } // Override cassette config with DB values (operator may have changed them via atm-tui // or via an operator-config publish from satmachineadmin). Pass per-position so the // HAL knows about every bay including duplicates of the same denomination — real @@ -519,10 +634,12 @@ ipcMain.handle('hal:stack-bill', () => { console.warn('[Electron] hal:stack-bill called with no bill in escrow — ignoring') return } - const denomination = pendingBillDenomination pendingBillDenomination = null + // Credit is NOT sent here. hal-service fires onBillInserted (forwarded + // as 'hal:bill-inserted') only on the validator's `billsValid` + // stacked-confirmation — a stack command can still fail or return the + // bill (aiolabs/bitspire#58). halInstance.stackBill() - mainWindow?.webContents.send('hal:bill-inserted', denomination) }) ipcMain.handle('hal:reject-bill', () => { @@ -723,6 +840,23 @@ app.whenReady().then(() => { startWatchdog() startCommandPoller() + // Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Fully + // best-effort: if the reader/pcscd is absent it just reports 'unavailable' + // and the cash-out QR path is unaffected. + void startNfcReader( + (lnurlw) => { + // Don't log the value — it carries the card's single-use SUN p/c. + console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`) + mainWindow?.webContents.send('nfc:card-tapped', lnurlw) + }, + (status: NfcStatus) => { + console.log( + `[NFC] status=${status.state}${status.reader ? ` reader="${status.reader}"` : ''}${status.message ? ` — ${status.message}` : ''}` + ) + mainWindow?.webContents.send('nfc:status', status) + } + ) + app.on('activate', () => { // macOS: re-create window when dock icon clicked if (BrowserWindow.getAllWindows().length === 0) { diff --git a/apps/machine/electron/nfc-service.test.ts b/apps/machine/electron/nfc-service.test.ts new file mode 100644 index 0000000..f694758 --- /dev/null +++ b/apps/machine/electron/nfc-service.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from 'vitest' +import { extractLnurlw, readNdefLnurlw } from './nfc-service' + +const LNURLW = + 'lnurlw://lnbits.l484.com/boltcards/api/v1/scan/abc123?p=DEADBEEFDEADBEEFDEADBEEFDEADBEEF&c=1122334455667788' + +/** Build a Type-4 NDEF message with a single URI record carrying `uri`. */ +function ndefUriMessage(uri: string): Buffer { + const uriBytes = Buffer.from(uri, 'ascii') + const payload = Buffer.concat([Buffer.from([0x00]), uriBytes]) // 0x00 = no prefix + // D1 = MB|ME|SR, TNF=well-known; type length 1; payload length; 'U' + return Buffer.concat([Buffer.from([0xd1, 0x01, payload.length, 0x55]), payload]) +} + +describe('extractLnurlw', () => { + it('pulls an lnurlw:// URI out of an NDEF record', () => { + expect(extractLnurlw(ndefUriMessage(LNURLW))).toBe(LNURLW) + }) + it('pulls a boltcards https scan URL', () => { + const https = 'https://lnbits.l484.com/boltcards/api/v1/scan/x?p=aa&c=bb' + expect(extractLnurlw(ndefUriMessage(https))).toBe(https) + }) + it('stops at the record boundary (no trailing binary)', () => { + const msg = Buffer.concat([ndefUriMessage(LNURLW), Buffer.from([0x00, 0xfe, 0x01])]) + expect(extractLnurlw(msg)).toBe(LNURLW) + }) + it('returns null when there is no lnurl', () => { + expect(extractLnurlw(Buffer.from('just some text', 'ascii'))).toBeNull() + }) +}) + +describe('readNdefLnurlw', () => { + const SW_OK = Buffer.from([0x90, 0x00]) + const SW_NOTFOUND = Buffer.from([0x6a, 0x82]) + // Capability Container advertising the NDEF file id E104 (TLV 04 06 at [7,8]). + const CC = Buffer.from([ + 0x00, 0x0f, 0x20, 0x00, 0x3b, 0x00, 0x34, 0x04, 0x06, 0xe1, 0x04, 0x00, 0xff, 0x00, 0xff, + ]) + + /** Route APDUs by content so the CC-read + fallback loop is exercised. */ + function cardMock(opts: { noApp?: boolean; nlen0?: boolean; uri?: string } = {}) { + const msg = ndefUriMessage(opts.uri ?? LNURLW) + const nlen = msg.length + return vi.fn(async (apdu: Buffer) => { + const hex = apdu.toString('hex') + if (hex.includes('d2760000850101')) return opts.noApp ? SW_NOTFOUND : SW_OK // select app + if (hex.startsWith('00a4000c02e103')) return SW_OK // select CC + if (hex.startsWith('00b000000f')) return Buffer.concat([CC, SW_OK]) // read CC + if (hex.startsWith('00a4000c02e104')) return SW_OK // select NDEF file (E104) + if (hex.startsWith('00a4000c020004')) return SW_NOTFOUND // fallback file id: absent + if (hex.startsWith('00b0000002')) + return opts.nlen0 + ? Buffer.concat([Buffer.from([0x00, 0x00]), SW_OK]) + : Buffer.concat([Buffer.from([(nlen >> 8) & 0xff, nlen & 0xff]), SW_OK]) // NLEN + if (hex.startsWith('00b0')) return Buffer.concat([msg, SW_OK]) // read message + return SW_NOTFOUND + }) + } + + it('reads CC → NDEF file (E104) and returns the lnurlw', async () => { + const transmit = cardMock() + expect(await readNdefLnurlw(transmit)).toBe(LNURLW) + // First APDU selects the NDEF application (AID D2760000850101). + expect((transmit.mock.calls[0][0] as Buffer).toString('hex')).toContain('d2760000850101') + }) + + it('returns null if selecting the NDEF app fails', async () => { + expect(await readNdefLnurlw(cardMock({ noApp: true }))).toBeNull() + }) + + it('returns null on an empty NDEF file', async () => { + expect(await readNdefLnurlw(cardMock({ nlen0: true }))).toBeNull() + }) +}) diff --git a/apps/machine/electron/nfc-service.ts b/apps/machine/electron/nfc-service.ts new file mode 100644 index 0000000..e94ddaf --- /dev/null +++ b/apps/machine/electron/nfc-service.ts @@ -0,0 +1,250 @@ +/** + * NFC reader driver (main process) for Bolt Card tap-to-pay. + * + * Wraps `nfc-pcsc` (PC/SC via the Feitian KP382 CCID reader). On each card + * tap it reads the NTAG424 Type-4 NDEF file over ISO7816 APDUs and extracts + * the `lnurlw://…?p=…&c=…` voucher (the card computes fresh SUN p/c per tap), + * then hands it to the renderer over IPC. The renderer, when showing a + * cash-out invoice, pays it via LNURL-withdraw (see lnurl-withdraw.ts). + * + * Everything here is best-effort and lazy: `nfc-pcsc` is a native addon, so it + * is dynamically imported and every failure is swallowed into a status + * callback. If the reader/library is absent, NFC is simply unavailable and the + * QR path keeps working — cash-out never depends on this. + */ + +import { execFile } from 'node:child_process' + +export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable' +export interface NfcStatus { + state: NfcState + reader?: string + message?: string +} + +type CardHandler = (lnurlw: string) => void +type StatusHandler = (status: NfcStatus) => void + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e) +} + +/** Pull the lnurlw (or a boltcards https scan URL) out of a Type-4 NDEF blob. */ +export function extractLnurlw(ndef: Buffer): string | null { + // Robust to record framing: the URI record embeds the literal string; grab + // it directly, bounded to URL-safe characters so we stop at the record end. + const text = ndef.toString('latin1') + const urlChars = "[A-Za-z0-9._~:/?#\\[\\]@!$&'()*+,;=%-]+" + const m = + text.match(new RegExp('lnurlw://' + urlChars, 'i')) || + text.match(new RegExp('https://' + urlChars + '/boltcards/' + urlChars, 'i')) + return m ? m[0] : null +} + +const swOk = (r: Buffer) => r.length >= 2 && r[r.length - 2] === 0x90 && r[r.length - 1] === 0x00 + +/** Select an EF by its 2-byte file id and read + parse its NDEF message. */ +async function readNdefFile( + send: (bytes: number[]) => Promise, + fid: [number, number] +): Promise { + if (!swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, fid[0], fid[1]]))) return null + // 2-byte NLEN header at offset 0. + const lenResp = await send([0x00, 0xb0, 0x00, 0x00, 0x02]) + if (!swOk(lenResp)) return null + const nlen = (lenResp[0] << 8) | lenResp[1] + if (nlen <= 0 || nlen > 0x2000) return null + // NDEF message starts at offset 2; read in <=250-byte chunks. + const chunks: Buffer[] = [] + let offset = 2 + let remaining = nlen + while (remaining > 0) { + const toRead = Math.min(remaining, 0xfa) + const resp = await send([0x00, 0xb0, (offset >> 8) & 0xff, offset & 0xff, toRead]) + if (!swOk(resp)) break + const data = resp.subarray(0, resp.length - 2) + if (data.length === 0) break + chunks.push(data) + offset += data.length + remaining -= data.length + } + return extractLnurlw(Buffer.concat(chunks)) +} + +/** + * Read the NDEF of a Type-4 tag and return the extracted lnurlw, or null. + * `transmit(apdu, maxLen) => Buffer` including the trailing SW1 SW2. + * + * Select the NDEF Tag Application, read the Capability Container to learn the + * real NDEF FileID (NTAG424 Bolt Cards use E104, not the 0004 some tags use), + * then read that file. Falls back to E104/0004 if the CC read is unavailable. + */ +export async function readNdefLnurlw( + transmit: (apdu: Buffer, maxLen: number) => Promise +): Promise { + const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256) + + // Select the NDEF Tag Application (AID D2760000850101). + if ( + !swOk( + await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]) + ) + ) { + return null + } + + // NTAG424 Bolt Cards use NDEF FileID E104. Try it (and 0004) directly to + // minimise APDU round-trips over a flaky RF link; only fall back to reading + // the Capability Container to discover the id if both direct reads fail. + for (const fid of [[0xe1, 0x04] as [number, number], [0x00, 0x04] as [number, number]]) { + const found = await readNdefFile(send, fid) + if (found) return found + } + if (swOk(await send([0x00, 0xa4, 0x00, 0x0c, 0x02, 0xe1, 0x03]))) { + const cc = await send([0x00, 0xb0, 0x00, 0x00, 0x0f]) + // CC layout: …[07]=TLV tag 0x04, [08]=len, [09..10]=NDEF FileID. + if (swOk(cc) && cc.length >= 13 && cc[7] === 0x04) { + const found = await readNdefFile(send, [cc[9], cc[10]]) + if (found) return found + } + } + return null +} + +let stopFn: (() => void) | null = null + +// ── Wedge auto-recovery ─────────────────────────────────────────────────── +// Cheap CCID readers (the Feitian R502-CL especially) occasionally wedge: they +// keep detecting a card but every APDU returns "card absent or mute", and ONLY +// a USB power-cycle clears it — pcscd/app restarts do NOT. When we see a run of +// consecutive read failures we trigger nfc-reader-reset.service (a root oneshot +// that re-binds the reader's USB device = a software replug); nfc-pcsc then +// re-detects the reader on hotplug with no app restart. The trigger is gated by +// a cooldown so a still-wedged reader can't reset-loop. A quality reader (e.g. +// ACR1252U) wedges far less; this is belt-and-suspenders for any reader. +const WEDGE_FAILURE_THRESHOLD = 3 +const RESET_COOLDOWN_MS = 30_000 +// Persist across reader re-enumerations (a reset spawns a fresh reader closure). +let lastReaderResetAt = 0 + +/** Trigger the privileged USB power-cycle of the reader. Best-effort. */ +function resetWedgedReader(): void { + // NixOS: the app runs unprivileged as `bitspire`; a polkit rule authorises it + // to start this one unit. systemctl lives at a stable path on the device. + execFile('/run/current-system/sw/bin/systemctl', ['start', 'nfc-reader-reset.service'], () => { + /* best-effort — if it fails the reader stays wedged until a manual reset */ + }) +} + +/** + * Start listening for Bolt Card taps. Idempotent. Returns a stop function. + * Never throws — failures surface via onStatus. + */ +export async function startNfcReader( + onCard: CardHandler, + onStatus: StatusHandler +): Promise<() => void> { + if (stopFn) return stopFn + + let mod: unknown + try { + // Non-literal specifier: nfc-pcsc ships no types; keep it `any` to tsc + // while resolving normally at runtime. + const pkg = 'nfc-pcsc' + mod = (await import(pkg)) as unknown + } catch (e) { + onStatus({ state: 'unavailable', message: `NFC library unavailable: ${errMsg(e)}` }) + return () => {} + } + const NFC = + (mod as { NFC?: unknown }).NFC ?? (mod as { default?: { NFC?: unknown } }).default?.NFC + if (typeof NFC !== 'function') { + onStatus({ state: 'unavailable', message: 'NFC library has no NFC export' }) + return () => {} + } + + let nfc: { on: (e: string, cb: (...a: unknown[]) => void) => void; close?: () => void } + try { + nfc = new (NFC as new () => typeof nfc)() + } catch (e) { + onStatus({ state: 'unavailable', message: `NFC init failed: ${errMsg(e)}` }) + return () => {} + } + + nfc.on('reader', (reader: unknown) => { + const r = reader as { + name?: string + reader?: { name?: string } + autoProcessing?: boolean + on: (e: string, cb: (...a: unknown[]) => void) => void + transmit: (data: Buffer, maxLen: number) => Promise + } + const name = r.name ?? r.reader?.name ?? 'reader' + // We do our own NDEF APDU read, not nfc-pcsc's UID auto-processing. + r.autoProcessing = false + onStatus({ state: 'ready', reader: name }) + + // Cooldown after a failed read: these cheap CCID readers can get wedged into + // a present↔empty storm when hammered, so ignore re-detections for a beat + // after a failure. Successful reads don't cool down. + let cooldownUntil = 0 + // Consecutive failed reads → wedge detection (see resetWedgedReader above). + // A completed read (Bolt Card or not) proves the reader is healthy and + // clears the count; only a run of thrown transmits trips the reset. + let consecutiveFailures = 0 + r.on('card', async () => { + if (Date.now() < cooldownUntil) return + onStatus({ state: 'reading', reader: name }) + // Single attempt: retrying hammers a flaky RF link. A read is a few APDU + // round-trips; if the card shifts mid-read the transmit fails and the + // user simply re-taps. + try { + const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen)) + consecutiveFailures = 0 + if (lnurlw) { + onCard(lnurlw) + return + } + onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' }) + } catch (e) { + consecutiveFailures++ + if ( + consecutiveFailures >= WEDGE_FAILURE_THRESHOLD && + Date.now() - lastReaderResetAt > RESET_COOLDOWN_MS + ) { + // Reader looks wedged — auto power-cycle it (only fix that works). + lastReaderResetAt = Date.now() + consecutiveFailures = 0 + onStatus({ state: 'error', reader: name, message: 'reader stuck — auto-resetting…' }) + resetWedgedReader() + } else { + onStatus({ + state: 'error', + reader: name, + message: 'card read failed — hold steady & retap', + }) + } + void e + } + cooldownUntil = Date.now() + 1500 + }) + r.on('card.off', () => onStatus({ state: 'card-removed', reader: name })) + r.on('error', (err: unknown) => + onStatus({ state: 'error', reader: name, message: errMsg(err) }) + ) + r.on('end', () => + onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' }) + ) + }) + nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) })) + + stopFn = () => { + try { + nfc.close?.() + } catch { + /* idempotent */ + } + stopFn = null + } + return stopFn +} diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index 836b98d..8ced2d5 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 @@ -42,13 +38,29 @@ 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 + /** LNbits transport relays from the seed (#70); absent on pre-#70 bindings. */ + relays?: string[] + /** LNbits nostr-transport server pubkey (hex) from the seed (#70). */ + lnbitsServerPubkey?: string +} + /** * 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,35 @@ 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'), + resetForRepair: (): Promise => ipcRenderer.invoke('state:reset-for-repair'), + + // QR-pairing wizard (aiolabs/bitspire#52): persist a scanned spire-seed, + // then relaunch so the normal boot flow pairs it. + saveSpireSeed: (seed: string): Promise => ipcRenderer.invoke('state:save-spire-seed', seed), + relaunchApp: (): Promise => ipcRenderer.invoke('app:relaunch'), + // Reload the renderer to re-attempt initialization (connectivity recovery). + recoverApp: (): Promise => ipcRenderer.invoke('app:recover'), + + // Bolt Card cash-out: pull payment for the current invoice from a tapped card. + lnurlWithdraw: (args: { + lnurlw: string + bolt11: string + amountMsat?: number + }): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('lnurl:withdraw', args), + + // Bolt Card cash-in: resolve a tapped card + amount to a BOLT11 to pay. + resolveCardInvoice: (args: { + lnurlw: string + amountMsat: number + }): Promise<{ ok: boolean; bolt11?: string; reason?: string }> => + ipcRenderer.invoke('lnurl:pay-card', args), + applyOperatorCassettesConfig: ( payload: { positions: Record @@ -161,6 +202,20 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('hal:error', (_event, error) => callback(error)) }, + // Bolt Card reader (main process → renderer). removeAllListeners first: a + // renderer reload re-runs this, and a duplicated card-tap listener would + // trigger the LNURL-withdraw twice. + onNfcCardTapped: (callback: (lnurlw: string) => void) => { + ipcRenderer.removeAllListeners('nfc:card-tapped') + ipcRenderer.on('nfc:card-tapped', (_event, lnurlw) => callback(lnurlw)) + }, + onNfcStatus: ( + callback: (status: { state: string; reader?: string; message?: string }) => void + ) => { + ipcRenderer.removeAllListeners('nfc:status') + ipcRenderer.on('nfc:status', (_event, status) => callback(status)) + }, + // Watchdog heartbeat (main process → renderer → main process) onWatchdogPing: (callback: () => void) => { ipcRenderer.on('watchdog:ping', () => callback()) @@ -212,6 +267,22 @@ declare global { getLastKnownConfigCreatedAt: () => Promise getBootstrapPublishedAt: () => Promise markBootstrapPublished: (unixTimestamp: number) => Promise + saveBunkerBinding: (binding: BunkerBindingRecord) => Promise + clearBunkerBinding: () => Promise + resetBootstrapGate: () => Promise + resetForRepair: () => Promise + saveSpireSeed: (seed: string) => Promise + relaunchApp: () => Promise + recoverApp: () => Promise + lnurlWithdraw: (args: { + lnurlw: string + bolt11: string + amountMsat?: number + }) => Promise<{ ok: boolean; reason?: string }> + resolveCardInvoice: (args: { + lnurlw: string + amountMsat: number + }) => Promise<{ ok: boolean; bolt11?: string; reason?: string }> applyOperatorCassettesConfig: ( payload: { positions: Record }, eventCreatedAt: number @@ -249,6 +320,10 @@ declare global { onHalBillInserted: (callback: (denomination: number) => void) => void onHalBillRejected: (callback: (reason: string) => void) => void onHalError: (callback: (error: string) => void) => void + onNfcCardTapped: (callback: (lnurlw: string) => void) => void + onNfcStatus: ( + callback: (status: { state: string; reader?: string; message?: string }) => void + ) => void onWatchdogPing: (callback: () => void) => void watchdogPong: () => Promise platform: NodeJS.Platform diff --git a/apps/machine/electron/state-store.ts b/apps/machine/electron/state-store.ts index fcf83e5..cfbfd71 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 = '12' function getDbPath(): string { const prodDir = '/var/lib/bitspire' @@ -114,6 +114,17 @@ 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, + relays TEXT, + lnbits_server_pubkey TEXT + ); `) // Seed meta + cashbox if first run, or run migrations @@ -320,6 +331,44 @@ 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)') + existing.value = '11' + } + + if (existing && existing.value === '11') { + // Migration v11 → v12: carry the LNbits transport config in the binding + // (aiolabs/bitspire#70). relays (JSON array) + lnbits_server_pubkey let a + // paired machine reach the backend from the pairing alone — no VITE_RELAY_URL + // / VITE_LNBITS_SERVER_PUBKEY provisioning. Nullable: bindings written before + // this (the seed didn't carry them) resume fine and fall back to env. + db.exec(` + ALTER TABLE bunker_binding ADD COLUMN relays TEXT; + ALTER TABLE bunker_binding ADD COLUMN lnbits_server_pubkey TEXT; + `) + db.prepare('UPDATE meta SET value = ? WHERE key = ?').run('12', 'schema_version') + console.log('[StateStore] Migrated schema v11 → v12 (bunker_binding transport config)') } // Defensive: a fresh install at SCHEMA_VERSION skips all migrations. @@ -381,6 +430,142 @@ 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 + /** + * LNbits transport relays from the pairing seed (aiolabs/bitspire#70). Lets a + * resumed (seedless) boot reach the backend without env provisioning. + * Undefined for bindings written before the seed carried them. + */ + relays?: string[] + /** LNbits nostr-transport server pubkey (hex) from the seed (#70). */ + lnbitsServerPubkey?: string +} + +/** Read the persisted bunker binding, or null if the ATM is unpaired. */ +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, relays, lnbits_server_pubkey FROM bunker_binding WHERE id = 1' + ) + .get() as + | { + client_secret_hex: string + spire_pubkey: string + bunker_url: string + seed_fingerprint: string + paired_at: number + relays: string | null + lnbits_server_pubkey: string | null + } + | 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, + relays: parseRelaysColumn(row.relays), + lnbitsServerPubkey: row.lnbits_server_pubkey ?? undefined, + } +} + +/** Decode the JSON-array `relays` column, tolerating null/legacy/garbage. */ +function parseRelaysColumn(value: string | null): string[] | undefined { + if (!value) return undefined + try { + const parsed = JSON.parse(value) + if (Array.isArray(parsed) && parsed.every((r) => typeof r === 'string')) { + return parsed as string[] + } + } catch { + // fall through + } + return undefined +} + +/** Upsert the bunker binding after a successful (re-)pairing. */ +export function saveBunkerBinding(binding: StoredBunkerBinding): void { + if (!db) throw new Error('Database not initialized') + db.prepare( + `INSERT INTO bunker_binding (id, client_secret_hex, spire_pubkey, bunker_url, seed_fingerprint, paired_at, relays, lnbits_server_pubkey) + VALUES (1, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + client_secret_hex = excluded.client_secret_hex, + spire_pubkey = excluded.spire_pubkey, + bunker_url = excluded.bunker_url, + seed_fingerprint = excluded.seed_fingerprint, + paired_at = excluded.paired_at, + relays = excluded.relays, + lnbits_server_pubkey = excluded.lnbits_server_pubkey` + ).run( + binding.clientSecretHex, + binding.spirePubkey, + binding.bunkerUrl, + binding.seedFingerprint, + binding.pairedAt, + binding.relays ? JSON.stringify(binding.relays) : null, + binding.lnbitsServerPubkey ?? null + ) +} + +/** 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() +} + +/** + * 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') +} + +/** + * Wipe operator-scoped CONFIG/TRUST state on a re-pair to a new operator/backend, + * so stale policy from the previous pairing can't linger or silently reject the + * new operator's config. + * + * Clears the fee config and resets BOTH replay watermarks to 0. The watermark + * reset is the load-bearing part: without it, a new backend whose first config + * event has a lower `created_at` than the old operator's last event is silently + * dropped as a replay — the exact remnant trap where re-pairing a long-lived + * install to a fresh backend appears to "work" but never picks up new config. + * + * Deliberately does NOT touch cassettes / cashbox / transactions: those track + * PHYSICAL cash, which survives an operator handover. A full wipe (decommission + * or a truly-fresh test) is the factory-reset path, not this. + */ +export function resetForRepair(): void { + if (!db) throw new Error('Database not initialized') + const database = db + database.transaction(() => { + database.prepare('DELETE FROM fee_config').run() + const setWatermark = database.prepare('UPDATE meta SET value = ? WHERE key = ?') + setWatermark.run('0', 'lastKnownFeeConfigCreatedAt') + setWatermark.run('0', 'lastKnownConfigCreatedAt') + })() +} + export type OperatorCassettesPayload = { positions: Record } @@ -797,8 +982,11 @@ export function recordTransaction(tx: TransactionInput): void { const insertCassetteBill = db.prepare( 'INSERT INTO cassette_bills (txid, name, position, denomination, provisioned, dispensed, rejected) VALUES (?, ?, ?, ?, ?, ?, ?)' ) - const updateCassette = db.prepare( - 'UPDATE cassettes SET count = MAX(0, count + ?) WHERE denomination = ?' + const updateCassetteByPosition = db.prepare( + 'UPDATE cassettes SET count = MAX(0, count + ?) WHERE position = ?' + ) + const selectBaysByDenom = db.prepare( + 'SELECT position, count FROM cassettes WHERE denomination = ? ORDER BY position' ) const updateCashboxStmt = db.prepare( 'UPDATE cashbox SET total_bills = total_bills + ?, total_fiat_cents = total_fiat_cents + ? WHERE id = 1' @@ -839,17 +1027,33 @@ export function recordTransaction(tx: TransactionInput): void { } if (t.type === 'cash_out') { - // Decrement cassettes by ACTUALLY dispensed count (not requested) + // Decrement cassettes by ACTUALLY dispensed count (not requested). + // Position is the addressable unit (v9): duplicate denominations + // across bays are legal, so a denomination-keyed UPDATE would + // decrement every matching bay. if (t.cassettes) { for (const c of t.cassettes) { if (c.dispensed > 0) { - updateCassette.run(-c.dispensed, c.denomination) + updateCassetteByPosition.run(-c.dispensed, c.position) } } } else { - // Fallback: use bill counts (backward compat for mocks without cassette data) + // Fallback: per-denomination bill counts (mocks without per-bay + // results). Drain matching bays greedily in position order — + // the dispenser's own fill order. for (const bill of t.bills) { - updateCassette.run(-bill.count, bill.denomination) + let remaining = bill.count + const bays = selectBaysByDenom.all(bill.denomination) as { + position: number + count: number + }[] + for (const bay of bays) { + if (remaining <= 0) break + const take = Math.min(remaining, bay.count) + if (take <= 0) continue + updateCassetteByPosition.run(-take, bay.position) + remaining -= take + } } } } diff --git a/apps/machine/package.json b/apps/machine/package.json index dfc3336..7f68db8 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", @@ -35,8 +35,10 @@ "clsx": "^2.1.1", "lucide-vue-next": "^0.563.0", "marked": "^17.0.5", + "nfc-pcsc": "^0.8.1", "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/App.vue b/apps/machine/src/App.vue index c5730e3..d9bce92 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -1,12 +1,14 @@ + + diff --git a/apps/machine/src/composables/useAvailabilityBroadcast.ts b/apps/machine/src/composables/useAvailabilityBroadcast.ts index b36f6f2..20517e9 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,19 +73,22 @@ export function useAvailabilityBroadcast(options: UseAvailabilityBroadcastOption model, }) - const event = createSignedEvent(identity, { - 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) } } diff --git a/apps/machine/src/config/device.ts b/apps/machine/src/config/device.ts index 9a22ab8..9a06c9a 100644 --- a/apps/machine/src/config/device.ts +++ b/apps/machine/src/config/device.ts @@ -15,7 +15,15 @@ import type { HalConfig, CassetteConfig } from '@/services/hal' /** * Supported machine models */ -export type MachineModel = 'sintra' | 'tejo' | 'douro' | 'gaia' | 'batm3' | 'custom' +export type MachineModel = + | 'sintra' + | 'tejo' + | 'douro' + | 'gaia' + | 'batm3' + | 'rpi4' + | 'rpi5' + | 'custom' /** * Full device configuration @@ -28,7 +36,10 @@ export interface DeviceConfig { /** Bill validator configuration */ validator: { /** Validator protocol type */ - type: 'id003' | 'ebds' + // 'apex' = Pyramid Apex RS-232. The driver landed with the Pi 5 work + // (packages/hal ValidatorType) but these app-side unions were never + // widened, so no machine could actually be configured to use it. + type: 'id003' | 'ebds' | 'apex' /** Serial device path(s) */ device: string | string[] } @@ -117,6 +128,52 @@ export const MACHINE_PRESETS: Record { + 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/hal.ts b/apps/machine/src/services/hal.ts index 1e20cc3..4ab83ee 100644 --- a/apps/machine/src/services/hal.ts +++ b/apps/machine/src/services/hal.ts @@ -23,7 +23,7 @@ export interface CassetteConfig { export interface HalConfig { validator: { - type: 'id003' | 'ebds' + type: 'id003' | 'ebds' | 'apex' device: string | string[] fiatCode: string } @@ -109,6 +109,12 @@ export async function initializeHalServices(config: HalConfig): Promise = {} for (const cassette of dispConfig.cassettes) { @@ -206,10 +212,13 @@ export async function initializeHalServices(config: HalConfig): Promise { + if (inFlightDenomination === null) { + console.warn('[HAL] billsValid with no bill in flight — ignoring') + return + } + const denomination = inFlightDenomination + inFlightDenomination = null + console.log('[HAL] Bill stacked (confirmed):', denomination) + callbacks.onBillInserted(denomination) + }) + validator.on('billsRejected', (data?: { reason: string; code: number | null }) => { + escrowDenomination = null + inFlightDenomination = null callbacks.onBillRejected(data?.reason ?? 'unknown') }) @@ -248,8 +271,19 @@ export async function initializeHalServices(config: HalConfig): Promise validator.stack(), - rejectBill: () => validator.reject(), + stackBill: () => { + if (escrowDenomination === null) { + console.warn('[HAL] stackBill with no bill in escrow — ignoring') + return + } + inFlightDenomination = escrowDenomination + escrowDenomination = null + validator.stack() + }, + rejectBill: () => { + escrowDenomination = null + validator.reject() + }, cleanup: async () => { return new Promise((resolve) => { diff --git a/apps/machine/src/services/init-error.ts b/apps/machine/src/services/init-error.ts new file mode 100644 index 0000000..bbeb117 --- /dev/null +++ b/apps/machine/src/services/init-error.ts @@ -0,0 +1,20 @@ +/** + * Classify an initialization failure into a maintenance-screen sentinel + * (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` 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/lightning.ts b/apps/machine/src/services/lightning.ts index 1466e20..eb2e1c7 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -12,12 +12,8 @@ * the customer's invoice. */ -import { - NostrClient, - generateIdentity, - loadIdentityFromHex, - 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' @@ -37,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). */ @@ -60,8 +54,10 @@ interface LightningConfig { */ async function loadLightningConfig(): Promise { const defaults: LightningConfig = { - relayUrl: 'ws://localhost:7777', - atmPrivateKey: '', + // Empty when unset (not the dev relay) so initializeLightningServices can + // tell "operator gave us a relay" from "fall back to the pairing seed". See + // aiolabs/bitspire#70 and DEV_DEFAULT_RELAY. + relayUrl: '', appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID operatorPubkeys: [], lnbitsServerPubkey: '', @@ -70,10 +66,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 @@ -90,7 +84,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) || @@ -107,6 +100,10 @@ async function loadLightningConfig(): Promise { // Config is loaded async now - will be set in initializeLightningServices let CONFIG: LightningConfig +/** Dev-only relay used when neither env nor the pairing supplies one. Matches + * the dev stack — LNbits's bundled nostrrelay (no separate strfry container). */ +const DEV_DEFAULT_RELAY = 'ws://localhost:5001/nostrrelay/test' + /** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime. * Sessions are normally cleaned up by the state machine on idle transition. * This is a safety net in case the state machine doesn't clean up properly. */ @@ -119,33 +116,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(), @@ -153,10 +144,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) } @@ -164,23 +155,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 @@ -234,7 +225,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 @@ -411,50 +402,85 @@ export async function initializeLightningServices(options?: { // Load configuration (async for Electron runtime config) CONFIG = await loadLightningConfig() - console.log('[Lightning] Relay URL:', CONFIG.relayUrl) - console.log('[Lightning] LNbits server pubkey:', CONFIG.lnbitsServerPubkey || '(not configured)') + // Resolve the signing identity BEFORE validating the LNbits transport + // config. An unpaired machine must reach the QR-pairing wizard regardless + // of relay/server-pubkey provisioning — pairing is what provides those — so + // resolveSigner (which throws NoPairingError → 'unpaired' → wizard for a + // machine with no seed and no binding) has to run ahead of the config + // checks below. The relay/pubkey validation then only gates a *paired* + // machine that's actually trying to talk to LNbits. See aiolabs/bitspire#70. + // + // 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, transport } = await resolveSigner({ allowEphemeral: !options?.strict }) + console.log('[Lightning] ATM pubkey:', signer.pubkey) - // Strict mode: validate config is production-ready (no localhost, no ephemeral identity) + // Resolve the effective LNbits transport. Precedence: explicit env wins (dev + // + operator override), else the pairing (seed/binding) supplies it (#70) so + // a blank-.env paired machine reaches the backend from the seed alone, else a + // dev-only localhost fallback. CONFIG is mutated to the resolved values so + // downstream (and the exported CONFIG) see a single source of truth. + const envRelay = CONFIG.relayUrl + const envPubkey = CONFIG.lnbitsServerPubkey + const relays: string[] = envRelay + ? [envRelay] + : transport && transport.relays.length > 0 + ? transport.relays + : [DEV_DEFAULT_RELAY] + CONFIG.relayUrl = relays[0]! + CONFIG.lnbitsServerPubkey = envPubkey || transport?.lnbitsServerPubkey || '' + console.log( + '[Lightning] Relay(s):', + relays.join(', '), + envRelay ? '(env)' : transport?.relays.length ? '(pairing)' : '(default)', + ) + console.log( + '[Lightning] LNbits server pubkey:', + CONFIG.lnbitsServerPubkey || '(not configured)', + envPubkey ? '(env)' : transport?.lnbitsServerPubkey ? '(pairing)' : '', + ) + // Operator pubkey provenance. Today the ONLY source is VITE_OPERATOR_PUBKEYS + // (env). An empty set disables the fees/operator-config services → the machine + // sits at "awaiting configuration" — so log it loudly rather than fail silent. + // (aiolabs/bitspire#70 P1 will source this from LNbits over the transport.) + console.log( + '[Lightning] Operator pubkey(s):', + CONFIG.operatorPubkeys.length + ? CONFIG.operatorPubkeys.join(', ') + ' (env)' + : '(none — fee/operator config gated until a server-delivered operator pubkey; #70 P1)', + ) + + // Strict mode: validate the RESOLVED config is production-ready (no + // localhost). Values may come from env or the pairing seed (#70). if (options?.strict) { const errors: string[] = [] if (/localhost|127\.0\.0\.1/.test(CONFIG.relayUrl)) { - errors.push('VITE_RELAY_URL contains localhost') - } - if (!CONFIG.atmPrivateKey) { - errors.push('VITE_ATM_PRIVATE_KEY is not set (ephemeral identity not allowed in production)') + errors.push('relay resolves to localhost (VITE_RELAY_URL / seed relays)') } if (!CONFIG.lnbitsServerPubkey) { - errors.push('VITE_LNBITS_SERVER_PUBKEY is not set') + errors.push('no LNbits server pubkey (VITE_LNBITS_SERVER_PUBKEY / seed lnbits_npub)') } if (errors.length > 0) { throw new Error('[Lightning] Production config validation failed:\n- ' + errors.join('\n- ')) } } - // Validate required configuration + // Validate required configuration. Reached only for a paired machine (an + // unpaired one threw NoPairingError above) — it needs the LNbits server + // pubkey to talk to the transport, from either env or the pairing seed. if (!CONFIG.lnbitsServerPubkey) { throw new Error( - '[Lightning] VITE_LNBITS_SERVER_PUBKEY is required. ' + - 'Get it from: docker logs lnbits | grep nostr_transport pubkey', + '[Lightning] LNbits server pubkey is required — set VITE_LNBITS_SERVER_PUBKEY ' + + 'or pair with a seed that carries lnbits_npub (aiolabs/bitspire#70).', ) } - // 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) - // Create Nostr client const nostrClient = new NostrClient({ - relays: [{ url: CONFIG.relayUrl }], - identity, + relays: relays.map((url) => ({ url })), + signer, }) await nostrClient.connect() @@ -463,9 +489,9 @@ export async function initializeLightningServices(options?: { // LNbits nostr-transport client. const lnbits = new LnbitsClient({ serverPubkey: CONFIG.lnbitsServerPubkey, - relays: [CONFIG.relayUrl], + relays, }) - lnbits.initialize(nostrClient, identity) + lnbits.initialize(nostrClient, signer) _lnbitsRef = lnbits console.log('[Lightning] LNbits client initialized') @@ -479,14 +505,54 @@ export async function initializeLightningServices(options?: { } console.log('[Lightning] LNbits wallet:', lnbitsWalletId) + // #70 P1: pull operator pubkey + fee config from LNbits over the authenticated + // transport (spirekeeper#41 `get_machine_config`). A seed-only machine has no + // VITE_OPERATOR_PUBKEYS, so without this it can't trust its fee config and sits + // at "awaiting configuration". Only for the seed-only case — an explicit + // VITE_OPERATOR_PUBKEYS override keeps the env/kind-30078 path untouched. + // Soft-fail: an older spirekeeper (no RPC) or a transport error falls back to + // whatever the operator services can pull from kind-30078. + if (CONFIG.operatorPubkeys.length === 0) { + try { + const mc = await lnbits.getMachineConfig() + if (mc.operator_pubkey) { + CONFIG.operatorPubkeys = [mc.operator_pubkey] + console.log('[Lightning] Operator pubkey(s):', mc.operator_pubkey, '(server-delivered, #70 P1)') + } + if (mc.fee_config && isElectron && window.electronAPI) { + // Persist the server-delivered fee config so atm.ts's awaiting-fees gate + // (getFeeConfig) clears immediately — robust to the replaceable kind-30078 + // event not being fetchable from the relay. The live kind-30078 + // subscription still handles mid-run fee updates. + const applied = await window.electronAPI.applyFeeConfig( + { + cashInFeeFraction: mc.fee_config.cash_in_fee_fraction, + cashOutFeeFraction: mc.fee_config.cash_out_fee_fraction, + schemaVersion: mc.fee_config.schema_version, + }, + mc.created_at, + ) + console.log( + '[Lightning] Server-delivered fee config:', + applied.applied ? 'applied' : `skipped (${applied.reason})`, + ) + } + } catch (e) { + console.warn( + '[Lightning] get_machine_config unavailable; falling back to env/kind-30078 for operator config:', + (e as Error).message, + ) + } + } + // CLINK client — kept in tree but not actively wired into LNbits flows. // operatorPubkey is the operator allowlist for kind-21003 management // commands; it has no Lightning.Pub dependency. const clink = new CLINKClient({ nostrClient, - identity, + signer, operatorPubkey: CONFIG.operatorPubkeys, - relays: [CONFIG.relayUrl], + relays, }) // Callbacks for events @@ -574,7 +640,6 @@ export async function initializeLightningServices(options?: { } const atmServices = createATMServices( - identity, (preimage) => { if (paymentReceivedCallback) { paymentReceivedCallback(preimage) @@ -588,7 +653,7 @@ export async function initializeLightningServices(options?: { nostrClient, lightningPub, clink, - identity, + signer, operatorPubkeys: CONFIG.operatorPubkeys, atmServices, onOfferRequest: (callback: OfferRequestCallback) => { @@ -617,7 +682,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, @@ -661,57 +725,70 @@ function createATMServices( * over nostr, we trigger dispense. */ generateLnurlWithdraw: async (context: ATMContext): Promise => { - console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats') + // GROSS principal (fiat × rate, BEFORE commission). The server derives + // fee + NET from this, so we must NOT send the already-fee'd + // context.satsAmount — doing so double-applies the commission (client + // subtracts it in calculateSats, then the server subtracts it again, + // e.g. 12% → 22.6% effective; the customer is short-changed while the + // quote/receipt still read 12%). Mirror calculateSats's principal. + const grossPrincipalSats = Math.floor((context.fiatCents / 100) * context.exchangeRate) + console.log( + `[ATM Service] Generating LNURL-withdraw: gross principal=${grossPrincipalSats} sats ` + + `(net after ${(context.feeFraction * 100).toFixed(2)}% ≈ ${context.satsAmount})` + ) try { if (context.cashInSessionId) { 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: grossPrincipalSats, + 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/apps/machine/src/services/operator-config.ts b/apps/machine/src/services/operator-config.ts index c9b26f8..d853114 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,17 +45,28 @@ 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 } 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( @@ -65,14 +74,14 @@ 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.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 +97,7 @@ export async function startOperatorConfigService( [ { kinds: [KIND_NIP78], - '#p': [cfg.identity.publicKey], + '#p': [cfg.signer.pubkey], '#d': [dTag], authors: cfg.operatorPubkeys, }, @@ -105,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) + }), } } @@ -150,7 +165,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) @@ -195,38 +210,44 @@ 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) { 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: [ @@ -237,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/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/services/pairing/__tests__/ingest.test.ts b/apps/machine/src/services/pairing/__tests__/ingest.test.ts new file mode 100644 index 0000000..f6c069e --- /dev/null +++ b/apps/machine/src/services/pairing/__tests__/ingest.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { ingestScannedSeed } from '../ingest' +import { SPIRE_SEED_SCHEME } from '@bitSpire/nostr-client' +import { npubEncode } from 'nostr-tools/nip19' + +/** Mirror of spirekeeper pairing.py: urlsafe base64, padding stripped. */ +function makeSeed(json: unknown): string { + 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: npubEncode(SPIRE_PUBKEY), + lnbits_npub: npubEncode('b'.repeat(64)), + bunker_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..faaad95 --- /dev/null +++ b/apps/machine/src/services/pairing/index.ts @@ -0,0 +1,32 @@ +/** + * 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, parseScannedSeed } from './ingest' +export type { IngestResult, SeedPreview } from './ingest' +export { testRelay } from './relay-test' +export type { RelayTestResult } from './relay-test' + +/** All sources in preference order, regardless of availability. */ +export function allPairingSources(): PairingSource[] { + 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..dc22ad2 --- /dev/null +++ b/apps/machine/src/services/pairing/ingest.ts @@ -0,0 +1,94 @@ +/** + * 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 type SeedPreview = + | { ok: true; spirePubkey: string; fingerprint: string; relays: string[] } + | { ok: false; reason: 'invalid-seed'; message: string } + +/** + * Validate-only: parse a scanned payload as a spire-seed WITHOUT persisting or + * relaunching. The wizard uses this to show a review step (decoded relay + a + * "test relay" button) before committing, so a well-formed but unreachable + * relay is caught before the machine relaunches into a pairing crash-loop. + * `parseSpireSeed` already rejects a malformed relay (e.g. a QR misread of + * `ws://` → `As://`); this surfaces that as an invalid-seed rejection. + */ +export function parseScannedSeed(raw: string): SeedPreview { + const trimmed = (raw || '').trim() + try { + const seed = parseSpireSeed(trimmed) + return { + ok: true, + spirePubkey: seed.spirePubkey, + fingerprint: seedFingerprint(trimmed), + relays: seed.relays, + } + } catch (e) { + return { + ok: false, + reason: 'invalid-seed', + message: e instanceof Error ? e.message : 'Not a valid pairing code', + } + } +} + +export async function ingestScannedSeed(raw: string): Promise { + 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..1628a4b --- /dev/null +++ b/apps/machine/src/services/pairing/qr-source.ts @@ -0,0 +1,90 @@ +/** + * 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