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.
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
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.
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 tomaincasually — a wrong commit gets baked into prod ATMs the next morning.dev— staging. LNbits backend. The Sintra dev unit auto-pulls from here (?ref=devpin on this branch'sflake.nix). Push freely; tagpre-bitspire-cutoveris 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:
- 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, 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.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 thelamassuuser;/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)