diff --git a/.claude/skills/lightning-check.md b/.claude/skills/lightning-check.md index e69faa6..6390fe7 100644 --- a/.claude/skills/lightning-check.md +++ b/.claude/skills/lightning-check.md @@ -75,7 +75,7 @@ LNbits transport requires NIP-44 v2, NOT NIP-04. Required checks: LNbits derives the calling account from the event signature. There is **no admin token, no API key, no client cert** on the ATM. Required checks: - [ ] No code stores or references an admin token -- [ ] No code makes outbound HTTP to LNbits (other than via `VITE_LNBITS_HTTP_URL` for the LNURL-withdraw callback, which is the customer wallet's path — the ATM itself doesn't hit HTTP) +- [ ] No code makes outbound HTTP to LNbits (the LNURL-withdraw callback URL embedded in cash-in QRs is the customer wallet's path — the ATM itself doesn't hit HTTP). Post aiolabs/withdraw#1 / `e9d911e`, the ATM does not even compose that URL: LNbits's nostr-transport returns `link.lnurl` populated from `settings.lnbits_baseurl` - [ ] The ATM's `identity.privateKey` is loaded once from `/var/lib/bitspire/.env` and never logged ### 4. Cash-out flow (`--lnbits`) @@ -125,8 +125,10 @@ const link = await lnbits.createWithdrawLink(walletId, { is_unique: false, }) -const callbackUrl = `${CONFIG.lnbitsHttpUrl.replace(/\/+$/, '')}/withdraw/api/v1/lnurl/${link.unique_hash}` -const lnurl = encodeLnurl(callbackUrl) // bech32 HRP="lnurl", uppercase +if (!link.lnurl) { + throw new Error('LNbits returned link.lnurl=null — check LNBITS_BASEURL on the server') +} +const lnurl = link.lnurl.toUpperCase() const subId = await lnbits.subscribePayments(walletId, { tag: 'withdraw', @@ -139,8 +141,8 @@ Required checks: - [ ] `uses: 1` — single-use prevents replay - [ ] `min_withdrawable === max_withdrawable === ctx.satsAmount` — exact amount, no operator slippage -- [ ] The LNURL is composed from `VITE_LNBITS_HTTP_URL` + `link.unique_hash` (the transport `lnurlw_create_link` returns `link.lnurl === null` — those fields are filled by HTTP views, not the create RPC) -- [ ] bech32 encoding uses HRP `"lnurl"` and the result is uppercased per BOLT/LNURL convention +- [ ] The LNURL comes from `link.lnurl` directly (LNbits populates it from `settings.lnbits_baseurl` over the nostr-transport per aiolabs/withdraw#1 / `e9d911e`); error out if it's null instead of falling back to a hardcoded HTTP URL +- [ ] Uppercase the bech32 string per BOLT/LNURL convention (the LNbits side returns it lowercase) - [ ] Subscription filter is `tag: 'withdraw'` + `link_id: link.id` — this is what the withdraw extension stamps on settlement events - [ ] On session abort, the cleanup closure both unsubscribes AND deletes the withdraw link (so a half-completed session doesn't leave a redeemable QR alive on LNbits) diff --git a/CLAUDE.md b/CLAUDE.md index c67c3eb..20ba0df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,7 +71,7 @@ bitSpire/ 4. Implements the four flow-critical `ATMServices` methods on top of LNbits: - `generateInvoice(msat)` → cash-out BOLT11 - `watchInvoice(bolt11, cb)` → `subscribe_payments({payment_hash, max_seconds:600})` push - - `generateLnurlWithdraw(ctx)` → `lnurlw_create_link({uses:1, ...})`, compose callback URL from `VITE_LNBITS_HTTP_URL`, bech32-encode with HRP `lnurl`, subscribe for `tag:"withdraw", link_id` settlement push + - `generateLnurlWithdraw(ctx)` → `lnurlw_create_link({uses:1, ...})`, use `link.lnurl` directly (LNbits populates it from `settings.lnbits_baseurl` per aiolabs/withdraw#1 / `e9d911e`), subscribe for `tag:"withdraw", link_id` settlement push - `getAvailableBalance()` → wraps `lnbits.getBalance(walletId).balanceSats` `CashInView.vue` calls `atmStore.generateLnurlWithdraw()` directly when entering `displayingQR`; the state machine still invokes `generateNdebit` as an actor but its output is discarded (kept only to avoid an invasive state-machine rewrite). @@ -84,7 +84,6 @@ Renderer reads (Electron IPC or Vite `import.meta.env`): |---|---|---| | `VITE_RELAY_URL` | yes | `ws://...` of the relay both ATM and LNbits subscribe to. Dev: `ws://localhost:5001/nostrrelay/test` (LNbits's bundled `nostrrelay` extension — no separate strfry container) | | `VITE_LNBITS_SERVER_PUBKEY` | yes | 64-char hex pubkey LNbits prints on startup (`docker logs lnbits \| grep 'Public key (share this)'`) | -| `VITE_LNBITS_HTTP_URL` | yes | `http(s)://...` origin used to compose LNURL-withdraw callback URLs. The ATM itself never calls this URL — it's only embedded in the bech32 string customer wallets dereference | | `VITE_ATM_PRIVATE_KEY` | yes (prod) | 64-char hex. The ATM's nostr identity. Generates ephemeral on first boot if unset (dev only) | | `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands | diff --git a/README.md b/README.md index 7e48837..fcd43b9 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,6 @@ docker logs regtest-lnbits-1 | grep 'Public key (share this)' cat > apps/machine/.env < -VITE_LNBITS_HTTP_URL=http://localhost:5001 VITE_ATM_PRIVATE_KEY=$(openssl rand -hex 32) EOF diff --git a/apps/machine/.env.example b/apps/machine/.env.example index e5aa292..73b6281 100644 --- a/apps/machine/.env.example +++ b/apps/machine/.env.example @@ -30,15 +30,16 @@ VITE_RELAY_URL=ws://localhost:7777 # docker logs lnbits | grep 'nostr_transport pubkey' VITE_LNBITS_SERVER_PUBKEY= -# LNbits HTTP root — used purely to compose the LNURL-withdraw callback -# URL that customer wallets dereference. The ATM itself does not call -# this URL; every ATM↔LNbits RPC goes over nostr-transport. -VITE_LNBITS_HTTP_URL=http://localhost:5000 +# (LNbits HTTP URL is no longer needed on the ATM side — the +# nostr-transport RPC `lnurlw_create_link` now returns `link.lnurl` +# populated from `settings.lnbits_baseurl` on the LNbits server. See +# aiolabs/withdraw#1 / commit e9d911e.) # ============================================================================= # ATM Identity # ============================================================================= +# 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). diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index a2a9d3a..59cbcd5 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -283,7 +283,6 @@ ipcMain.handle('get-config', () => { // LNbits nostr-transport connection (public info only) relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777', lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '', - lnbitsHttpUrl: process.env.VITE_LNBITS_HTTP_URL || 'http://localhost:5000', appId: process.env.VITE_APP_ID || '', // Hardware configuration diff --git a/apps/machine/electron/preload.ts b/apps/machine/electron/preload.ts index a69536d..836b98d 100644 --- a/apps/machine/electron/preload.ts +++ b/apps/machine/electron/preload.ts @@ -17,8 +17,6 @@ export interface RuntimeConfig { relayUrl: string /** LNbits nostr-transport server pubkey (hex, 64 chars). */ lnbitsServerPubkey: string - /** LNbits HTTP root — used only to compose the LNURL-withdraw callback URL. */ - lnbitsHttpUrl: string /** Legacy LP fields — retained until 3d removes the LP backend. Optional. */ lightningPubPubkey?: string lightningPubApiUrl?: string diff --git a/apps/machine/package.json b/apps/machine/package.json index a0320d5..dfc3336 100644 --- a/apps/machine/package.json +++ b/apps/machine/package.json @@ -28,7 +28,6 @@ "@bitSpire/lnbits": "workspace:*", "@bitSpire/nostr-client": "workspace:*", "@bitSpire/state-machine": "workspace:*", - "@scure/base": "^1.2.0", "@tanstack/vue-table": "^8.21.3", "@vueuse/core": "^14.1.0", "better-sqlite3": "^11.0.0", diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index 82369f3..1466e20 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -19,7 +19,6 @@ import { type MachineIdentity, } from '@bitSpire/nostr-client' import { LnbitsClient } from '@bitSpire/lnbits' -import { bech32 } from '@scure/base' import { CLINKClient } from '@bitSpire/clink' import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink' import type { ATMServices, ATMContext } from '@bitSpire/state-machine' @@ -50,13 +49,6 @@ interface LightningConfig { operatorPubkeys: string[] /** LNbits nostr-transport server pubkey (hex, 64 chars). */ lnbitsServerPubkey: string - /** - * LNbits HTTP root (e.g. `https://lnbits.example`). Used purely to - * compose the LNURL callback URL that customer wallets dereference - * to redeem an LNURL-withdraw. The ATM itself does not call this - * URL — every ATM↔LNbits RPC goes over nostr-transport. - */ - lnbitsHttpUrl: string } /** @@ -73,7 +65,6 @@ async function loadLightningConfig(): Promise { appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID operatorPubkeys: [], lnbitsServerPubkey: '', - lnbitsHttpUrl: 'http://localhost:5000', } if (isElectron && window.electronAPI) { @@ -91,7 +82,6 @@ async function loadLightningConfig(): Promise { .filter(Boolean) : defaults.operatorPubkeys, lnbitsServerPubkey: rc.lnbitsServerPubkey || defaults.lnbitsServerPubkey, - lnbitsHttpUrl: rc.lnbitsHttpUrl || defaults.lnbitsHttpUrl, } } catch (e) { console.warn('[Lightning] Failed to get runtime config from Electron:', e) @@ -105,9 +95,6 @@ async function loadLightningConfig(): Promise { lnbitsServerPubkey: (import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) || defaults.lnbitsServerPubkey, - lnbitsHttpUrl: - (import.meta.env.VITE_LNBITS_HTTP_URL as string | undefined) || - defaults.lnbitsHttpUrl, operatorPubkeys: import.meta.env.VITE_OPERATOR_PUBKEYS ? (import.meta.env.VITE_OPERATOR_PUBKEYS as string) .split(',') @@ -120,19 +107,6 @@ async function loadLightningConfig(): Promise { // Config is loaded async now - will be set in initializeLightningServices let CONFIG: LightningConfig -/** - * Encode a callback URL as an LNURL (bech32 with HRP "lnurl", upper-cased - * per BOLT/LNURL convention). Used for cash-in: customer wallet scans - * the QR, decodes the URL, GETs it to receive the LNURL-withdraw params. - * - * Generous bech32 limit: LNURLs can run long (full origin + path + hash). - */ -function encodeLnurl(url: string): string { - const bytes = new TextEncoder().encode(url) - const words = bech32.toWords(bytes) - return bech32.encode('lnurl', words, 2000).toUpperCase() -} - /** 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. */ @@ -677,22 +651,18 @@ function createATMServices( /** * Generate an LNURL-withdraw for cash-in. * - * 1. Create the link via LNbits transport (lnurlw_create_link). - * 2. Compose the customer-facing callback URL from VITE_LNBITS_HTTP_URL - * plus the link's unique_hash (the transport returns null for - * `lnurl`/`lnurl_url` — those are filled by HTTP views, not the - * create RPC). Bech32-encode it ourselves with HRP "lnurl". - * 3. Subscribe to settlement pushes filtered by tag="withdraw" + + * 1. Create the link via LNbits transport (lnurlw_create_link). The + * nostr-transport RPC returns `link.lnurl` populated from + * `settings.lnbits_baseurl` on the LNbits side (see + * aiolabs/withdraw#1 / commit e9d911e) — no need to compose + * the callback URL ourselves anymore. + * 2. Subscribe to settlement pushes filtered by tag="withdraw" + * link_id; customer wallet redeems via HTTP, LNbits pushes us * over nostr, we trigger dispense. */ generateLnurlWithdraw: async (context: ATMContext): Promise => { console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats') - if (!CONFIG.lnbitsHttpUrl) { - throw new Error('[ATM Service] VITE_LNBITS_HTTP_URL is required for LNbits cash-in') - } - try { if (context.cashInSessionId) { invalidateLnurlSessionBySessionId(context.cashInSessionId) @@ -707,8 +677,12 @@ function createATMServices( is_unique: false, }) - const callbackUrl = `${CONFIG.lnbitsHttpUrl.replace(/\/+$/, '')}/withdraw/api/v1/lnurl/${link.unique_hash}` - const lnurl = encodeLnurl(callbackUrl) + if (!link.lnurl) { + throw new Error( + '[ATM Service] LNbits returned link.lnurl=null — check LNBITS_BASEURL on the server (aiolabs/withdraw#1)' + ) + } + const lnurl = link.lnurl.toUpperCase() if (context.cashInSessionId) { registerLnurlSession( diff --git a/apps/machine/src/types/electron.d.ts b/apps/machine/src/types/electron.d.ts index 9fc11a3..7830360 100644 --- a/apps/machine/src/types/electron.d.ts +++ b/apps/machine/src/types/electron.d.ts @@ -6,8 +6,6 @@ export interface RuntimeConfig { relayUrl: string /** LNbits nostr-transport server pubkey (hex, 64 chars). */ lnbitsServerPubkey: string - /** LNbits HTTP root — used only to compose the LNURL-withdraw callback URL. */ - lnbitsHttpUrl: string /** Legacy LP fields — retained until 3d removes the LP backend. Optional. */ lightningPubPubkey?: string lightningPubApiUrl?: string diff --git a/deploy/nixos/README.md b/deploy/nixos/README.md index 44b04af..794c471 100644 --- a/deploy/nixos/README.md +++ b/deploy/nixos/README.md @@ -153,7 +153,6 @@ Pull both USB sticks. Power Sintra back on. systemd-boot loads from the eMMC's E LNBITS_SERVER_PUBKEY=$(docker logs 2>&1 | \ grep -oP 'Public key \(share this\):\s*\K[a-f0-9]{64}' | tail -1) -LNBITS_HTTP_URL=http://:5001 \ RELAY_URL=ws://:5001/nostrrelay/test \ LNBITS_SERVER_PUBKEY="$LNBITS_SERVER_PUBKEY" \ ATM_PRIVATE_KEY=$(openssl rand -hex 32) \ @@ -166,7 +165,6 @@ bash deploy/nixos/provision-atm.sh 22 set -a; source ~/sintra-backup-/.env; set +a ATM_PRIVATE_KEY=$VITE_ATM_PRIVATE_KEY \ LNBITS_SERVER_PUBKEY=$VITE_LNBITS_SERVER_PUBKEY \ -LNBITS_HTTP_URL=$VITE_LNBITS_HTTP_URL \ RELAY_URL=$VITE_RELAY_URL \ bash deploy/nixos/provision-atm.sh 22 ``` @@ -204,12 +202,12 @@ Production ATMs on `main` continue to read `main`'s flake (no `?ref=` pin → re | Path | Owner | Purpose | |------|-------|---------| | `/var/lib/bitspire/` | bitspire:bitspire, 0750 | Service data directory | -| `/var/lib/bitspire/.env` | bitspire:bitspire, 0600 | Runtime config — `VITE_RELAY_URL`, `VITE_LNBITS_SERVER_PUBKEY`, `VITE_LNBITS_HTTP_URL`, `VITE_ATM_PRIVATE_KEY`, … | +| `/var/lib/bitspire/.env` | bitspire:bitspire, 0600 | Runtime config — `VITE_RELAY_URL`, `VITE_LNBITS_SERVER_PUBKEY`, `VITE_ATM_PRIVATE_KEY`, … | | `/var/lib/bitspire/state.db` | bitspire:bitspire | SQLite — cassette inventory, cashbox state, transaction history | | `/var/lib/bitspire/logs/` | bitspire:bitspire, 0750 | Service logs (if app writes them) | | `/var/lib/bitspire/branding/` | bitspire:bitspire, 0755 | Operator branding override (logo.png + branding.json) — see issue #47 | | `/opt/bitspire/` | bitspire:bitspire | Optional override drop for app assets (mostly unused — app comes from `/nix/store`) | -| `/etc/bitspire/config.env` | root:root | Static config emitted by the NixOS module (RELAY_URL, LNBITS_HTTP_URL — informational; the renderer reads `/var/lib/bitspire/.env` instead) | +| `/etc/bitspire/config.env` | root:root | Static config emitted by the NixOS module (RELAY_URL, LNBITS_SERVER_PUBKEY — informational; the renderer reads `/var/lib/bitspire/.env` instead) | ## Common operations @@ -266,7 +264,6 @@ ls -la /dev/serial/by-id/ enable = true; relayUrl = "wss://relay.aiolabs.dev"; # ATM ↔ LNbits relay lnbitsServerPubkey = "<64-hex>"; # LNbits transport pubkey - lnbitsHttpUrl = "https://lnbits.aiolabs.dev"; # LNURL callback URL prefix appDir = "/opt/bitspire"; # rarely overridden — defaults via flake dataDir = "/var/lib/bitspire"; # rarely overridden logLevel = "info"; # error | warn | info | debug diff --git a/deploy/nixos/bitspire-atm.nix b/deploy/nixos/bitspire-atm.nix index 7607364..51daf42 100644 --- a/deploy/nixos/bitspire-atm.nix +++ b/deploy/nixos/bitspire-atm.nix @@ -1,7 +1,13 @@ # bitSpire ATM Service Module # Manages the ATM Electron application and related services -{ config, lib, pkgs, pkgs-unstable, ... }: +{ + config, + lib, + pkgs, + pkgs-unstable, + ... +}: with lib; @@ -29,17 +35,6 @@ in ''; }; - lnbitsHttpUrl = mkOption { - type = types.str; - default = "https://lnbits.aiolabs.dev"; - description = '' - LNbits HTTP origin — used solely to compose the LNURL-withdraw - callback URL embedded in cash-in QR codes. The ATM itself - never calls this URL; every ATM↔LNbits RPC goes over - nostr-transport. - ''; - }; - appDir = mkOption { type = types.path; default = "/opt/bitspire"; @@ -53,7 +48,12 @@ in }; logLevel = mkOption { - type = types.enum [ "error" "warn" "info" "debug" ]; + type = types.enum [ + "error" + "warn" + "info" + "debug" + ]; default = "info"; description = "Logging level for the ATM application"; }; @@ -73,7 +73,11 @@ in }; type = mkOption { - type = types.enum [ "id003" "mei" "ccnet" ]; + type = types.enum [ + "id003" + "mei" + "ccnet" + ]; default = "id003"; description = "Bill validator protocol type"; }; @@ -93,7 +97,10 @@ in }; type = mkOption { - type = types.enum [ "puloon" "genmega" ]; + type = types.enum [ + "puloon" + "genmega" + ]; default = "puloon"; description = "Bill dispenser type"; }; @@ -128,7 +135,6 @@ in # bitSpire ATM Configuration RELAY_URL=${cfg.relayUrl} LNBITS_SERVER_PUBKEY=${cfg.lnbitsServerPubkey} - LNBITS_HTTP_URL=${cfg.lnbitsHttpUrl} LOG_LEVEL=${cfg.logLevel} DATA_DIR=${cfg.dataDir} @@ -153,7 +159,10 @@ in systemd.services.bitspire = { description = "bitSpire ATM Application"; wantedBy = [ "graphical.target" ]; - after = [ "graphical.target" "network-online.target" ]; + after = [ + "graphical.target" + "network-online.target" + ]; wants = [ "network-online.target" ]; serviceConfig = { @@ -180,7 +189,10 @@ in NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = true; - ReadWritePaths = [ cfg.dataDir "/tmp" ]; + ReadWritePaths = [ + cfg.dataDir + "/tmp" + ]; PrivateTmp = true; # Allow device access for hardware @@ -196,7 +208,6 @@ in preStart = '' echo "bitSpire starting..." echo "Relay: ${cfg.relayUrl}" - echo "LNbits HTTP: ${cfg.lnbitsHttpUrl}" # Check bill validator if enabled if [ "${boolToString cfg.billValidator.enable}" = "true" ]; then diff --git a/docs/architecture-comparison.md b/docs/architecture-comparison.md index 12f4fb9..9d4ace7 100644 --- a/docs/architecture-comparison.md +++ b/docs/architecture-comparison.md @@ -121,13 +121,12 @@ This document compares the bitSpire architecture — a Nostr-native Lightning AT ### Cash-in flow (customer hands ATM cash, gets sats) 1. ATM publishes `lnurlw_create_link` (kind-21000) to LNbits with `{uses: 1, max_withdrawable: }` -2. LNbits replies with a `WithdrawLink` (a `unique_hash` plus metadata) -3. ATM composes the callback URL: `{LNBITS_HTTP_URL}/withdraw/api/v1/lnurl/{unique_hash}` and bech32-encodes it as an LNURL -4. ATM displays the LNURL as a QR -5. Customer scans with any LNURL-withdraw-capable wallet, redeems it -6. LNbits settles the withdrawal via its Lightning backend -7. LNbits pushes a kind-21000 settlement event to the ATM, filtered by `tag="withdraw"` + `link_id` -8. ATM marks the session complete (cash already physically accepted earlier in the flow) +2. LNbits replies with a `WithdrawLink` carrying a `lnurl` field already populated from `settings.lnbits_baseurl` (per aiolabs/withdraw#1 / `e9d911e`) +3. ATM displays the LNURL as a QR (uppercased per BOLT/LNURL convention) +4. Customer scans with any LNURL-withdraw-capable wallet, redeems it +5. LNbits settles the withdrawal via its Lightning backend +6. LNbits pushes a kind-21000 settlement event to the ATM, filtered by `tag="withdraw"` + `link_id` +7. ATM marks the session complete (cash already physically accepted earlier in the flow) ### Characteristics diff --git a/flake.nix b/flake.nix index 470e7e0..720c167 100644 --- a/flake.nix +++ b/flake.nix @@ -195,7 +195,6 @@ cp ${pkgs.writeText "bitspire-env-default" '' VITE_RELAY_URL= VITE_LNBITS_SERVER_PUBKEY= - VITE_LNBITS_HTTP_URL= VITE_ATM_PRIVATE_KEY= VITE_APP_ID= VITE_OPERATOR_PUBKEYS= diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe5f4df..3218ae5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,6 @@ importers: '@bitSpire/state-machine': specifier: workspace:* version: link:../../packages/state-machine - '@scure/base': - specifier: ^1.2.0 - version: 1.2.6 '@tanstack/vue-table': specifier: ^8.21.3 version: 8.21.3(vue@3.5.27(typescript@5.9.3))