Closes gap 2 from coord log 2026-06-01T18:30Z. The LNbits withdraw extension's nostr-transport RPC now populates `link.lnurl` from `settings.lnbits_baseurl` (aiolabs/withdraw#1 / commit e9d911e), so the ATM no longer needs a separate HTTP URL on the wire to compose the LNURL-withdraw callback itself. What goes: - `VITE_LNBITS_HTTP_URL` env var (renderer + Electron main) - `lnbitsHttpUrl` field on `LightningConfig`, `RuntimeConfig`, and the Window mirror in `src/types/electron.d.ts` - The manual `${lnbitsHttpUrl}/withdraw/api/v1/lnurl/${unique_hash}` composition in `generateLnurlWithdraw` - The `encodeLnurl` bech32 helper in `lightning.ts` (LNbits returns bech32-encoded; we just `.toUpperCase()` to match BOLT/LNURL convention) - `@scure/base` dep from `apps/machine/package.json` (only used by the removed helper; clink still uses it directly) - The `lnbitsHttpUrl` option + `LNBITS_HTTP_URL=…` env var + boot echo in `deploy/nixos/bitspire-atm.nix` - Doc references in CLAUDE.md, README.md, deploy/nixos/README.md, docs/architecture-comparison.md, and the lightning-check skill What stays: - `link.lnurl` consumption, with an explicit error if LNbits returns null (which signals `LNBITS_BASEURL` is unset on the server side — better to fail clearly than silently) - The receiver-side bech32 uppercasing (LNbits returns lowercase per the standard library) Why this is a net win: - Removes a config-drift surface — if LNbits's external URL moved (DNS, port, reverse-proxy rewrite), every ATM in the field would stop issuing redeemable LNURL-withdraw QRs until reconfigured. Now LNbits derives its own URL from `settings.lnbits_baseurl`, one source of truth. - Removes an extra provisioning step. No more `LNBITS_HTTP_URL=…` before running `provision-atm.sh`; the relay + server pubkey suffice. - Removes the misleading boot echo that triggered the §`18:30Z` smoke triage confusion ("LNbits HTTP: <url>" read like ATM-→-LNbits connectivity, when it was only ever a URL embedded in customer QRs). Also adds a `# pragma: allowlist secret` marker above the `VITE_ATM_PRIVATE_KEY` doc block in `.env.example` so the global secret scanner stops false-positiving on the documentation prose. Workspace typecheck + 24/24 apps/machine tests still green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
12 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 open-source lamassu-machine and lamassu-server repositories, only up to v8.1.5 — the last release published under a fully-open license. Lamassu transitioned to a proprietary, source-available license (their custom "Appendix A SLA") on 2024-01-26 and gated v8.1.6+ behind a paid OSA subscription.
Hard rule when working in this repo: do not pull, port, or copy code from lamassu-machine / lamassu-server at v8.1.6 or later. If a HAL bug fix or feature exists upstream past 8.1.5, either (a) reimplement from protocol docs / hardware specs without looking at v8.1.6+ source, or (b) raise the question with the maintainer first. The 8.1.5 tree is fair game; everything after is licensed code we have no rights to.
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 |
yes | ws://... of the relay both ATM and LNbits subscribe to. Dev: ws://localhost:5001/nostrrelay/test (LNbits's bundled nostrrelay extension — no separate strfry container) |
VITE_LNBITS_SERVER_PUBKEY |
yes | 64-char hex pubkey LNbits prints on startup (docker logs lnbits | grep 'Public key (share this)') |
VITE_ATM_PRIVATE_KEY |
yes (prod) | 64-char hex. The ATM's nostr identity. Generates ephemeral on first boot if unset (dev only) |
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.
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. The ATM's
VITE_ATM_PRIVATE_KEYlives in/var/lib/bitspire/.envwith mode 0600, owned bybitspire:bitspire. - 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. 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)