bitspire/CLAUDE.md
Padreug 4f68ddc40b 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>
2026-06-01 20:33:28 +02:00

12 KiB

CLAUDE.md

Guidance for Claude Code when working in this repo. Read this before touching code.

Project Overview

bitSpire is a Nostr-native Lightning ATM. Production ATMs (batm3, douro) currently run from main against Lightning.Pub; the dev branch — which is what this file describes — has been migrated to LNbits over the nostr-native-transport.

Core principles:

  • KYC-free — no identity collection, no compliance theater
  • No HTTP to the Lightning backend — every ATM↔LNbits RPC goes over kind-21000 NIP-44 v2 events on a relay
  • The ATM's nostr private key IS the credential — LNbits auto-creates the wallet on first contact (issue aiolabs/lnbits#9 alignment, prvkey=NULL)
  • Open source first — every component auditable and forkable

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.

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.

bitSpire is an independent project under AGPL-3.0 and is not affiliated with Lamassu Industries AG.

Branch model

  • main — production. Lightning.Pub backend. The two production ATMs auto-pull from here daily at 04:00 (flake.nix:152-160). DO NOT push to main casually — a wrong commit gets baked into prod ATMs the next morning.
  • dev — staging. LNbits backend. The Sintra dev unit auto-pulls from here (?ref=dev pin on this branch's flake.nix). Push freely; tag pre-bitspire-cutover is the rollback target if the migration ever needs to be reverted on prod.

Architecture

bitSpire/
├── apps/
│   └── machine/          # Electron + Vue 3 ATM kiosk
├── packages/
│   ├── nostr-client/     # NIP-01 client, NIP-44 v2 (encryptContentV2/decryptContentV2)
│   ├── lnbits/           # LnbitsClient — kind-21000 RPC over nostr-transport
│   ├── clink/            # CLINK protocol (kinds 21001-21003) — IN TREE BUT UNUSED ON DEV
│   ├── state-machine/    # XState v5 ATM state machine
│   ├── hal/              # Hardware abstraction (JCM iVIZION, Fujitsu F56, etc.)
│   ├── cashu/            # placeholder
│   └── ui-shared/        # placeholder
└── deploy/nixos/         # NixOS module + provisioning for Sintra/tejo/douro/batm3

packages/lightning/ (the Lightning.Pub RPC client) was deleted on dev in commit a51d7af. If a piece of code on dev needs LP semantics, look at the equivalent in packages/lnbits/ or in apps/machine/src/services/lightning.ts (which has a LightningBackend interface implemented by an LNbits-backed adapter).

Package status

Package Scope Notes
@bitSpire/nostr-client active NIP-01 + NIP-44 v2 helpers. Used by every other package.
@bitSpire/lnbits active Wraps the nostr-native-transport API (10 core RPCs + lnurlw/lnurlp + subscribe_payments). Mirror peer of the old @bitSpire/lightning.
@bitSpire/state-machine active XState v5. Tests pass. generateNdebit / generateClinkOffer / generateNoffer services are stubs returning placeholder strings (state machine contract preserved; outputs unused by views).
@bitSpire/hal active TypeScript HAL. JCM iVIZION + Fujitsu F56 drivers.
@bitSpire/clink dormant Code kept for future re-wire if direct nostr-to-nostr cash-in returns. Don't import on dev — services/lightning.ts no longer wires it.
@bitSpire/cashu placeholder empty
@bitSpire/ui-shared placeholder empty

Backend wiring

apps/machine/src/services/lightning.ts is the central file. After the 3d cutover it:

  1. Loads config from Electron IPC (get-config + one-shot get-atm-secrets) or from import.meta.env (browser dev)
  2. Creates an LnbitsClient, calls list_wallets, picks the first wallet — fails-fast if either step doesn't return data
  3. Exposes a tiny LightningBackend adapter on services.lightningPub that satisfies apps/machine/src/stores/atm.ts's expectations:
    • getBalance() → wraps lnbits.getBalance(walletId)
    • watchBalance(cb) → wraps lnbits.watchWallet and refetches balance per push
    • createInvoice({amountSats, description}) → wraps lnbits.createInvoice(walletId, {amount, memo, unit:'sat'})
    • payInvoice(bolt11, amountSats) → wraps lnbits.payInvoice(walletId, {bolt11, max_sat})
  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, ...}), 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).

Environment variables

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_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.

Commands

# Inside devenv shell
pnpm install                 # install all workspace deps
pnpm dev                     # start the machine app (Vite + Electron with hot reload)
pnpm typecheck               # vue-tsc --noEmit
pnpm test                    # vitest across all packages

# In apps/machine specifically
cd apps/machine && pnpm build  # full prod build: vue-tsc + vite build + electron tsc + fund-atm esbuild bundle

Disk image build (for Sintra/douro/etc.):

nix build .#disk-image-sintra   # → result/nixos.img

Nostr event kinds

Kind Role Status on dev
21000 LNbits nostr-native-transport (request + ack + push) active — all backend traffic
21001 CLINK Offer (invoice request/response) dormant
21002 CLINK Debit (payment authorization) dormant
21003 CLINK Manage (operator commands) partially wired — clink.onManagement still listens for operator dispense, decoupled from LP
30078 Service Beacon (replaceable, ATM availability) active — startAvailabilityBroadcast() heartbeats every 5 min
30079 Transaction Record (replaceable) planned

Wire envelope (kind 21000 RPC)

Plaintext JSON, encrypted as NIP-44 v2 inside the event content:

// Request (client → server)
{
  "rpc_name": "create_invoice",
  "request_id": "create_invoice-7-ab12cd",
  "wallet_id": "<uuid>",
  "body": { "amount": 1000, "memo": "...", "unit": "sat" }
}

// Reply (server → client, one-shot)
{
  "status": "OK",
  "request_id": "create_invoice-7-ab12cd",
  "data": { /* Payment object */ }
}

// Subscription push (server → client, repeated)
{
  "status": "OK",
  "request_id": "sub-9-ef34gh",
  "subscription_id": "<server-issued>",
  "data": { "payment": { /* Payment */ } }
}

// Subscription close (server → client, terminal)
{
  "status": "OK",
  "request_id": "sub-9-ef34gh",
  "subscription_id": "<server-issued>",
  "data": { "closed": true, "reason": "ttl" | "unsubscribed" }
}

See packages/lnbits/src/types.ts for the TypeScript surface and ~/dev/lnbits/nostr-transport/docs/devs/nostr-transport.md for the authoritative wire spec.

Hardware drivers

TypeScript drivers in packages/hal/. Coverage by device class:

Category Drivers
Validators id003, ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50
Dispensers puloon, f56, genmega, hcm2, gsr50
Recyclers MEI SCR (planned — hardware in BATM3, no driver yet)
Printers nippon, zebra, genmega

Sintra hardware specifics (Aaeon UP Board)

  • Validator (JCM iVIZION, ID003): FTDI USB-serial bridge → /dev/ttyUSB1 → udev symlink /dev/ttyJ5
  • Dispenser (Fujitsu F56): SoC MMIO UART (the only on-carrier RS-232) → /dev/ttyS4 → udev symlink /dev/ttyJ7. ttyS4 must NOT be the kernel console — upboard.nix keeps console=tty0 only
  • The kernel-enumerated ttyS1/ttyS2/ttyS3 are placeholder nodes that error on any I/O — the legacy 16550A is ttyS0, the SoC MMIO is ttyS4. Confirm with dmesg \| grep ttyS

Initrd modules for eMMC boot

UP Board enumerates its eMMC controller via ACPI, not PCI. upboard.nix force-loads sdhci-acpi and mmc_block in initrd.kernelModules so root-by-label resolves in stage 1. If you ever rebuild the disk image for a different SoC, double-check this list.

Code style

  • TypeScript: ESM only, strict mode + strictNullChecks + noUncheckedIndexedAccess. No any. Zod for runtime validation at boundaries.
  • Vue 3: Composition API, <script setup lang="ts">. Pinia for state.
  • Formatting: Prettier (2 spaces, no semicolons, single quotes). Pre-commit hooks enforce.
  • Tests: Vitest. Cash-out is the critical-path flow — keep packages/state-machine coverage tight.

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.
  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.

Useful invariants when debugging

  • The renderer logs prefix every line with a tag: [Lightning], [ATM], [ATM Service], [LNURL Session], [CLINK], [StateStore]. journalctl -u bitspire | grep '\[' is your friend.
  • bitspire.service runs as the lamassu user; /var/lib/bitspire is its dataDir (ReadWritePaths). DB lives at /var/lib/bitspire/state.db (we previously had /var/lib/lamassu-atm — that path is gone on dev, see commit 9c455d6).
  • The lightning.lightningPub field on LightningServices is a LightningBackend adapter, not a LightningPubClient. Don't try to call LP-only methods on it.
  • docs/machine-installation.md — Sintra deployment walkthrough (build, flash, provision)
  • deploy/nixos/README.md — NixOS module options, runtime config layout
  • docs/architecture-comparison.md — Nostr-native vs traditional lamassu-server
  • .claude/skills/*.md — Custom skills (/security, /nostr-check, /test, etc.)

External: