- TypeScript 56.4%
- Vue 11.2%
- JavaScript 9.4%
- Shell 9%
- Nix 8.1%
- Other 5.9%
Three protocol corrections from Pyramid's spec (RS_232 Rev G), all of which this driver had guessed at because it was written clean-room without it. **Escrow was never enabled.** BYTE 1 bit 4 is an enable, and the driver left it clear on every poll. With it clear the acceptor does not stop at escrow, so the host is never offered the stack-or-return decision and notes are banked before anything has validated them. The entire FSM here is built around that decision point, so this was not a missing nicety — the driver's central flow could not have happened. It is now asserted on every poll. **Return used the wrong mechanism.** The driver expressed "give the note back" by zeroing the enable mask mid-escrow, under an in-code assumption that "Apex has no distinct return opcode". It has one: BYTE 1 bit 6. The old approach was flagged in a comment as needing hardware verification; the spec settles it instead. Note the spec also distinguishes Returning (host refused a valid note) from Rejected (acceptor judged it invalid), which is the distinction this bit exists to express. **The checksum range was hardcoded to the host frame.** computeChecksum always XORed bytes 1..5, which is right for the 8-byte poll and wrong for the 11-byte reply, where it should span 1..8. So every reply failed validation. That was masked by the check being non-fatal "pending hardware verification", which logged a warning and parsed anyway. The range is now derived from the frame length, and verified against the two reset frames the spec spells out literally with their checksums — the only ground truth available without hardware. Those same frames are now a test. With the range confirmed, a mismatch becomes a hard drop rather than a warning. A corrupt frame carries a denomination field, and crediting a note from a frame known to be damaged is the one outcome worth refusing. The raw bytes are logged so a systematic framing error stays diagnosable. Also records two operational facts from the spec that were not written down: the interface is Mars/MEI GL5-compatible (hence the resemblance to the EBDS driver), and polls must not fall more than 5s apart or the acceptor may dump an escrowed note and stop accepting until the host resumes. Our 100ms cadence is comfortably inside that. |
||
|---|---|---|
| .claude/skills | ||
| apps/machine | ||
| deploy | ||
| docker | ||
| docs | ||
| nix | ||
| packages | ||
| scripts | ||
| .devenv.flake.nix | ||
| .gitignore | ||
| .prettierrc | ||
| CLAUDE.md | ||
| devenv.lock | ||
| devenv.nix | ||
| devenv.yaml | ||
| flake.lock | ||
| flake.nix | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| tsconfig.json | ||
| turbo.json | ||
bitSpire
A Nostr-native Lightning ATM. KYC-free, open source, auditable. Talks to its Lightning backend over the nostr-native-transport (kind-21000 NIP-44 v2) on a relay — never HTTP — so the kiosk has no admin tokens to leak and no API surface to attack.
Originally
lamassu-next. Renamed during the LNbits-backend transition on thedevbranch (commits leading up to 2026-05-13). Production ATMs (batm3,douro) still run frommainagainst Lightning.Pub until cutover; this README describes thedevbranch state.
What the ATM actually does
| Flow | Customer side | ATM side |
|---|---|---|
| Cash-out (customer pays ATM, gets cash) | scans BOLT11 invoice, pays from any LN wallet | lnbits.createInvoice() over nostr → subscribe_payments({payment_hash}) push fires on settlement → dispense |
| Cash-in (customer hands ATM cash, gets sats) | scans LNURL-withdraw QR, redeems with any LN wallet that supports LNURL-w | lnbits.createWithdrawLink({uses:1}) over nostr → subscribe_payments({tag:"withdraw", link_id}) push fires when LNbits settles → mark complete |
No HTTP to the Lightning backend. No admin tokens on the kiosk. The ATM's nostr private key is its credential — LNbits auto-creates the wallet on first contact via the signature (see aiolabs/lnbits#9).
Prerequisites
- Nix with flakes enabled
- devenv
- Docker + Docker Compose
- A running LNbits instance with the
nostr-native-transportbranch built in. The local dev compose lives at~/dev/local/docker/regtest— that ships an LNbits with the transport pre-enabled and the relevant extensions (withdraw,lnurlp,nostrrelay) installed.
The nostrrelay extension inside LNbits is what the ATM connects to — there is no separate strfry/khatru container in the dev compose. The relay URL is ws://<host>:5001/nostrrelay/test.
Quick Start
# 1. Clone
git clone ssh://forgejo@git.atitlan.io/aiolabs/lamassu-next.git
cd lamassu-next # repo name kept for now — rename to bitSpire is a follow-up
git checkout dev
# 2. Enter the dev environment
devenv shell
# 3. Install JS deps
pnpm install
# 4. Start the regtest stack (bitcoind, LNDs, LNbits with nostr-transport, relay)
cd ~/dev/local/docker/regtest && ./start-regtest
docker logs regtest-lnbits-1 | grep 'Public key (share this)'
# → copy that pubkey, you'll need it as VITE_LNBITS_SERVER_PUBKEY
# 5. Configure the machine app for the dev LNbits
cat > apps/machine/.env <<EOF
VITE_RELAY_URL=ws://localhost:5001/nostrrelay/test
VITE_LNBITS_SERVER_PUBKEY=<paste pubkey from step 4>
VITE_ATM_PRIVATE_KEY=$(openssl rand -hex 32)
EOF
# 6. Run the kiosk in browser dev mode
cd apps/machine && pnpm dev
The kiosk should come up at http://localhost:5173, log [Lightning] LNbits client initialized, and report a wallet id once LNbits auto-creates one for the ATM's pubkey.
Architecture
bitSpire/
├── apps/
│ └── machine/ # Electron + Vue 3 ATM kiosk
├── packages/
│ ├── nostr-client/ # NIP-01 relay client, NIP-44 v2 encryption
│ ├── lnbits/ # LnbitsClient — talks to LNbits over kind-21000 transport
│ ├── clink/ # CLINK protocol (kind-21001/2/3) — still in tree as a
│ │ # reference; unused on dev since the LNbits backend
│ │ # does cash-in via LNURL-withdraw and cash-out via
│ │ # BOLT11, both of which subsume CLINK's role
│ ├── hal/ # Hardware abstraction (JCM iVIZION validator, F56 dispenser)
│ ├── state-machine/ # XState v5 state machine driving cash-out + cash-in
│ ├── cashu/ # Cashu ecash (placeholder)
│ └── ui-shared/ # Shared Vue components (placeholder)
└── deploy/nixos/ # NixOS module + provisioning script for Sintra/tejo/douro/batm3
packages/lightning/ (the Lightning.Pub RPC client) was removed on dev — see git log packages/lightning on main for the historical sources.
Deploying to real hardware
The Sintra/tejo/douro/batm3 disk-image pipeline lives in flake.nix + deploy/nixos/. See deploy/nixos/README.md for the full flow; the abbreviated path:
# Build the disk image for a Sintra
nix build .#disk-image-sintra
# → result/nixos.img
# Flash to a USB stick
sudo dd if=result/nixos.img of=/dev/sdX bs=4M status=progress conv=fsync && sync
# Boot Sintra from the USB, dd onto the eMMC from inside Alpine live (see
# deploy/nixos/README.md), then provision the .env from the dev box:
bash deploy/nixos/provision-atm.sh <sintra-lan-ip>
The auto-upgrade timer (flake.nix:152-160) pulls dev daily at 04:00, so the Sintra stays in sync with whatever's on the dev branch. Production ATMs run from main and are unaffected.
Documentation
| Document | Description |
|---|---|
| docs/machine-installation.md | Step-by-step Sintra install: build, flash, provision |
| deploy/nixos/README.md | NixOS module options, runtime config layout, hardware variants |
| docs/architecture-comparison.md | Nostr-native ATM vs traditional lamassu-server |
| docs/device-configuration.md | Validator/dispenser hardware configuration |
| docs/business-model.md | Deployment economics |
| docs/adr/001-hal-architecture.md | HAL design decision record |
| docs/clink-protocol.md | CLINK protocol reference — historical, no longer wired on dev |
| docs/ndebit-cash-in-flow.md | Pre-LNbits cash-in flow — historical, replaced by LNURL-withdraw + subscribe_payments push |
| CLAUDE.md | Development guidelines and code style (read this if using Claude Code) |
Contributing
See CLAUDE.md for development guidelines, package layout conventions, and code style.
Acknowledgements
bitSpire's hardware drivers (JCM iVIZION / ID003, MEI EBDS, Fujitsu F56, etc.) and the cash-flow state machine derive from prior art first published as open source by Lamassu Industries AG in the lamassu-machine and lamassu-server repositories, up to and including the v8.1.5 release line — the last published under a fully-open license. bitSpire wouldn't exist without that foundation, and we're grateful for the years of operational hardening that went into it.
Lamassu Industries AG transitioned to a proprietary, source-available license on 2024-01-26, with v8.1.6 and subsequent releases gated behind a paid Operator Support Agreement. bitSpire incorporates no code from v8.1.6 or later, is an independent project, and is not affiliated with or endorsed by Lamassu Industries AG.
License
AGPL-3.0 (matches LNbits, which we link against).