bitspire/CLAUDE.md
Padreug a68e462462 fix(logging): interpolate log fields instead of passing an object
Electron's console bridge stringifies each console argument on its way to
the journal, so `console.log('msg:', { a, b })` arrives as
`msg: [object Object]` and every field is lost.

That cost a debugging session today: a cassette-state publish that had
in fact applied an operator refill correctly looked from the journal like
nothing had happened, because the d-tag, event id and stamp were all
inside the object.

Three call sites, the only ones in the renderer passing an object. The
publish line now also carries seq and the applied-op count, which are the
two things worth knowing when an operation seems not to have landed.

Recorded in CLAUDE.md's debugging invariants so it does not come back.
2026-09-23 23:55:13 +02:00

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

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 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:<base64url>) 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

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

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.
  • Never pass an object as a console argument in the renderer. Electron's console bridge stringifies each argument, so console.log('msg:', { a, b }) reaches the journal as msg: [object Object] and every field is lost. Interpolate instead. Cost a debugging session on 2026-09-23, when a cassette publish that had worked looked like it had done nothing.
  • 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: