bitspire/CLAUDE.md
Padreug 23bd54738d docs(claude): sintra's nightly also fails on the timeout; the push-cache rule, the pnpm hash rule, the activation PATH rule
The fleet table called sintra a working dev unit; its nixos-upgrade had
failed every night 2026-10-06 → 10-09 on the same 60 s atm-app timeout
as batm3, so nothing merged that week reached it. Recorded with the
interim rule — push-cache after every dev push, because mkAtmApp's
src = self makes any commit invalidate the cached toplevel — and the two
traps found while applying it: a lockfile change silently reuses a stale
pnpmDeps store unless the hash is re-derived, and activation scripts
don't have grep/sed on PATH.
2026-10-10 22:09:39 +02:00

20 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 running LNbits over the nostr-native-transport. The dev branch, which this file describes, is what the machines run.

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.

Reference permission (2026-10-09). The maintainer taking over the Lamassu codebase has given us permission to use lamassu-machine and lamassu-server — including post-boundary code — as prior art in any way that improves this codebase (relayed by padreug, 2026-10-09; the earlier hard rule — reference only c0b69d1 or earlier — is superseded). Prefer to reference over port: read it, take the behaviour model, reimplement in our idiom. When a block is ported verbatim, say so in the commit, with the source commit, so the provenance is in git log. The pre-boundary tree needs no permission at all and is the first place to look.

Boundaries, for the record. lamassu-machine's last public-domain commit is c0b69d1 (2023-09-19, v8.6.0-beta.9); a9234d124d added Appendix A the same day, so every 8.1.5+ tag is proprietary. lamassu-server's last public-domain commit is adbc9709 (2023-09-19, v8.6.0-beta.9), licence added in d06a8f54 the same day. Both GitHub repos are gone (404); full history survives in ~/dev/repos/ and at Software Heritage (crawls 2026-03-02 and 2026-07-19, tips identical to the local mirrors). ~/lamassu/lamassu-server is a squashed v12 repo whose first commit (e2c49ea, 2025-12-31) already carries Appendix A — it has no open era to reference; use ~/dev/repos/ for that. ~/lamassu/ also holds the 33 surviving github.com/lamassu dependency repos (cloned 2026-09-27) and bnr-xfs-salvage/ (the MIT bnr-xfs / bnr crates, checksums verified). The curated Lamassu Port Backlog (claude.ai artifact 61af38f6, 2026-09-27) ranks what is worth taking and tags each item's provenance.

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

Branch model

  • dev — what every live machine runs. Not a staging branch any more. Verified 2026-09-24 on batm3, whose nixos-upgrade unit pulls git+ssh://…/bitspire.git?ref=dev#batm3-installed daily at 04:00. "Push freely to dev" is no longer safe advice: a bad commit reaches production hardware the next morning, unattended.
  • main — Lightning.Pub era, historical. Tag pre-bitspire-cutover is the rollback target if the migration ever has to be reverted.

This section previously said the production ATMs ran main against Lightning.Pub and that only Sintra was on dev. That was stale and it was repeatedly taken at face value. Check the machine, not this file, before relying on which stack a given box runs: systemctl cat nixos-upgrade gives the branch, /var/lib/bitspire vs /var/lib/lamassu-atm gives the era.

Fleet state (surveyed 2026-09-24)

Machine Reachable Stack GPU Notes
sintra LAN 192.168.0.252 dev / LNbits Braswell 8086:22b0 → crocus dev unit; ethernet r8169. Its nightly upgrade failed every night 2026-10-06 → 10-09 on the same 60 s timeout as batm3 (below); nothing merged that week reached it until a cachix push on 10-10
batm3 wg 10.0.0.5 dev / LNbits Haswell GT2 8086:0412 → crocus networks over WiFi, iwlwifi 7260; ethernet down
douro down — Bay Trail (Gen7) needs reflashing with the current image and reconnecting to WireGuard
tejo wg 10.0.0.3 Debian (ubilinux4, kernel 4.9) Braswell 8086:22b0 never had bitspire installed; a flake target, not a deployment

Two consequences worth holding onto. Every GPU in the fleet binds crocus, not iris — sintra's Braswell does so despite being Gen8. And batm3's only working network path is Intel WiFi, so intel/iwlwifi firmware is load-bearing there; trimming it would strand the machine with no way back in.

The nightly upgrade fails on any machine that has to build the app (batm3, and sintra too)

Confirmed on batm3 2026-09-24 and on sintra 2026-10-10 (failing since 10-06). The run dies at:

04:03:26 building '…-bitspire-atm-app-0.1.0.drv'...
04:04:28 error: timed out after 60 seconds

The ATM app is built in-house and is not in aiolabs.cachix.org or cache.nixos.org, so batm3 has to build it locally, and nix.settings.timeout = 60 in flake.nix kills it. The comment there assumes heavy derivations are "effectively cache-only … upstream-cached", which is true of nixpkgs and false of our own app.

So the machine is pinned to whatever generation last succeeded, and nothing merged to dev reaches it. This is the same class of silent-updater failure as #98, in a new form. The fix is pushing atm-app-* to the aiolabs cachix as part of releasing, not raising the timeout — a 60s ceiling on ATM hardware is correct.

Interim rule (2026-10-10): every push to dev is followed by ./deploy/push-cache.sh sintra && ./deploy/push-cache.sh batm3 from bohm (cachix is authenticated there). mkAtmApp takes src = self — the whole flake tree — so any commit, docs included, changes the app derivation and a cached toplevel no longer matches what ?ref=dev resolves to. Three more things that bit on 10-10:

  • pnpm-lock.yaml changes require re-deriving pnpmDeps.hash in nix/mkAtmApp.nix (blank it, build, paste the got: value). Nix reuses the stale fixed-output store otherwise and the sandboxed pnpm install --offline fails with ERR_PNPM_NO_OFFLINE_TARBALL. A passing local pnpm build says nothing about the nix build.
  • Activation scripts run with a minimal PATH — coreutils yes, grep/sed no. Reference ${pkgs.gnugrep}/bin/grep / ${pkgs.gnused}/bin/sed by store path; the .env migration printed success and then died 127.
  • nixos-rebuild switch --flake .#<model>-installed --target-host <model> --sudo --use-substitutes from bohm is the fast manual path once the cache has the toplevel: store hit here, closure copied, nothing built on the UP board.

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, ebds
Dispensers f56, puloon
Recyclers none — MEI SCR hardware in BATM3; a clean-room BNR Advance route exists via the MIT bnr-xfs crate (see the port backlog)
Printers none — packages/hal/src/printers/ does not exist

This table previously listed ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 and three printers that are not in the tree (corrected 2026-10-09). packages/hal/src also carries orphaned *.rs files (lib.rs, error.rs, mod.rs, traits.rs, mock.rs) from an abandoned Rust HAL; they are not built.

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 bitspire user (verified on sintra 2026-09-24; this line used to say lamassu, left over from the rename in 46e52f6); /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: