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

236 lines
15 KiB
Markdown

# 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 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
```bash
# 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.):
```bash
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`:
```jsonc
// 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.
## Related documentation
- `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:
- [LNbits nostr-transport docs](~/dev/lnbits/nostr-transport/docs/devs/nostr-transport.md) — wire spec
- [NIP-44 encryption](https://github.com/nostr-protocol/nips/blob/master/44.md)
- [CLINK Protocol Spec](https://github.com/shocknet/clink) (historical reference)