refactor(machine): drop VITE_LNBITS_HTTP_URL — lnurl now arrives populated from LNbits (#57 gap 2)

Closes gap 2 from coord log 2026-06-01T18:30Z. The LNbits withdraw
extension's nostr-transport RPC now populates `link.lnurl` from
`settings.lnbits_baseurl` (aiolabs/withdraw#1 / commit e9d911e), so the
ATM no longer needs a separate HTTP URL on the wire to compose the
LNURL-withdraw callback itself.

What goes:

- `VITE_LNBITS_HTTP_URL` env var (renderer + Electron main)
- `lnbitsHttpUrl` field on `LightningConfig`, `RuntimeConfig`, and the
  Window mirror in `src/types/electron.d.ts`
- The manual `${lnbitsHttpUrl}/withdraw/api/v1/lnurl/${unique_hash}`
  composition in `generateLnurlWithdraw`
- The `encodeLnurl` bech32 helper in `lightning.ts` (LNbits returns
  bech32-encoded; we just `.toUpperCase()` to match BOLT/LNURL convention)
- `@scure/base` dep from `apps/machine/package.json` (only used by the
  removed helper; clink still uses it directly)
- The `lnbitsHttpUrl` option + `LNBITS_HTTP_URL=…` env var + boot echo
  in `deploy/nixos/bitspire-atm.nix`
- Doc references in CLAUDE.md, README.md, deploy/nixos/README.md,
  docs/architecture-comparison.md, and the lightning-check skill

What stays:

- `link.lnurl` consumption, with an explicit error if LNbits returns
  null (which signals `LNBITS_BASEURL` is unset on the server side —
  better to fail clearly than silently)
- The receiver-side bech32 uppercasing (LNbits returns lowercase per
  the standard library)

Why this is a net win:

- Removes a config-drift surface — if LNbits's external URL moved
  (DNS, port, reverse-proxy rewrite), every ATM in the field would
  stop issuing redeemable LNURL-withdraw QRs until reconfigured.
  Now LNbits derives its own URL from `settings.lnbits_baseurl`,
  one source of truth.
- Removes an extra provisioning step. No more `LNBITS_HTTP_URL=…`
  before running `provision-atm.sh`; the relay + server pubkey suffice.
- Removes the misleading boot echo that triggered the §`18:30Z`
  smoke triage confusion ("LNbits HTTP: <url>" read like ATM-→-LNbits
  connectivity, when it was only ever a URL embedded in customer QRs).

Also adds a `# pragma: allowlist secret` marker above the
`VITE_ATM_PRIVATE_KEY` doc block in `.env.example` so the global
secret scanner stops false-positiving on the documentation prose.

Workspace typecheck + 24/24 apps/machine tests still green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-01 20:33:28 +02:00
commit 4f68ddc40b
14 changed files with 63 additions and 91 deletions

View file

@ -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: 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 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 - [ ] The ATM's `identity.privateKey` is loaded once from `/var/lib/bitspire/.env` and never logged
### 4. Cash-out flow (`--lnbits`) ### 4. Cash-out flow (`--lnbits`)
@ -125,8 +125,10 @@ const link = await lnbits.createWithdrawLink(walletId, {
is_unique: false, is_unique: false,
}) })
const callbackUrl = `${CONFIG.lnbitsHttpUrl.replace(/\/+$/, '')}/withdraw/api/v1/lnurl/${link.unique_hash}` if (!link.lnurl) {
const lnurl = encodeLnurl(callbackUrl) // bech32 HRP="lnurl", uppercase 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, { const subId = await lnbits.subscribePayments(walletId, {
tag: 'withdraw', tag: 'withdraw',
@ -139,8 +141,8 @@ Required checks:
- [ ] `uses: 1` — single-use prevents replay - [ ] `uses: 1` — single-use prevents replay
- [ ] `min_withdrawable === max_withdrawable === ctx.satsAmount` — exact amount, no operator slippage - [ ] `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) - [ ] 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
- [ ] bech32 encoding uses HRP `"lnurl"` and the result is uppercased per BOLT/LNURL convention - [ ] 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 - [ ] 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) - [ ] 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)

View file

@ -71,7 +71,7 @@ bitSpire/
4. Implements the four flow-critical `ATMServices` methods on top of LNbits: 4. Implements the four flow-critical `ATMServices` methods on top of LNbits:
- `generateInvoice(msat)` → cash-out BOLT11 - `generateInvoice(msat)` → cash-out BOLT11
- `watchInvoice(bolt11, cb)` → `subscribe_payments({payment_hash, max_seconds:600})` push - `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` - `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). `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_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_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_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 | | `VITE_OPERATOR_PUBKEYS` | optional | Comma-separated hex pubkeys allowed to send kind-21003 management commands |

View file

@ -45,7 +45,6 @@ docker logs regtest-lnbits-1 | grep 'Public key (share this)'
cat > apps/machine/.env <<EOF cat > apps/machine/.env <<EOF
VITE_RELAY_URL=ws://localhost:5001/nostrrelay/test VITE_RELAY_URL=ws://localhost:5001/nostrrelay/test
VITE_LNBITS_SERVER_PUBKEY=<paste pubkey from step 4> VITE_LNBITS_SERVER_PUBKEY=<paste pubkey from step 4>
VITE_LNBITS_HTTP_URL=http://localhost:5001
VITE_ATM_PRIVATE_KEY=$(openssl rand -hex 32) VITE_ATM_PRIVATE_KEY=$(openssl rand -hex 32)
EOF EOF

View file

@ -30,15 +30,16 @@ VITE_RELAY_URL=ws://localhost:7777
# docker logs lnbits | grep 'nostr_transport pubkey' # docker logs lnbits | grep 'nostr_transport pubkey'
VITE_LNBITS_SERVER_PUBKEY= VITE_LNBITS_SERVER_PUBKEY=
# LNbits HTTP root — used purely to compose the LNURL-withdraw callback # (LNbits HTTP URL is no longer needed on the ATM side — the
# URL that customer wallets dereference. The ATM itself does not call # nostr-transport RPC `lnurlw_create_link` now returns `link.lnurl`
# this URL; every ATM↔LNbits RPC goes over nostr-transport. # populated from `settings.lnbits_baseurl` on the LNbits server. See
VITE_LNBITS_HTTP_URL=http://localhost:5000 # aiolabs/withdraw#1 / commit e9d911e.)
# ============================================================================= # =============================================================================
# ATM Identity # ATM Identity
# ============================================================================= # =============================================================================
# pragma: allowlist secret
# ATM's Nostr private key (hex format, 64 characters). This signing # ATM's Nostr private key (hex format, 64 characters). This signing
# key IS the credential — LNbits derives the account from it on first # key IS the credential — LNbits derives the account from it on first
# contact (issue aiolabs/lnbits#9 alignment). # contact (issue aiolabs/lnbits#9 alignment).

View file

@ -283,7 +283,6 @@ ipcMain.handle('get-config', () => {
// LNbits nostr-transport connection (public info only) // LNbits nostr-transport connection (public info only)
relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777', relayUrl: process.env.VITE_RELAY_URL || 'ws://localhost:7777',
lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '', lnbitsServerPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY || '',
lnbitsHttpUrl: process.env.VITE_LNBITS_HTTP_URL || 'http://localhost:5000',
appId: process.env.VITE_APP_ID || '', appId: process.env.VITE_APP_ID || '',
// Hardware configuration // Hardware configuration

View file

@ -17,8 +17,6 @@ export interface RuntimeConfig {
relayUrl: string relayUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */ /** LNbits nostr-transport server pubkey (hex, 64 chars). */
lnbitsServerPubkey: string 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. */ /** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
lightningPubPubkey?: string lightningPubPubkey?: string
lightningPubApiUrl?: string lightningPubApiUrl?: string

View file

@ -28,7 +28,6 @@
"@bitSpire/lnbits": "workspace:*", "@bitSpire/lnbits": "workspace:*",
"@bitSpire/nostr-client": "workspace:*", "@bitSpire/nostr-client": "workspace:*",
"@bitSpire/state-machine": "workspace:*", "@bitSpire/state-machine": "workspace:*",
"@scure/base": "^1.2.0",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",
"better-sqlite3": "^11.0.0", "better-sqlite3": "^11.0.0",

View file

@ -19,7 +19,6 @@ import {
type MachineIdentity, type MachineIdentity,
} from '@bitSpire/nostr-client' } from '@bitSpire/nostr-client'
import { LnbitsClient } from '@bitSpire/lnbits' import { LnbitsClient } from '@bitSpire/lnbits'
import { bech32 } from '@scure/base'
import { CLINKClient } from '@bitSpire/clink' import { CLINKClient } from '@bitSpire/clink'
import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink' import type { OfferRequest, ManagementRequest, ManagementResponse } from '@bitSpire/clink'
import type { ATMServices, ATMContext } from '@bitSpire/state-machine' import type { ATMServices, ATMContext } from '@bitSpire/state-machine'
@ -50,13 +49,6 @@ interface LightningConfig {
operatorPubkeys: string[] operatorPubkeys: string[]
/** LNbits nostr-transport server pubkey (hex, 64 chars). */ /** LNbits nostr-transport server pubkey (hex, 64 chars). */
lnbitsServerPubkey: string 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<LightningConfig> {
appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID appId: '30270e761f2e30b1737f34ce661df45f521352b408b8ed18fcc09f3f0dec5097', // bitSpire ATM app ID
operatorPubkeys: [], operatorPubkeys: [],
lnbitsServerPubkey: '', lnbitsServerPubkey: '',
lnbitsHttpUrl: 'http://localhost:5000',
} }
if (isElectron && window.electronAPI) { if (isElectron && window.electronAPI) {
@ -91,7 +82,6 @@ async function loadLightningConfig(): Promise<LightningConfig> {
.filter(Boolean) .filter(Boolean)
: defaults.operatorPubkeys, : defaults.operatorPubkeys,
lnbitsServerPubkey: rc.lnbitsServerPubkey || defaults.lnbitsServerPubkey, lnbitsServerPubkey: rc.lnbitsServerPubkey || defaults.lnbitsServerPubkey,
lnbitsHttpUrl: rc.lnbitsHttpUrl || defaults.lnbitsHttpUrl,
} }
} catch (e) { } catch (e) {
console.warn('[Lightning] Failed to get runtime config from Electron:', e) console.warn('[Lightning] Failed to get runtime config from Electron:', e)
@ -105,9 +95,6 @@ async function loadLightningConfig(): Promise<LightningConfig> {
lnbitsServerPubkey: lnbitsServerPubkey:
(import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) || (import.meta.env.VITE_LNBITS_SERVER_PUBKEY as string | undefined) ||
defaults.lnbitsServerPubkey, defaults.lnbitsServerPubkey,
lnbitsHttpUrl:
(import.meta.env.VITE_LNBITS_HTTP_URL as string | undefined) ||
defaults.lnbitsHttpUrl,
operatorPubkeys: import.meta.env.VITE_OPERATOR_PUBKEYS operatorPubkeys: import.meta.env.VITE_OPERATOR_PUBKEYS
? (import.meta.env.VITE_OPERATOR_PUBKEYS as string) ? (import.meta.env.VITE_OPERATOR_PUBKEYS as string)
.split(',') .split(',')
@ -120,19 +107,6 @@ async function loadLightningConfig(): Promise<LightningConfig> {
// Config is loaded async now - will be set in initializeLightningServices // Config is loaded async now - will be set in initializeLightningServices
let CONFIG: LightningConfig 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. /** Safety timeout in ms (15 minutes) — absolute maximum LNURL session lifetime.
* Sessions are normally cleaned up by the state machine on idle transition. * 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. */ * 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. * Generate an LNURL-withdraw for cash-in.
* *
* 1. Create the link via LNbits transport (lnurlw_create_link). * 1. Create the link via LNbits transport (lnurlw_create_link). The
* 2. Compose the customer-facing callback URL from VITE_LNBITS_HTTP_URL * nostr-transport RPC returns `link.lnurl` populated from
* plus the link's unique_hash (the transport returns null for * `settings.lnbits_baseurl` on the LNbits side (see
* `lnurl`/`lnurl_url` — those are filled by HTTP views, not the * aiolabs/withdraw#1 / commit e9d911e) — no need to compose
* create RPC). Bech32-encode it ourselves with HRP "lnurl". * the callback URL ourselves anymore.
* 3. Subscribe to settlement pushes filtered by tag="withdraw" + * 2. Subscribe to settlement pushes filtered by tag="withdraw" +
* link_id; customer wallet redeems via HTTP, LNbits pushes us * link_id; customer wallet redeems via HTTP, LNbits pushes us
* over nostr, we trigger dispense. * over nostr, we trigger dispense.
*/ */
generateLnurlWithdraw: async (context: ATMContext): Promise<string> => { generateLnurlWithdraw: async (context: ATMContext): Promise<string> => {
console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats') 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 { try {
if (context.cashInSessionId) { if (context.cashInSessionId) {
invalidateLnurlSessionBySessionId(context.cashInSessionId) invalidateLnurlSessionBySessionId(context.cashInSessionId)
@ -707,8 +677,12 @@ function createATMServices(
is_unique: false, is_unique: false,
}) })
const callbackUrl = `${CONFIG.lnbitsHttpUrl.replace(/\/+$/, '')}/withdraw/api/v1/lnurl/${link.unique_hash}` if (!link.lnurl) {
const lnurl = encodeLnurl(callbackUrl) 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) { if (context.cashInSessionId) {
registerLnurlSession( registerLnurlSession(

View file

@ -6,8 +6,6 @@ export interface RuntimeConfig {
relayUrl: string relayUrl: string
/** LNbits nostr-transport server pubkey (hex, 64 chars). */ /** LNbits nostr-transport server pubkey (hex, 64 chars). */
lnbitsServerPubkey: string 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. */ /** Legacy LP fields — retained until 3d removes the LP backend. Optional. */
lightningPubPubkey?: string lightningPubPubkey?: string
lightningPubApiUrl?: string lightningPubApiUrl?: string

View file

@ -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 <lnbits-container> 2>&1 | \ LNBITS_SERVER_PUBKEY=$(docker logs <lnbits-container> 2>&1 | \
grep -oP 'Public key \(share this\):\s*\K[a-f0-9]{64}' | tail -1) grep -oP 'Public key \(share this\):\s*\K[a-f0-9]{64}' | tail -1)
LNBITS_HTTP_URL=http://<dev-lan-ip>:5001 \
RELAY_URL=ws://<dev-lan-ip>:5001/nostrrelay/test \ RELAY_URL=ws://<dev-lan-ip>:5001/nostrrelay/test \
LNBITS_SERVER_PUBKEY="$LNBITS_SERVER_PUBKEY" \ LNBITS_SERVER_PUBKEY="$LNBITS_SERVER_PUBKEY" \
ATM_PRIVATE_KEY=$(openssl rand -hex 32) \ ATM_PRIVATE_KEY=$(openssl rand -hex 32) \
@ -166,7 +165,6 @@ bash deploy/nixos/provision-atm.sh <sintra-lan-ip> 22
set -a; source ~/sintra-backup-<date>/.env; set +a set -a; source ~/sintra-backup-<date>/.env; set +a
ATM_PRIVATE_KEY=$VITE_ATM_PRIVATE_KEY \ ATM_PRIVATE_KEY=$VITE_ATM_PRIVATE_KEY \
LNBITS_SERVER_PUBKEY=$VITE_LNBITS_SERVER_PUBKEY \ LNBITS_SERVER_PUBKEY=$VITE_LNBITS_SERVER_PUBKEY \
LNBITS_HTTP_URL=$VITE_LNBITS_HTTP_URL \
RELAY_URL=$VITE_RELAY_URL \ RELAY_URL=$VITE_RELAY_URL \
bash deploy/nixos/provision-atm.sh <sintra-lan-ip> 22 bash deploy/nixos/provision-atm.sh <sintra-lan-ip> 22
``` ```
@ -204,12 +202,12 @@ Production ATMs on `main` continue to read `main`'s flake (no `?ref=` pin → re
| Path | Owner | Purpose | | Path | Owner | Purpose |
|------|-------|---------| |------|-------|---------|
| `/var/lib/bitspire/` | bitspire:bitspire, 0750 | Service data directory | | `/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/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/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 | | `/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`) | | `/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 ## Common operations
@ -266,7 +264,6 @@ ls -la /dev/serial/by-id/
enable = true; enable = true;
relayUrl = "wss://relay.aiolabs.dev"; # ATM ↔ LNbits relay relayUrl = "wss://relay.aiolabs.dev"; # ATM ↔ LNbits relay
lnbitsServerPubkey = "<64-hex>"; # LNbits transport pubkey lnbitsServerPubkey = "<64-hex>"; # LNbits transport pubkey
lnbitsHttpUrl = "https://lnbits.aiolabs.dev"; # LNURL callback URL prefix
appDir = "/opt/bitspire"; # rarely overridden — defaults via flake appDir = "/opt/bitspire"; # rarely overridden — defaults via flake
dataDir = "/var/lib/bitspire"; # rarely overridden dataDir = "/var/lib/bitspire"; # rarely overridden
logLevel = "info"; # error | warn | info | debug logLevel = "info"; # error | warn | info | debug

View file

@ -1,7 +1,13 @@
# bitSpire ATM Service Module # bitSpire ATM Service Module
# Manages the ATM Electron application and related services # Manages the ATM Electron application and related services
{ config, lib, pkgs, pkgs-unstable, ... }: {
config,
lib,
pkgs,
pkgs-unstable,
...
}:
with lib; 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 { appDir = mkOption {
type = types.path; type = types.path;
default = "/opt/bitspire"; default = "/opt/bitspire";
@ -53,7 +48,12 @@ in
}; };
logLevel = mkOption { logLevel = mkOption {
type = types.enum [ "error" "warn" "info" "debug" ]; type = types.enum [
"error"
"warn"
"info"
"debug"
];
default = "info"; default = "info";
description = "Logging level for the ATM application"; description = "Logging level for the ATM application";
}; };
@ -73,7 +73,11 @@ in
}; };
type = mkOption { type = mkOption {
type = types.enum [ "id003" "mei" "ccnet" ]; type = types.enum [
"id003"
"mei"
"ccnet"
];
default = "id003"; default = "id003";
description = "Bill validator protocol type"; description = "Bill validator protocol type";
}; };
@ -93,7 +97,10 @@ in
}; };
type = mkOption { type = mkOption {
type = types.enum [ "puloon" "genmega" ]; type = types.enum [
"puloon"
"genmega"
];
default = "puloon"; default = "puloon";
description = "Bill dispenser type"; description = "Bill dispenser type";
}; };
@ -128,7 +135,6 @@ in
# bitSpire ATM Configuration # bitSpire ATM Configuration
RELAY_URL=${cfg.relayUrl} RELAY_URL=${cfg.relayUrl}
LNBITS_SERVER_PUBKEY=${cfg.lnbitsServerPubkey} LNBITS_SERVER_PUBKEY=${cfg.lnbitsServerPubkey}
LNBITS_HTTP_URL=${cfg.lnbitsHttpUrl}
LOG_LEVEL=${cfg.logLevel} LOG_LEVEL=${cfg.logLevel}
DATA_DIR=${cfg.dataDir} DATA_DIR=${cfg.dataDir}
@ -153,7 +159,10 @@ in
systemd.services.bitspire = { systemd.services.bitspire = {
description = "bitSpire ATM Application"; description = "bitSpire ATM Application";
wantedBy = [ "graphical.target" ]; wantedBy = [ "graphical.target" ];
after = [ "graphical.target" "network-online.target" ]; after = [
"graphical.target"
"network-online.target"
];
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
serviceConfig = { serviceConfig = {
@ -180,7 +189,10 @@ in
NoNewPrivileges = true; NoNewPrivileges = true;
ProtectSystem = "strict"; ProtectSystem = "strict";
ProtectHome = true; ProtectHome = true;
ReadWritePaths = [ cfg.dataDir "/tmp" ]; ReadWritePaths = [
cfg.dataDir
"/tmp"
];
PrivateTmp = true; PrivateTmp = true;
# Allow device access for hardware # Allow device access for hardware
@ -196,7 +208,6 @@ in
preStart = '' preStart = ''
echo "bitSpire starting..." echo "bitSpire starting..."
echo "Relay: ${cfg.relayUrl}" echo "Relay: ${cfg.relayUrl}"
echo "LNbits HTTP: ${cfg.lnbitsHttpUrl}"
# Check bill validator if enabled # Check bill validator if enabled
if [ "${boolToString cfg.billValidator.enable}" = "true" ]; then if [ "${boolToString cfg.billValidator.enable}" = "true" ]; then

View file

@ -121,13 +121,12 @@ This document compares the bitSpire architecture — a Nostr-native Lightning AT
### Cash-in flow (customer hands ATM cash, gets sats) ### Cash-in flow (customer hands ATM cash, gets sats)
1. ATM publishes `lnurlw_create_link` (kind-21000) to LNbits with `{uses: 1, max_withdrawable: <sats>}` 1. ATM publishes `lnurlw_create_link` (kind-21000) to LNbits with `{uses: 1, max_withdrawable: <sats>}`
2. LNbits replies with a `WithdrawLink` (a `unique_hash` plus metadata) 2. LNbits replies with a `WithdrawLink` carrying a `lnurl` field already populated from `settings.lnbits_baseurl` (per aiolabs/withdraw#1 / `e9d911e`)
3. ATM composes the callback URL: `{LNBITS_HTTP_URL}/withdraw/api/v1/lnurl/{unique_hash}` and bech32-encodes it as an LNURL 3. ATM displays the LNURL as a QR (uppercased per BOLT/LNURL convention)
4. ATM displays the LNURL as a QR 4. Customer scans with any LNURL-withdraw-capable wallet, redeems it
5. Customer scans with any LNURL-withdraw-capable wallet, redeems it 5. LNbits settles the withdrawal via its Lightning backend
6. 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. 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)
8. ATM marks the session complete (cash already physically accepted earlier in the flow)
### Characteristics ### Characteristics

View file

@ -195,7 +195,6 @@
cp ${pkgs.writeText "bitspire-env-default" '' cp ${pkgs.writeText "bitspire-env-default" ''
VITE_RELAY_URL= VITE_RELAY_URL=
VITE_LNBITS_SERVER_PUBKEY= VITE_LNBITS_SERVER_PUBKEY=
VITE_LNBITS_HTTP_URL=
VITE_ATM_PRIVATE_KEY= VITE_ATM_PRIVATE_KEY=
VITE_APP_ID= VITE_APP_ID=
VITE_OPERATOR_PUBKEYS= VITE_OPERATOR_PUBKEYS=

3
pnpm-lock.yaml generated
View file

@ -38,9 +38,6 @@ importers:
'@bitSpire/state-machine': '@bitSpire/state-machine':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/state-machine version: link:../../packages/state-machine
'@scure/base':
specifier: ^1.2.0
version: 1.2.6
'@tanstack/vue-table': '@tanstack/vue-table':
specifier: ^8.21.3 specifier: ^8.21.3
version: 8.21.3(vue@3.5.27(typescript@5.9.3)) version: 8.21.3(vue@3.5.27(typescript@5.9.3))