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.
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
Provenance + legal status
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, whosenixos-upgradeunit pullsgit+ssh://…/bitspire.git?ref=dev#batm3-installeddaily 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. Tagpre-bitspire-cutoveris the rollback target if the migration ever has to be reverted.
This section previously said the production ATMs ran
mainagainst Lightning.Pub and that only Sintra was ondev. 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-upgradegives the branch,/var/lib/bitspirevs/var/lib/lamassu-atmgives 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.yamlchanges require re-derivingpnpmDeps.hashinnix/mkAtmApp.nix(blank it, build, paste thegot:value). Nix reuses the stale fixed-output store otherwise and the sandboxedpnpm install --offlinefails withERR_PNPM_NO_OFFLINE_TARBALL. A passing localpnpm buildsays nothing about the nix build.- Activation scripts run with a minimal PATH — coreutils yes,
grep/sedno. Reference${pkgs.gnugrep}/bin/grep/${pkgs.gnused}/bin/sedby store path; the.envmigration printed success and then died 127. nixos-rebuild switch --flake .#<model>-installed --target-host <model> --sudo --use-substitutesfrom 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:
- Loads config from Electron IPC (
get-config+ one-shotget-atm-secrets) or fromimport.meta.env(browser dev) - Creates an
LnbitsClient, callslist_wallets, picks the first wallet — fails-fast if either step doesn't return data - Exposes a tiny
LightningBackendadapter onservices.lightningPubthat satisfiesapps/machine/src/stores/atm.ts's expectations:getBalance()→ wrapslnbits.getBalance(walletId)watchBalance(cb)→ wrapslnbits.watchWalletand refetches balance per pushcreateInvoice({amountSats, description})→ wrapslnbits.createInvoice(walletId, {amount, memo, unit:'sat'})payInvoice(bolt11, amountSats)→ wrapslnbits.payInvoice(walletId, {bolt11, max_sat})
- Implements the four flow-critical
ATMServicesmethods on top of LNbits:generateInvoice(msat)→ cash-out BOLT11watchInvoice(bolt11, cb)→subscribe_payments({payment_hash, max_seconds:600})pushgenerateLnurlWithdraw(ctx)→lnurlw_create_link({uses:1, ...}), uselink.lnurldirectly (LNbits populates it fromsettings.lnbits_baseurlper aiolabs/withdraw#1 /e9d911e), subscribe fortag:"withdraw", link_idsettlement pushgetAvailableBalance()→ wrapslnbits.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:
- captures + decodes via a
PairingSource(src/services/pairing/) — camera today (decode throughqr, paulmillr's zero-dep lib), NFC scaffolded; - validates the scan parses as a spire-seed (
ingestScannedSeed), rejecting a stray QR; - persists it as
VITE_SPIRE_SEEDvia thestate:save-spire-seedIPC 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.nixkeepsconsole=tty0only - The kernel-enumerated
ttyS1/ttyS2/ttyS3are placeholder nodes that error on any I/O — the legacy 16550A isttyS0, the SoC MMIO isttyS4. Confirm withdmesg \| 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. Noany. 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-machinecoverage tight.
Security priorities
- 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 instate.db(bunker_binding). The operator's signing key stays in the bunker. The legacyVITE_ATM_PRIVATE_KEYis a dev-only fallback. - Payments — Validate the bolt11 amount on cash-out before exposing the QR. Decode
payment_hashfrom the bolt11 (cheap, avoids a roundtrip) and use it as thesubscribe_paymentsfilter. - Replay — LNURL-withdraw links use
uses:1and are deleted on session abort. - 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 asmsg: [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.serviceruns as thebitspireuser (verified on sintra 2026-09-24; this line used to saylamassu, left over from the rename in46e52f6);/var/lib/bitspireis itsdataDir(ReadWritePaths). DB lives at/var/lib/bitspire/state.db(we previously had/var/lib/lamassu-atm— that path is gone on dev, see commit9c455d6).- The
lightning.lightningPubfield onLightningServicesis aLightningBackendadapter, not aLightningPubClient. Don't try to call LP-only methods on it.
Related documentation
docs/machine-installation.md— Sintra deployment walkthrough (build, flash, provision)deploy/nixos/README.md— NixOS module options, runtime config layoutdocs/architecture-comparison.md— Nostr-native vs traditional lamassu-server.claude/skills/*.md— Custom skills (/security,/nostr-check,/test, etc.)
External:
- LNbits nostr-transport docs — wire spec
- NIP-44 encryption
- CLINK Protocol Spec (historical reference)