docs: finish the LNbits-era doc sweep across docs/ + .claude/skills/

Second + final batch of the doc refresh. README + CLAUDE went out in
8c9ae29; deploy/nixos/README + obsolete-flow flags in 924844f. This
commit covers everything left.

docs/machine-installation.md
  Was describing a manual AppImage scp deploy + a `lamassu-kiosk`
  systemd unit that hasn't been the deployment path for months.
  Replaced with a high-level "what the pipeline does and why"
  overview that points at deploy/nixos/README.md for the full
  command-by-command walkthrough. Includes the BATM3 chassis-mod
  note (custom Dell OptiPlex retrofit, not a stock Dell).

docs/architecture-comparison.md
  Rewrote the comparison to be lamassu-server (≤ v8.1.5) vs bitSpire
  (LNbits-backed) instead of the original lamassu-server vs LP-backed
  lamassu-next framing. Updated the cash-out + cash-in flow diagrams
  to show the actual nostr-transport path (LNbits-bundled nostrrelay
  extension at ws://<host>:5001/nostrrelay/test, no separate strfry
  container). Replaced the migration-path section with a softer
  "when to choose what" framing that includes Lamassu's current
  commercial offering as a legitimate third option. Added a header
  pointer to the Acknowledgements section.

docs/business-model.md
  Light touch-ups: Lightning.Pub → LNbits where it appeared, swapped
  the [[ndebit-cash-in-flow]] link for [[architecture-comparison]],
  noted the kind-30078 service beacon for availability broadcasts.

docs/device-configuration.md
  Dropped "Lamassu" branding from the machine-model headings
  (Sintra / tejo / douro / batm3 are referenced by hardware identity
  here, not by Lamassu's product line). Added the Sintra-specific
  ttyS4-vs-placeholder-ttyS1..3 gotcha we hard-learned during the
  first flash. Corrected the BATM3 entry: stock GeneralBytes chassis
  with a Dell OptiPlex 9030 AIO motherboard physically grafted in,
  NOT a Dell out of the box. Updated the example /dev/ttyJ* symlink
  output to match what a healthy Sintra actually shows.

docs/adr/001-hal-architecture.md
  ADRs are historical artifacts — kept the original decision text
  intact. Added a postscript noting:
    - The package rename @lamassu/hal → @bitSpire/hal
    - The v8.1.5 boundary on any lamassu-machine source-tree
      references (Lamassu's 2024-01-26 license transition)
    - That the "Remaining Work" list is complete and the first
      successful Sintra hardware integration ran on 2026-05-13

.claude/skills/lightning-check.md
  Rewrote end-to-end. Was Lightning.Pub-flavoured with CLINK kinds
  21001/21002 as the primary flows; now validates the LNbits nostr-
  transport surface (kind-21000 envelope, NIP-44 v2 encryption,
  subscribe_payments filter discipline, lnurlw link composition).
  Preserved a --clink mode for the still-live kind-21003 operator-
  management surface. Includes a "what to check" rubric for cash-out
  vs cash-in flows that mirrors the actual code in
  apps/machine/src/services/lightning.ts.

.claude/skills/hal-check.md
  Two pivots: (1) acknowledge ADR-001's TypeScript-not-Rust choice
  and reframe all the safety checklists in TS-flavour (type safety,
  discriminated unions, single-writer serial, bounded emitters)
  instead of Rust-flavour (unsafe, borrow checker). (2) Add explicit
  v8.1.5 provenance boundary plus a "forbidden operations" section
  that prohibits diffing or porting from v8.1.6+ lamassu-machine
  source. Updated the port-validation source-reference table to
  list TS file paths under packages/hal/ instead of Rust paths.

.claude/skills/docs.md
  @lamassu/* → @bitSpire/*. Replaced the Lightning.Pub mermaid
  diagram with a current cash-out flow showing the nostr-transport
  RPC + subscribe_payments push path. Left the createOffer noffer
  example in the API-docs template section since it's illustrative
  ("here's what a good TSDoc block looks like") rather than current
  reference documentation.

.claude/skills/test.md
  One-line: @lamassu/nostr-client → @bitSpire/nostr-client in the
  pnpm-filter example.

deploy/nixos/README.md
  Single touch-up: clarified the douro/batm3 hardware-module comments
  to reflect that BATM3 is a custom-installed Dell board in a
  GeneralBytes BATM3 chassis (not a Dell OEM).

Files NOT touched in this sweep (intentionally):
  - packages/hal/src/**/*.ts attribution comments — those reference
    "lamassu-machine" in their port-source headers. Those are
    factually accurate (the drivers ARE ported from there, up to
    v8.1.5) and constitute necessary license/attribution metadata.
    Editing them would erase the provenance trail.
  - .claude/skills/{nostr-check,security}.md — already protocol-
    neutral, no LP/lamassu references to clean up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 08:25:18 +02:00
commit 53b0d382e8
10 changed files with 692 additions and 792 deletions

View file

@ -25,10 +25,11 @@ docs/
│ ├── lightning-flow.md # Payment flows
│ └── hardware-hal.md # HAL documentation
├── api/
│ ├── nostr-client.md # @lamassu/nostr-client API
│ ├── clink.md # @lamassu/clink API
│ ├── state-machine.md # @lamassu/state-machine API
│ └── hal.md # @lamassu/hal API
│ ├── nostr-client.md # @bitSpire/nostr-client API
│ ├── lnbits.md # @bitSpire/lnbits API (kind-21000 RPC surface)
│ ├── clink.md # @bitSpire/clink API (dormant on dev; 21003 management still wired)
│ ├── state-machine.md # @bitSpire/state-machine API
│ └── hal.md # @bitSpire/hal API
├── guides/
│ ├── development.md # Dev setup guide
│ ├── hardware-testing.md # Testing with real hardware
@ -106,22 +107,25 @@ Keep architecture diagrams current:
```mermaid
sequenceDiagram
participant U as User
participant U as Customer Wallet
participant A as ATM
participant R as Relay
participant L as Lightning.Pub
participant R as Relay (LNbits-bundled nostrrelay)
participant L as LNbits (nostr-transport)
U->>A: Insert $20 bill
A->>A: Validate bill
A->>R: Publish CLINK offer
U->>R: Send payment request
R->>A: Forward request
A->>L: Generate invoice
L->>A: Return invoice
A->>R: Send invoice to user
U->>L: Pay invoice
L->>A: Payment confirmed
A->>U: Display success
Note over A,L: cash-out flow (customer pays ATM, gets cash)
A->>R: kind-21000 RPC "create_invoice"
R->>L: forward to LNbits server pubkey
L->>R: kind-21000 ACK with BOLT11 invoice
R->>A: forward ACK
A->>U: display BOLT11 QR
A->>R: kind-21000 "subscribe_payments" by payment_hash
R->>L: forward
L->>R: kind-21000 ACK with subscription_id
R->>A: forward
U->>L: pay BOLT11 via Lightning Network
L->>R: kind-21000 push (settlement) tagged subscription_id
R->>A: forward
A->>A: dispense cash
```
### Auto-update triggers

View file

@ -1,133 +1,162 @@
# /hal-check - Hardware Abstraction Layer Agent
# /hal-check — Hardware Abstraction Layer Agent
## Purpose
Validate HAL driver implementations against existing lamassu-machine drivers and hardware specifications.
Validate HAL driver implementations in `packages/hal/` against published hardware protocol specs (JCM ID003, Fujitsu F56 DLE/STX, Puloon LCDM, MEI EBDS, etc.) and against the v8.1.5 release line of `lamassu-machine` — which is the **last** lamassu-machine release published under a fully-open license.
> **Provenance boundary.** Drivers in `packages/hal/` derive from `lamassu-machine` at v8.1.5 and earlier (plus hardware-vendor protocol specs). Lamassu Industries AG transitioned to a proprietary source-available license on 2024-01-26 with v8.1.6+ gated behind a paid Operator Support Agreement. **Do not** reference, port, or diff against v8.1.6+ — the only safe upstream tree for porting is `v8.1.5` or earlier. See [CLAUDE.md → Provenance + legal status](../../CLAUDE.md#provenance--legal-status) for the operating rules.
> **Language note.** ADR-001 selected TypeScript-in-Electron over Rust-in-Tauri for the HAL. Earlier versions of this skill referenced Rust patterns; that's obsolete. All checks below are TypeScript-flavored.
## Invocation
```
/hal-check [command] [driver]
```
Commands:
- `port` - Validate porting from lamassu-machine
- `protocol` - Check protocol implementation
- `safety` - Rust safety review
- `mock` - Validate mock implementation
- `port` — Validate that a driver matches its lamassu-machine v8.1.5 reference (where the driver was ported from one)
- `protocol` — Check protocol implementation against published vendor specs
- `safety` — Type safety, error handling, hardware safety review
- `mock` — Validate mock implementation completeness
Drivers:
- `id003`, `ccnet`, `cashflow`, `bnr`, `genmega`, `hcm2`, `gsr50` (validators)
- `puloon`, `f56`, `genmega`, `hcm2`, `gsr50` (dispensers)
- `nippon`, `zebra`, `genmega` (printers)
## Porting Validation
- Validators: `id003`, `ccnet`, `cashflow_sc` (EBDS), `bnr_advance`, `genmega`, `hcm2`, `gsr50`
- Dispensers: `puloon`, `f56`, `genmega`, `hcm2`, `gsr50`
- Printers: `nippon`, `zebra`, `genmega`
### Source Reference
Each Rust driver should map 1:1 with lamassu-machine JavaScript:
## Porting validation (`port` command)
| Rust File | JavaScript Source |
|-----------|-------------------|
| `validators/id003.rs` | `lib/id003/*.js` |
| `validators/ccnet.rs` | `lib/ccnet/*.js` |
| `dispensers/puloon.rs` | `lib/puloon/*.js` |
| `dispensers/f56.rs` | `lib/f56/*.js` |
### Source reference
Each TS driver in `packages/hal/` maps to (at most) one JS source in lamassu-machine v8.1.5 (the last fully-open release):
| TS Driver | JS Source (v8.1.5 release tree) |
|---|---|
| `validators/id003/*.ts` | `lib/id003/*.js` |
| `validators/ccnet/*.ts` | `lib/ccnet/*.js` |
| `validators/ebds/*.ts` | `lib/mei/cashflow_sc.js` + `lib/mei/*.js` |
| `dispensers/puloon/*.ts` | `lib/puloon/*.js` |
| `dispensers/f56/*.ts` | `lib/f56/*.js` |
If a driver in `packages/hal/` references a JS source NOT under v8.1.5 (or no JS source at all because it was re-derived from vendor specs), the attribution comment at the top of the TS file should say so.
### Porting checklist
### Porting Checklist
```
/hal-check port id003
```
Validates:
- [ ] All protocol commands implemented
- [ ] State machine matches JS FSM
- [ ] CRC/checksum calculation identical
- [ ] Timeout values match
- [ ] Error codes mapped correctly
- [ ] Denomination tables match
### Protocol Commands
- [ ] All protocol commands implemented (RESET, ENABLE, DISABLE, STACK, RETURN, STATUS — see ID003 spec or `lib/id003/id003rs232.js` from v8.1.5)
- [ ] State machine matches the JS FSM (`lib/id003/id003fsm.js`)
- [ ] CRC/checksum calculation byte-identical to the JS version
- [ ] Timeout values match the JS source
- [ ] Error codes mapped correctly to TypeScript enum/union
- [ ] Denomination tables match (currency-specific)
- [ ] Attribution comment at top of TS file points at the specific v8.1.5 file
### Protocol commands
#### ID003 (JCM)
| Command | Code | JS Reference |
|---------|------|--------------|
| RESET | 0x40 | id003.js:reset() |
| Command | Code | JS Reference (v8.1.5) |
|---|---|---|
| RESET | 0x40 | id003rs232.js:reset() |
| ENABLE | 0x13 | id003fsm.js:enable |
| DISABLE | 0x14 | id003fsm.js:disable |
| STACK | 0x15 | id003fsm.js:stack |
| RETURN | 0x16 | id003fsm.js:return |
| STATUS | 0x10 | id003fsm.js:poll |
#### Puloon
| Command | Code | JS Reference |
|---------|------|--------------|
#### Puloon LCDM
| Command | Code | JS Reference (v8.1.5) |
|---|---|---|
| RESET | 0x44 | puloonrs232.js |
| DISPENSE | 0x45 | puloonrs232.js |
| STATUS | 0x46 | puloonrs232.js |
## Safety Review (Rust-specific)
## Safety review (`safety` command)
### Memory Safety
- [ ] No `unsafe` without justification comment
- [ ] Buffer sizes validated before read/write
- [ ] No panic paths in production code
- [ ] Proper error propagation with Result
### Type safety
### Concurrency Safety
- [ ] Serial port access properly synchronized
- [ ] Event channels bounded
- [ ] No deadlock potential
- [ ] Timeout on all blocking operations
- [ ] No `any` types in driver public API
- [ ] Discriminated unions for state machine states (not just string literals)
- [ ] Error types are typed `Result<T, DriverError>` (or equivalent) at the driver boundary — never `throw` raw strings
- [ ] Buffer reads are bounds-checked; offsets validated before slicing
### Hardware Safety
- [ ] Dispenser amounts validated (prevent over-dispense)
- [ ] Bill count cross-checked
- [ ] Error states trigger hardware reset
- [ ] Graceful degradation on hardware failure
### Concurrency safety
## Mock Validation
- [ ] Serial port access is single-writer (TypeScript single-threaded helps, but ensure no two `await write()` calls can interleave with each other's expected response)
- [ ] Event emitters are bounded (max listeners set; back-pressure handled)
- [ ] Timeouts on all blocking I/O (`Promise.race` with `setTimeout`)
- [ ] No unbounded queues — a runaway validator polling loop shouldn't fill RAM
### Hardware safety
- [ ] Dispenser amounts validated against cassette inventory before dispensing
- [ ] Bill count cross-checked between requested and dispensed
- [ ] Error states trigger hardware reset (`disable + reset + enable` cycle for validators)
- [ ] Graceful degradation: partial dispense reports correct dispensed-vs-rejected counts
- [ ] Validator goes to `disable` on app shutdown — bills inserted while the app is dead get returned, not stacked uncounted
## Mock validation (`mock` command)
### Mock requirements
Mocks under `packages/hal/src/**/mock.ts` must simulate:
### Mock Requirements
Mocks must simulate:
1. Normal operation flow
2. Error conditions
3. Timing (realistic delays)
4. State persistence
2. Error conditions (jam, empty cassette, comms failure)
3. Realistic timing (validator escrow takes ~600ms; dispenser cycle takes ~2s)
4. State persistence within a session
### Mock test coverage
### Mock Test Coverage
```
/hal-check mock puloon
```
Validates mock implements:
- [ ] `connect()` - Success and failure paths
- [ ] `dispense()` - Full and partial dispense
- [ ] `reset()` - Error recovery
- [ ] Event emission timing
- [ ] Cassette state tracking
## Protocol Analysis
- [ ] `connect()` — success and failure paths
- [ ] `dispense()` — full, partial, and over-requested paths
- [ ] `reset()` — error recovery
- [ ] Event emission timing roughly matches real hardware
- [ ] Cassette state tracking (count decrements properly)
## Protocol analysis (`protocol` command)
### Packet structure validation
### Packet Structure Validation
```
/hal-check protocol id003
```
Compares Rust packet building with JS:
```rust
// Rust implementation
fn build_packet(&self, data: &[u8]) -> Vec<u8> {
let mut packet = vec![0x02]; // SYNC
packet.push(data.len() as u8 + 4);
packet.extend_from_slice(data);
let crc = self.calculate_crc(&packet);
packet.push((crc & 0xFF) as u8);
packet.push((crc >> 8) as u8);
packet
Compares TS packet building with the v8.1.5 JS reference:
```typescript
// TypeScript implementation
function buildPacket(data: Uint8Array): Uint8Array {
const packet = new Uint8Array(data.length + 4)
packet[0] = 0x02 // SYNC
packet[1] = data.length + 4 // length includes header + CRC
packet.set(data, 2)
const crc = calculateCrc(packet.slice(0, -2))
packet[packet.length - 2] = crc & 0xff
packet[packet.length - 1] = (crc >> 8) & 0xff
return packet
}
```
Against JavaScript:
Against the v8.1.5 JS reference (line numbers may vary by tag):
```javascript
// lamassu-machine/lib/id003/id003rs232.js
// lamassu-machine v8.1.5 — lib/id003/id003rs232.js
function buildPacket(data) {
const buf = Buffer.alloc(data.length + 4)
buf[0] = 0x02 // SYNC
@ -139,62 +168,72 @@ function buildPacket(data) {
}
```
## Output Format
A discrepancy here (different CRC polynomial, different framing, different endianness) is a serious port bug.
## Output format
### Port validation
### Port Validation
```markdown
## HAL Port Validation: id003
### Command Coverage
| Command | JS | Rust | Match |
|---------|-------|------|-------|
### Command coverage
| Command | v8.1.5 JS | TS | Match |
|---|---|---|---|
| RESET | ✅ | ✅ | ✅ |
| ENABLE | ✅ | ✅ | ✅ |
| STATUS | ✅ | ⚠️ | Partial |
### Protocol Differences
- [ ] `id003.rs:78` - CRC uses different polynomial than JS
### Protocol differences
- [ ] `id003-rs232.ts:78` — CRC uses different polynomial than v8.1.5 JS
### Missing Implementations
- [ ] `HOLD` command not implemented (used in id003fsm.js:holdBill)
### Missing implementations
- [ ] HOLD command not implemented (v8.1.5 `id003fsm.js:holdBill`)
### Recommendations
1. Verify CRC calculation against test vectors from JS
2. Add HOLD command for escrow mode
### Attribution
- [x] File header points at `lib/id003/id003rs232.js` (v8.1.5)
```
### Safety Review
### Safety review
```markdown
## HAL Safety Review: puloon
### Memory Safety
- [x] No unsafe blocks
- [x] Buffer bounds checked
- [ ] `dispense()` at line 145 - unwrap() could panic
### Type safety
- [x] No `any` types in public surface
- [x] Discriminated union for DispenserState
### Concurrency Safety
- [x] Serial port mutex protected
- [x] Event channel bounded (16)
### Concurrency safety
- [x] Single-writer over serial port
- [x] Event emitter has bounded listener count
- [ ] `puloon-rs232.ts:142` — no timeout on response wait; could hang forever
### Hardware Safety
- [x] Amount validation in dispense()
- [ ] No cassette empty check before dispense
### Hardware safety
- [x] Amount validated against cassette inventory in dispense()
- [ ] `puloon.ts:178` — partial dispense doesn't update cassette inventory
### Critical Issues
1. `puloon.rs:145` - Replace unwrap() with proper error handling
2. `puloon.rs:178` - Add cassette level check before dispensing
### Critical issues
1. `puloon-rs232.ts:142` — wrap response wait in `Promise.race([response, timeout(5000)])`
2. `puloon.ts:178` — decrement cassette count by actual dispensed, not requested
```
## Example Usage
## Example usage
```
/hal-check port id003
```
```
/hal-check safety packages/hal/src/dispensers/
```
```
/hal-check safety packages/hal/src/dispensers/puloon/
/hal-check mock --all
/hal-check protocol ebds # validates EBDS framing against v8.1.5 JS + MEI vendor spec
```
## Related skills
- `/security` — broader Bitcoin/Lightning/ATM vulnerability scan
- `/test` — runs HAL-specific tests, generates coverage reports
- `/lightning-check` — validates the Lightning backend side (LNbits transport)
## Forbidden operations
- Diff or read `lamassu-machine` source at v8.1.6 or later. Only `v8.1.5` (and the historical commit range leading up to it) is permissible to reference.
- "Backport" any fix or feature from v8.1.6+ JS sources into TypeScript. If a bug fix is needed, implement from the protocol spec or hardware traces.
- Include attribution comments pointing at v8.1.6+ files even if the implementation is your own — readers should be able to trust file-header attributions as accurate.

View file

@ -1,168 +1,208 @@
# /lightning-check - Lightning.Pub Conformity Agent
# /lightning-check — Lightning backend conformity
## Purpose
Validate Lightning.Pub integration and CLINK protocol conformity.
Validate the LNbits nostr-transport integration in bitSpire — wire envelope, encryption, subscription handling, invoice/withdraw correctness. Replaces the original Lightning.Pub-flavored version of this skill; the LP backend was removed from `dev` in commit `a51d7af`.
> The `--clink` mode is preserved for validating CLINK kind-21003 management commands, which are the one piece of CLINK still wired on `dev` (operator dispense commands). CLINK kinds 21001/21002 are dormant on `dev`.
## Invocation
```
/lightning-check [target] [--clink] [--wallet]
/lightning-check [target] [--lnbits] [--lnurlw] [--clink]
```
Where:
- `target` - File or directory to check
- `--clink` - Focus on CLINK offer/debit flow
- `--wallet` - Focus on wallet operations
## Lightning.Pub Integration
- `target` — file or directory to check (default: `packages/lnbits/src/` + `apps/machine/src/services/lightning.ts`)
- `--lnbits` — focus on LNbits nostr-transport RPC + subscription surface
- `--lnurlw` — focus on LNURL-withdraw cash-in flow (link creation, callback URL composition, subscription)
- `--clink` — focus on the kind-21003 management surface (operator commands)
### Connection
Lightning.Pub uses Nostr for all communication:
## What to validate
### 1. Wire envelope (kind 21000)
Each RPC request/response is a kind-21000 event with NIP-44 v2-encrypted JSON in `content`. The plaintext shape is:
```jsonc
// Request
{
"rpc_name": "create_invoice",
"request_id": "create_invoice-7-ab12cd",
"wallet_id": "<uuid>", // present for AUTH_WALLET RPCs
"body": { /* RPC-specific */ },
"query": { /* RPC-specific */ } // optional
}
// One-shot reply
{
"status": "OK" | "ERROR",
"request_id": "create_invoice-7-ab12cd",
"data": { /* RPC-specific */ },
"error": "..." // present when status==ERROR
}
// Subscription push
{
"status": "OK",
"request_id": "sub-9-ef34gh",
"subscription_id": "<server-issued>",
"data": { "payment": { /* Payment */ } } |
{ "closed": true, "reason": "ttl"|"unsubscribed" }
}
```
Required checks:
- [ ] `request_id` is unique per outbound RPC (no reuse across sessions)
- [ ] The pending map is keyed by `request_id` and cleaned up on resolve/reject
- [ ] Subscription pushes are matched by `subscription_id` (top-level field, not in `data`)
- [ ] The handler resolves the subscription's `subscriptionId` only after the ACK arrives — pre-ACK pushes (rare but possible) are handled
- [ ] The reply listener decrypts with `decryptContentV2(identity, serverPubkey, ev.content)` and silently skips events that don't decrypt (wrong sender)
- [ ] Events are signed with `finalizeEvent` from `nostr-tools` using the ATM's `identity.privateKey` — server identifies the calling account from the signature alone
### 2. Encryption (NIP-44 v2)
LNbits transport requires NIP-44 v2, NOT NIP-04. Required checks:
- [ ] All kind-21000 content is `encryptContentV2`/`decryptContentV2` (not `encryptContent`/`decryptContent`, which are the v1/NIP-04 paths kept around for CLINK compatibility)
- [ ] The shared secret is derived from `identity.privateKey × serverPubkey` (or the reverse on the server side — symmetric)
- [ ] No code logs ciphertext or shared secrets
### 3. Authentication model
LNbits derives the calling account from the event signature. There is **no admin token, no API key, no client cert** on the ATM. Required checks:
- [ ] No code stores or references an admin token
- [ ] No code makes outbound HTTP to LNbits (other than via `VITE_LNBITS_HTTP_URL` for the LNURL-withdraw callback, which is the customer wallet's path — the ATM itself doesn't hit HTTP)
- [ ] The ATM's `identity.privateKey` is loaded once from `/var/lib/bitspire/.env` and never logged
### 4. Cash-out flow (`--lnbits`)
In `services/lightning.ts → generateInvoice / watchInvoice`:
```typescript
// Connection via nprofile
const nprofile = 'nprofile1...' // Contains pubkey + relay hints
// Generate
const payment = await lnbits.createInvoice(walletId, {
amount: amountSats,
memo: 'bitSpire - Cash Out',
unit: 'sat',
})
// All operations are Nostr events to Lightning.Pub's pubkey
await relay.publish({
kind: 21002, // CLINK debit
content: encrypted_request,
tags: [['p', lightningPubPubkey]],
// Watch — must filter by payment_hash to avoid acting on unrelated settlements
const decoded = await lnbits.decodePayment(invoice)
const paymentHash = decoded.payment_hash
const subId = await lnbits.subscribePayments(walletId, {
payment_hash: paymentHash,
max_seconds: 600,
}, push => {
if (push.payment_hash !== paymentHash) return // belt-and-suspenders
if (push.status !== 'success') return
callback(push.preimage ?? 'payment-confirmed')
})
```
### Required Checks
- [ ] Using correct event kinds (21001, 21002, 21003)
- [ ] Proper NIP-44 encryption for requests
- [ ] Handling async responses via subscription
- [ ] Proper error handling for payment failures
Required checks:
## CLINK Protocol
- [ ] `subscribePayments` filter always specifies `payment_hash` for invoice watching (filter-less subscriptions are server-rejected; broader filters expose us to acting on unrelated settlements)
- [ ] `max_seconds` is clamped — LNbits clamps server-side to [1, 600], so values outside that range are silently changed
- [ ] The callback checks `push.payment_hash` matches the expected one (defense in depth)
- [ ] The callback checks `push.status === 'success'` before treating as paid
- [ ] On cleanup, `unsubscribe(walletId, subId)` is called
### Offer Flow (Kind 21001)
```
User scans noffer → Wallet sends 21002 → ATM responds with invoice → User pays
### 5. Cash-in flow (`--lnurlw`)
In `services/lightning.ts → generateLnurlWithdraw`:
```typescript
const link = await lnbits.createWithdrawLink(walletId, {
title: '...',
min_withdrawable: ctx.satsAmount,
max_withdrawable: ctx.satsAmount,
uses: 1,
wait_time: 1,
is_unique: false,
})
const callbackUrl = `${CONFIG.lnbitsHttpUrl.replace(/\/+$/, '')}/withdraw/api/v1/lnurl/${link.unique_hash}`
const lnurl = encodeLnurl(callbackUrl) // bech32 HRP="lnurl", uppercase
const subId = await lnbits.subscribePayments(walletId, {
tag: 'withdraw',
link_id: link.id,
max_seconds: 600,
}, push => { /* mark session claimed, dispense */ })
```
Validation:
- [ ] noffer encoding is valid
- [ ] Relays included in offer
- [ ] Price type correctly specified (fixed/variable/spontaneous)
- [ ] Amount bounds validated
Required checks:
### Debit Flow (Kind 21002)
Request structure:
```json
{
"method": "pay_invoice",
"params": {
"invoice": "lnbc...",
"amount_msat": 100000
}
}
```
- [ ] `uses: 1` — single-use prevents replay
- [ ] `min_withdrawable === max_withdrawable === ctx.satsAmount` — exact amount, no operator slippage
- [ ] The LNURL is composed from `VITE_LNBITS_HTTP_URL` + `link.unique_hash` (the transport `lnurlw_create_link` returns `link.lnurl === null` — those fields are filled by HTTP views, not the create RPC)
- [ ] bech32 encoding uses HRP `"lnurl"` and the result is uppercased per BOLT/LNURL convention
- [ ] Subscription filter is `tag: 'withdraw'` + `link_id: link.id` — this is what the withdraw extension stamps on settlement events
- [ ] On session abort, the cleanup closure both unsubscribes AND deletes the withdraw link (so a half-completed session doesn't leave a redeemable QR alive on LNbits)
Response structure:
```json
{
"result": {
"preimage": "...",
"fee_msat": 1000
}
}
```
### 6. Operator commands (`--clink`, kind-21003)
Validation:
- [ ] Invoice format valid (BOLT11)
- [ ] Amount matches invoice
- [ ] Preimage verified against payment hash
- [ ] Fee within acceptable bounds
- [ ] Timeout handling
The kind-21003 management surface is the one piece of CLINK still wired on `dev`. It's LP-independent (operators talk directly to the ATM over nostr) so it survived the migration.
### Manage Flow (Kind 21003)
For operator commands:
```json
{
"method": "get_balance",
"params": {}
}
```
Required checks:
Validation:
- [ ] Only authorized operators can send
- [ ] Commands are properly authenticated
- [ ] Responses handled securely
- [ ] `CLINKClient` is initialized with `operatorPubkey: CONFIG.operatorPubkeys` (no Lightning.Pub pubkey mixed in — that's LP-era)
- [ ] `clink.onManagement` handler routes through `handleManagementCommand` which dispatches by `request.command` (manual dispense, status query, etc.)
- [ ] Operator authentication is by pubkey allowlist — not a separate token
- [ ] Sensitive ops (`dispenseCash`) only fire when the state machine is idle (`isIdle.value` check)
## Invoice Validation
### BOLT11 Checks
- [ ] Valid bech32 encoding
- [ ] Expiry not passed
- [ ] Amount matches expected
- [ ] Description hash valid (if used)
- [ ] Payment hash extractable
### Security Checks
- [ ] Never pay same invoice twice
- [ ] Amount limits enforced
- [ ] Rate limiting on payments
- [ ] Proper logging (no sensitive data)
## Wallet Operations
### Balance Queries
- [ ] Cached appropriately (not every render)
- [ ] Error handling for offline
- [ ] Display in correct units (sats, not msat)
### Invoice Generation
- [ ] Unique payment hashes
- [ ] Reasonable expiry times
- [ ] Description for record-keeping
- [ ] Amount in correct units
## Output Format
## Output format
```markdown
## Lightning.Pub Conformity: [target]
## Lightning backend conformity: [target]
### CLINK Protocol
| Flow | Status | Notes |
|------|--------|-------|
| Offer (21001) | ✅ | |
| Debit (21002) | ⚠️ | Missing timeout |
| Manage (21003) | ✅ | |
### LNbits transport
| Concern | Status | Notes |
|---|---|---|
| Wire envelope | ✅ | |
| NIP-44 v2 encryption | ✅ | |
| Signature-only auth | ✅ | No admin tokens found |
| Subscription handling | ⚠️ | Missing payment_hash filter on watchInvoice cleanup |
### Invoice Handling
- [ ] `file:line` - Issue description
### Cash-out / Cash-in flows
- [ ] `file:line` — Issue description + fix
### Integration Issues
- [ ] Description with fix suggestion
### Operator (kind-21003)
- [ ] `file:line` — Issue description
### Recommendations
- [ ] Performance/UX improvements
```
## Example Usage
## Example
```
/lightning-check packages/lightning/src/ --clink
/lightning-check packages/lnbits/src/ --lnbits
```
Output:
```markdown
## Lightning.Pub Conformity: packages/lightning/src/
## Lightning backend conformity: packages/lnbits/src/
### CLINK Protocol
| Flow | Status | Notes |
|------|--------|-------|
| Offer (21001) | ✅ | Properly encoded |
| Debit (21002) | ⚠️ | No timeout handling |
### LNbits transport
| Concern | Status | Notes |
|---|---|---|
| Wire envelope | ✅ | request_id uniqueness OK, pending map cleaned up on timeout |
| NIP-44 v2 encryption | ✅ | encryptContentV2/decryptContentV2 used exclusively |
| Subscription handling | ✅ | subscriptionId resolved after ACK, push handlers race-safe |
| Cleanup | ⚠️ | unsubscribe error swallowed silently — log at warn level |
### Issues Found
- [ ] `client.ts:89` - Debit request has no timeout, could hang indefinitely
- [ ] `client.ts:112` - Payment hash not verified against preimage
### Recommendations
- Add 30-second timeout for CLINK debit requests
- Implement invoice deduplication to prevent double-pay
### Cash-out flow
- [ ] `packages/lnbits/src/client.ts:212` — watchInvoice resolves on first success push but doesn't unsubscribe until subIdRef is assigned; if push lands before subscribePayments resolves, leaks the subscription
```
## Related skills
- `/nostr-check` — validates NIP-01 / NIP-44 conformance at the nostr-client layer
- `/security` — Bitcoin/Lightning vuln audit
- `/test` — runs vitest + flow validation

View file

@ -22,7 +22,7 @@ Location: `*.test.ts` or `*.spec.ts` alongside source files
```bash
pnpm test # All tests
pnpm test --filter @lamassu/nostr-client # Specific package
pnpm test --filter @bitSpire/nostr-client # Specific package
```
### 2. Integration Tests

View file

@ -12,8 +12,8 @@ deploy/nixos/
├── configuration.nix # Base system (NixOS 24.05, locale, kernel, packages, lamassu user)
├── live.nix # Live USB variant (squashfs + tmpfs root) — used by mkLiveConfig
├── hardware/
│ ├── douro.nix # Dell OptiPlex 9030 AIO (batm3 sibling; SATA SSD, eGalax touch)
│ ├── batm3.nix # GeneralBytes BATM3 (Dell board, WireGuard wired in)
│ ├── douro.nix # Dell OptiPlex 9030 AIO (stock Douro motherboard; SATA SSD, eGalax touch)
│ ├── batm3.nix # GeneralBytes BATM3 chassis with a Dell OptiPlex 9030 AIO grafted in (custom mod; WireGuard wired in)
│ └── upboard.nix # Aaeon UP Board (Sintra + tejo; eMMC root via sdhci-acpi + mmc_block)
├── udev/
│ └── 99-lamassu-hardware.rules # additional udev rules (loaded via configuration.nix)

View file

@ -134,7 +134,24 @@ If a compelling reason for Rust emerges (it likely won't), the HAL interface is
## References
- `packages/hal/` - TypeScript HAL implementation
- `packages/state-machine/src/types.ts` - ATMServices interface
- `hardware/codebase/upboard/sintra/device_config.json` - Sintra device paths
- lamassu-machine `lib/id003/`, `lib/f56/` - Original JS drivers
- `packages/hal/` — TypeScript HAL implementation
- `packages/state-machine/src/types.ts` — ATMServices interface
- `deploy/nixos/hardware/upboard.nix` — Sintra device paths and udev rules
- lamassu-machine `lib/id003/`, `lib/f56/` — Original JS drivers (v8.1.5 release line and earlier; see postscript below)
---
## Postscript (2026-05-13)
This ADR is still in effect — TypeScript HAL in Electron remains the right call for the same reasons documented above. A few clarifications for readers in 2026 and beyond:
1. **Package naming.** The HAL package was originally `@lamassu/hal` (per the line above in "Completed"). It was renamed to `@bitSpire/hal` during the project rename from `lamassu-next` to `bitSpire` (commit `924844f` in the 2a sweep). All references to the package scope should now read `@bitSpire/hal`.
2. **Source-tree provenance, post-Lamassu license change.** The "Original JS drivers" reference above refers to lamassu-machine's `lib/id003/` and `lib/f56/` directories in the **v8.1.5 release line and earlier**, which were published under a fully-open license. Lamassu Industries AG transitioned to a proprietary source-available license on 2024-01-26 (v8.1.6+). bitSpire incorporates no code from v8.1.6 or later; the protocol-level reimplementations we have are derived from v8.1.5 + protocol specs published by JCM, Fujitsu, etc. See [CLAUDE.md → Provenance + legal status](../../CLAUDE.md#provenance--legal-status) for the operating rules going forward.
3. **Implementation status.** The "Remaining Work" list above is complete:
- `apps/machine` runs on Electron (the original Tauri scaffolding was removed early on)
- HAL is wired to the state machine via the ATMServices interface
- Device configuration ships via `deploy/nixos/hardware/upboard.nix` + the `services.bitspire` NixOS module
- First successful Sintra hardware integration test ran on 2026-05-13 (cash-out flow verified end-to-end against a regtest LNbits instance)

View file

@ -1,338 +1,306 @@
# Architecture Comparison: Nostr-Native vs Traditional Lamassu
# Architecture Comparison: bitSpire vs Traditional Lamassu
This document compares the Nostr-native Lightning ATM architecture (Lamassu Next) with the traditional lamassu-server/machine implementation to help operators evaluate the transition.
This document compares the bitSpire architecture — a Nostr-native Lightning ATM talking to LNbits over the nostr-native-transport — with the traditional `lamassu-server` + `lamassu-machine` architecture. The goal is to help an operator evaluating an open-source Lightning ATM stack understand what they're choosing between.
## Executive Summary
> A note on prior art: bitSpire's hardware drivers and cash-flow state machine derive from Lamassu Industries AG's open-source `lamassu-machine` and `lamassu-server` projects up to and including the v8.1.5 release line (the last published under a fully-open license). Lamassu [transitioned to a proprietary source-available license](https://blog.lamassu.is/updates-to-our-lamassu-software-license/) on 2024-01-26; bitSpire does **not** incorporate code from v8.1.6 or later and is not affiliated with Lamassu Industries AG. See the [Acknowledgements](../README.md#acknowledgements) section of the top-level README.
| Aspect | Traditional (lamassu-server) | Nostr-Native (lamassu-next) |
| ------------------------ | ---------------------------- | ---------------------------------- |
| **Communication** | Custom WebSocket protocol | Nostr relay (NIP-01) |
| **Identity** | Server-issued credentials | Cryptographic keypairs (npub/nsec) |
| **Account System** | PostgreSQL + custom auth | Lightning.Pub (Nostr-native) |
| **Payment Protocol** | Direct LND RPC | CLINK protocol (kinds 21001-21003) |
| **Infrastructure** | Server + DB + Admin UI | Relay (optional self-hosted) |
| **Wallet Compatibility** | Lamassu-specific | Any CLINK-compatible wallet |
## Executive summary
| Aspect | Traditional (`lamassu-server` ≤ v8.1.5) | bitSpire |
|---|---|---|
| **Software license** | AGPL-3.0 (v8.1.5); v8.1.6+ is proprietary SLA | AGPL-3.0 |
| **Communication** | Custom WebSocket + GraphQL over HTTPS | Nostr kind-21000 events (NIP-01, NIP-44 v2) over a relay |
| **Identity** | Server-issued client certs | Nostr keypairs (`nsec`/`npub`) |
| **Account system** | PostgreSQL + custom auth | LNbits wallet auto-created from ATM's nostr pubkey |
| **Payment protocol** | Direct LND RPC | BOLT11 (cash-out) + LNURL-withdraw (cash-in) |
| **Server infrastructure** | App server + DB + Admin UI | A relay + an LNbits instance (both can be one container) |
| **Wallet compatibility** | Any Lightning wallet (cash-out); custom flow for cash-in | Any Lightning wallet (BOLT11 + LNURL-withdraw — both widely supported) |
| **State location** | Centralized in PostgreSQL | Distributed: LNbits holds Lightning state, ATM holds session state |
---
## Traditional Architecture (lamassu-server/machine)
### Overview
## Traditional architecture (lamassu-server / lamassu-machine, v8.1.5)
```
┌─────────────────┐ WebSocket ┌─────────────────────┐
│ ATM Machine │◄─────────────────────────►│ lamassu-server │
│ (lamassu-machine)│ │ │
└─────────────────┘ │ ┌───────────────┐ │
│ │ PostgreSQL │ │
┌─────────────────┐ HTTPS │ └───────────────┘ │
│ Admin UI │◄─────────────────────────►│ │
│ (React SPA) │ GraphQL │ ┌───────────────┐ │
└─────────────────┘ │ │ LND │ │
│ └───────────────┘ │
┌─────────────────┐ │ │
│ Customer Wallet │◄── Lightning Invoice ─────│ ┌───────────────┐ │
│ (any LN wallet) │ │ │ Compliance │ │
└─────────────────┘ │ │ Services │ │
│ └───────────────┘ │
└─────────────────────┘
┌──────────────────┐ WebSocket ┌─────────────────────┐
│ ATM (kiosk) │◄──────────────────────────►│ lamassu-server │
│ lamassu-machine │ client-cert auth │ │
└──────────────────┘ │ ┌───────────────┐ │
│ │ PostgreSQL │ │
┌──────────────────┐ HTTPS │ └───────────────┘ │
│ Admin UI │◄──────────────────────────►│ │
│ (React SPA) │ GraphQL │ ┌───────────────┐ │
└──────────────────┘ │ │ LND │ │
│ └───────────────┘ │
┌──────────────────┐ │ │
│ Customer Wallet │◄── Lightning Invoice ──────│ ┌───────────────┐ │
│ (any LN wallet) │ │ │ Compliance │ │
└──────────────────┘ │ │ Services │ │
│ └───────────────┘ │
└─────────────────────┘
```
### Components
| Component | Description |
| ------------------- | ------------------------------------------------ |
| **lamassu-server** | Node.js Express + Apollo GraphQL backend |
| **lamassu-machine** | Node.js kiosk application with hardware drivers |
| **PostgreSQL** | Central database for transactions, users, config |
| **Admin UI** | React dashboard for fleet management |
| **LND** | Lightning Network Daemon for payments |
| Component | Description |
|---|---|
| `lamassu-server` | Node.js (Express + Apollo GraphQL) backend |
| `lamassu-machine` | Node.js kiosk app with hardware drivers |
| PostgreSQL | Central DB for transactions, users, config |
| Admin UI | React dashboard for fleet management |
| LND | Lightning Network Daemon for payments |
### Communication Flow
### Cash-out flow
1. Machine boots and authenticates with server via client certificate
2. Server pushes configuration and state via WebSocket
3. Customer initiates transaction at machine
4. Machine requests invoice/address from server
1. Machine boots, authenticates via client cert
2. Server pushes config + state over WebSocket
3. Customer initiates cash-out at machine
4. Machine asks server for an invoice
5. Server creates invoice via LND, stores in PostgreSQL
6. Customer pays invoice
7. Server detects payment, updates database
8. Server notifies machine to dispense cash
9. Transaction logged in PostgreSQL for compliance
6. Customer pays the invoice
7. Server polls LND, detects payment, updates DB
8. Server notifies machine to dispense
9. Compliance services log the transaction
### Characteristics
- **Centralized**: All state lives in server's PostgreSQL
- **Operator-managed**: Requires significant infrastructure
- **Custom protocol**: WebSocket messages are Lamassu-specific
- **Tight coupling**: Machine depends entirely on server availability
- **Compliance-ready**: Built-in KYC/AML features
- **Centralized**: all state in `lamassu-server`'s PostgreSQL
- **Operator-managed**: requires real infrastructure (server, DB, certs, monitoring)
- **Tightly coupled**: machine doesn't transact if server is unreachable
- **Compliance-ready**: KYC/AML hooks built in (relevant in regulated jurisdictions)
---
## Nostr-Native Architecture (lamassu-next)
### Overview
## bitSpire architecture
```
┌─────────────────┐ ┌─────────────────┐
│ ATM Machine │ │ Customer │
│ (npub_atm) │ │ Wallet │
└────────┬────────┘ │ (npub_user) │
│ └────────┬────────┘
│ NIP-01 events │
│ NIP-44 encrypted │ CLINK protocol
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ Nostr Relay │
│ (strfry, nostr-rs, etc.) │
└─────────────────────────────────────────────────────────────────┘
│ │
│ Kind 21000 (RPC) │
│ Kind 21001-21003 (CLINK) │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Lightning.Pub │ │ Lightning.Pub │
│ (ATM account) │◄── Lightning Payment ─────►│ (User account) │
└────────┬────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ LND │
└─────────────────┘
┌──────────────────┐ ┌──────────────────┐
│ ATM (kiosk) │ │ Customer wallet │
│ npub_atm │ │ (any LN wallet) │
└────────┬─────────┘ └────────┬─────────┘
│ kind-21000 │
│ NIP-44 v2 encrypted │ BOLT11
▼ │ LNURL-withdraw
┌───────────────────────────────┐ │
│ Nostr Relay │ │
│ (LNbits' bundled │ │
│ nostrrelay extension, or │ │
│ any standalone relay) │ │
└────────────────┬──────────────┘ │
│ │
│ kind-21000 RPC + push │
▼ │
┌───────────────────────┐ ◄──────────────────────┘
│ LNbits │ BOLT11 settle / LNURLw redeem
│ ┌──────────────────┐ │
│ │ FakeWallet (dev) │ │
│ │ or LND/CLN/etc. │ │
│ └──────────────────┘ │
└───────────────────────┘
```
### Components
| Component | Description |
| ------------------- | ----------------------------------------------- |
| **Nostr Relay** | Message broker (strfry, nostr-rs-relay) |
| **Lightning.Pub** | Nostr-native account system wrapping LND |
| **ATM Machine** | Tauri + Vue 3 kiosk identified by npub |
| **Customer Wallet** | Any CLINK-compatible wallet (ShockWallet, etc.) |
| Component | Description |
|---|---|
| Nostr relay | Message broker. In the dev compose, this is LNbits's bundled `nostrrelay` extension at `ws://<host>:5001/nostrrelay/test`. Production can use any standalone relay both peers subscribe to |
| LNbits | Lightning wallet platform; runs the `nostr-native-transport` branch. Auto-creates a wallet on first contact from any nostr pubkey |
| ATM machine | Electron + Vue 3 kiosk identified by its `npub`. Signing key IS the credential to LNbits |
| Customer wallet | Any LN wallet that handles BOLT11 (cash-out) and LNURL-withdraw (cash-in) — most do |
### Communication Flow (Cash-In Example)
### Cash-out flow (customer pays ATM, gets cash)
1. ATM generates ephemeral keypair or uses persistent npub
2. Customer scans QR containing `ndebit` (debit authorization)
3. Customer's wallet connects to relay, finds ATM's Lightning.Pub
4. Wallet creates invoice via Lightning.Pub RPC (kind 21000)
5. Wallet sends debit request (kind 21002) to ATM's npub
6. ATM verifies request, approves payment
7. Lightning.Pub pays invoice, returns preimage
8. ATM detects payment confirmation
9. ATM dispenses cash
10. Transaction recorded as Nostr event (optional)
1. ATM publishes a kind-21000 event to LNbits: `{rpc_name: "create_invoice", body: {amount: <sats>, memo: "..."}}`
2. LNbits replies on the same relay with the BOLT11 invoice
3. ATM displays the BOLT11 as a QR
4. Customer scans, pays from any LN wallet
5. LNbits's Lightning backend (FakeWallet / LND / CLN / etc.) settles the payment
6. LNbits pushes a kind-21000 settlement event to the ATM, filtered by `payment_hash`
7. ATM dispenses cash
### Cash-in flow (customer hands ATM cash, gets sats)
1. ATM publishes `lnurlw_create_link` (kind-21000) to LNbits with `{uses: 1, max_withdrawable: <sats>}`
2. LNbits replies with a `WithdrawLink` (a `unique_hash` plus metadata)
3. ATM composes the callback URL: `{LNBITS_HTTP_URL}/withdraw/api/v1/lnurl/{unique_hash}` and bech32-encodes it as an LNURL
4. ATM displays the LNURL as a QR
5. Customer scans with any LNURL-withdraw-capable wallet, redeems it
6. LNbits settles the withdrawal via its Lightning backend
7. LNbits pushes a kind-21000 settlement event to the ATM, filtered by `tag="withdraw"` + `link_id`
8. ATM marks the session complete (cash already physically accepted earlier in the flow)
### Characteristics
- **Decentralized**: No single server owns state
- **Interoperable**: Standard protocols (Nostr, CLINK, Lightning)
- **Keypair identity**: ATM is identified by npub, not server account
- **Loose coupling**: ATM can work with any relay/Lightning.Pub
- **Privacy-first**: No central transaction logs required
- **Decentralized message layer**: any nostr relay works, no proprietary protocol
- **No admin tokens on the kiosk**: the ATM's signing key IS the credential — there are no HTTP API tokens or client certs to leak. LNbits derives the calling identity from the event signature.
- **Loose coupling**: ATM and LNbits don't need to share a network segment; only a relay both reach
- **Privacy-first**: no central transaction logs unless the operator builds them. LNbits stores its own ledger, but bitSpire writes no user-identifying data anywhere on its side.
---
## Detailed Comparison
## Detailed comparison
### 1. Infrastructure Requirements
### 1. Infrastructure requirements
| Requirement | Traditional | Nostr-Native |
| -------------------- | ------------------------------------ | -------------------------------- |
| **Server Hardware** | Dedicated server (4+ GB RAM, SSD) | Optional (can use public relays) |
| **Database** | PostgreSQL (backup, maintenance) | None required |
| **SSL Certificates** | Required (client certs for machines) | Not required |
| **Domain Name** | Required | Optional |
| **Static IP** | Recommended | Not required |
| **Firewall Rules** | Complex (multiple ports) | Simple (outbound WebSocket only) |
| Requirement | Traditional | bitSpire |
|---|---|---|
| Server hardware | Dedicated server (4+ GB RAM, SSD) | An LNbits instance (1 GB RAM is plenty in production) |
| Database | PostgreSQL (operator managed) | LNbits's SQLite (or its configured DB; LNbits manages it) |
| Client TLS | Required (client certs per machine) | Not required (signature-based auth over a nostr relay) |
| Domain | Required | Optional (only if exposing LNbits HTTP for LNURL redemption from the public internet) |
| Static IP | Recommended | Not required |
| Firewall | Multiple inbound ports | Outbound WebSocket to a relay; inbound only on the LNbits HTTP port for LNURL callbacks |
**Advantage: Nostr-Native** - Dramatically simpler infrastructure. An operator can start with public relays and self-host later.
**Edge: bitSpire.** A single-container LNbits with FakeWallet is enough for an operator to evaluate the whole stack end-to-end.
### 2. Security Model
### 2. Security model
| Aspect | Traditional | Nostr-Native |
| -------------------- | ------------------------------- | ------------------------ |
| **Machine Auth** | Client certificates | Keypair (nsec) |
| **Message Security** | TLS | NIP-44 encryption |
| **Identity Theft** | Steal cert + key | Steal nsec only |
| **Compromise Scope** | All machines if server breached | Individual machine only |
| **Key Storage** | Server manages | Machine manages own keys |
| Aspect | Traditional | bitSpire |
|---|---|---|
| Machine auth | Client certificates | Nostr signing key |
| Transport security | TLS | NIP-44 v2 (XChaCha20 + HMAC-SHA256) |
| Compromise scope | Server breach exposes all machines | One machine's `nsec` compromises only that machine |
| Key storage | Server manages | Machine manages its own keys (`/var/lib/bitspire/.env`, mode 0600) |
| Revocation | CA-style cert revocation | Operator can stop honoring a pubkey at the LNbits layer; no fleet-wide blast radius |
**Advantage: Nostr-Native** - Compromise of one machine doesn't affect others. No central point of failure.
**Edge: bitSpire.** No central authority that, if compromised, exposes the whole fleet. Per-machine compromise stays contained.
### 3. Payment Flow
### 3. Payment flow
| Aspect | Traditional | Nostr-Native |
| ------------------------ | ---------------------- | ------------------------------ |
| **Invoice Creation** | Server creates via LND | Lightning.Pub via Nostr RPC |
| **Payment Detection** | Server polls LND | Subscription to payment events |
| **Wallet Compatibility** | Any Lightning wallet | CLINK-compatible wallets |
| **Offline Capability** | None | Cashu ecash (planned) |
| **Payment Protocol** | BOLT11 only | BOLT11 + CLINK + noffer |
| Aspect | Traditional | bitSpire |
|---|---|---|
| Invoice creation | Server creates via LND | LNbits creates via its Lightning backend, returned over kind-21000 |
| Payment detection | Server polls LND | LNbits pushes a settlement event over kind-21000 (`subscribe_payments` filtered by `payment_hash`) |
| Cash-in detection | Custom flow | LNbits pushes a `tag="withdraw"` + `link_id` event when the LNURL is redeemed |
| Wallet compatibility | Any BOLT11 wallet | Any BOLT11 wallet (cash-out) + any LNURL-withdraw wallet (cash-in) |
| Offline capability | None | Cashu ecash (placeholder package, not yet wired) |
**Mixed**: Traditional has broader wallet compatibility today. Nostr-Native enables advanced features (ndebit, noffer) but requires CLINK wallets.
**Tied.** Both architectures end up using BOLT11 + LNURL-withdraw at the customer-wallet boundary. The difference is on the ATM side: traditional polls a server it owns; bitSpire subscribes to a push channel on a wallet it shares with anyone who can reach the relay.
### 4. Operator Experience
### 4. Operator experience
| Aspect | Traditional | Nostr-Native |
| ----------------------- | ------------------------------- | -------------------------- |
| **Setup Time** | Hours (server, DB, certs) | Minutes (connect to relay) |
| **Maintenance** | DB backups, updates, monitoring | Minimal |
| **Fleet Management** | Admin UI (full-featured) | Dashboard (planned) |
| **Transaction History** | PostgreSQL queries | Nostr event queries |
| **Configuration** | Server-pushed | Local or relay-stored |
| Aspect | Traditional | bitSpire |
|---|---|---|
| Setup time | Hours (provision server, DB, TLS, cert each machine) | Minutes (run an LNbits instance + a relay; flash + provision each ATM) |
| Maintenance | DB backups, server updates, monitoring | Update LNbits + the bitSpire ATMs (the latter auto-upgrades from the `dev` branch nightly) |
| Fleet management | Mature Admin UI | Per-ATM `journalctl`; centralized dashboard is planned, not built |
| Transaction history | PostgreSQL queries | Per-ATM `/var/lib/bitspire/state.db` (SQLite); operator-side aggregation is on them to build |
| Configuration | Server-pushed | Per-ATM `.env` written via `provision-atm.sh` |
**Mixed**: Traditional has mature tooling. Nostr-Native is simpler but dashboard is still in development.
**Mixed.** Traditional has mature operator tooling. bitSpire is simpler to stand up but the fleet-management story is currently "ssh + scripts" rather than a UI.
### 5. Customer Experience
### 5. Customer experience
| Aspect | Traditional | Nostr-Native |
| ----------------------- | ------------------------ | ----------------------- |
| **Cash-In** | Scan invoice, pay | Scan ndebit, authorize |
| **Cash-Out** | Enter phone, receive SMS | Scan noffer, receive |
| **Wallet Requirements** | Any Lightning wallet | CLINK-compatible wallet |
| **Account Required** | Sometimes (compliance) | Never |
| **Transaction Speed** | ~10 seconds | ~5 seconds |
| Aspect | Traditional | bitSpire |
|---|---|---|
| Cash-out | Scan BOLT11, pay | Scan BOLT11, pay |
| Cash-in | Varies by deployment (LNURL-withdraw common, sometimes custom) | Scan LNURL-withdraw QR, redeem |
| Wallet requirements | Any LN wallet for cash-out | Any LN wallet for cash-out + any LNURL-w wallet for cash-in |
| Account required | Sometimes (compliance) | Never |
| Transaction speed | ~10 seconds | ~3-5 seconds (push-based; no server polling delay) |
**Advantage: Nostr-Native** - Faster, more private, no accounts. But requires specific wallet support.
**Tied on protocol; slight edge to bitSpire on speed** because LNbits push events skip the polling cycle that traditional setups have between LND and the application server.
### 6. Compliance & Regulation
### 6. Compliance & regulation
| Aspect | Traditional | Nostr-Native |
| -------------------- | ----------------------- | --------------------- |
| **KYC Integration** | Built-in | Not included |
| **Transaction Logs** | PostgreSQL | Optional Nostr events |
| **Audit Trail** | Comprehensive | Operator-defined |
| **Reporting** | Admin UI exports | Custom tooling needed |
| **Regulatory Fit** | Designed for compliance | Privacy-first design |
| Aspect | Traditional | bitSpire |
|---|---|---|
| KYC integration | Built-in | Not included |
| Transaction logs | PostgreSQL (server) | Per-ATM SQLite + whatever LNbits records on its side |
| Audit trail | Comprehensive | Operator-defined; LNbits's ledger is the canonical Lightning-side record |
| Reporting | Admin UI exports | Custom tooling needed |
| Regulatory fit | Designed for KYC/AML jurisdictions | Privacy-first design; suits jurisdictions without identity-collection requirements |
**Advantage: Traditional** - If you need KYC/AML, traditional is ready. Nostr-Native assumes jurisdictions without these requirements.
**Edge: Traditional** if you need built-in compliance. bitSpire targets KYC-free operation; operators in regulated jurisdictions would need to add compliance hooks themselves.
### 7. Resilience & Availability
### 7. Resilience & availability
| Aspect | Traditional | Nostr-Native |
| --------------------------- | ---------------------- | ------------------------- |
| **Server Down** | All machines offline | Machines continue working |
| **Network Partition** | Transactions fail | Can use multiple relays |
| **Database Corruption** | Catastrophic | No database to corrupt |
| **Recovery** | Restore from backup | Re-sync from relay |
| **Geographic Distribution** | Single server location | Relay anywhere |
| Aspect | Traditional | bitSpire |
|---|---|---|
| Server down | All machines stop transacting | Machines stop transacting if LNbits is down; relay outage means RPCs hang |
| Network partition | Transactions fail | Can connect to multiple relays for failover |
| Database corruption | Catastrophic (cascading impact across fleet) | LNbits-side issue affects only the affected operator's wallet; per-ATM SQLite is local and independent |
| Recovery | Restore from backup | LNbits-side restore is its concern; ATMs come back up clean by re-reading `.env` |
| Geographic distribution | Single server | LNbits can be regional; relays can be anywhere |
**Advantage: Nostr-Native** - No single point of failure. Machines are autonomous.
**Edge: bitSpire** for blast-radius reasons. LNbits-side failures are no worse than `lamassu-server`-side failures, but bitSpire's per-ATM state is genuinely isolated.
### 8. Development & Extensibility
### 8. Development & extensibility
| Aspect | Traditional | Nostr-Native |
| --------------------------- | ----------------- | ------------------------- |
| **Codebase** | Large monolith | Modular packages |
| **Protocol** | Proprietary | Open standards |
| **Third-party Integration** | Custom API needed | Standard Nostr/CLINK |
| **Community** | Lamassu operators | Nostr + Bitcoin ecosystem |
| **Forkability** | Complex | Straightforward |
| Aspect | Traditional | bitSpire |
|---|---|---|
| Codebase | Large monolith (`lamassu-server` is hundreds of files) | Modular workspace; ATM, transport client, HAL, state machine are separate packages |
| Protocol | Custom WebSocket schema | NIP-01 + NIP-44 v2 + a thin documented kind-21000 envelope |
| Third-party integration | Custom API needed | Any nostr/Lightning client can talk to LNbits over the transport |
| Community | Lamassu operators (and ex-operators after the license change) | Nostr + Bitcoin ecosystem; LNbits community |
| Forkability | Complex (server + machine are coupled) | Straightforward (replace any one package without touching the others) |
**Advantage: Nostr-Native** - Open protocols mean broader ecosystem participation.
**Edge: bitSpire.** Open protocols mean a wider ecosystem of compatible clients, wallets, and tools.
---
## Migration Path
## When to choose what
### Phase 1: Parallel Operation
### Choose bitSpire if
Run both systems simultaneously:
- You operate in a jurisdiction without mandatory KYC/AML
- You want minimal infrastructure
- You value customer privacy (no PII collection, no central server-side ledger of who-paid-what)
- You're comfortable with emerging tooling (no admin UI yet)
- You want a license that stays open (AGPL-3.0)
- Existing machines continue with lamassu-server
- New machines or test units use lamassu-next
- Compare reliability and customer feedback
### Phase 2: Hybrid Mode
Bridge the systems:
- lamassu-server creates Nostr events for transactions
- Dashboard reads from both PostgreSQL and relay
- Gradual feature parity validation
### Phase 3: Full Migration
Complete transition:
- All machines run lamassu-next firmware
- Retire lamassu-server infrastructure
- Optional: Keep PostgreSQL as read-only archive
### Migration Considerations
| Consideration | Notes |
| -------------------------- | ----------------------------------------------- |
| **Hardware Compatibility** | Same bill validators/dispensers, new software |
| **Customer Education** | May need CLINK-compatible wallet guidance |
| **Operator Training** | Different mental model (events vs database) |
| **Regulatory Review** | Verify compliance in your jurisdiction |
| **Rollback Plan** | Keep lamassu-server available during transition |
---
## Trade-offs & Honest Assessment
### Where Nostr-Native Excels
1. **Simplicity**: No server to manage, no database to backup
2. **Privacy**: No central transaction logs
3. **Resilience**: No single point of failure
4. **Interoperability**: Works with any CLINK wallet
5. **Speed**: Direct relay communication is faster
6. **Cost**: No server hosting costs (can use public relays)
### Where Traditional May Be Better
1. **Compliance**: Built-in KYC/AML if required by law
2. **Wallet Support**: Works with any Lightning wallet today
3. **Maturity**: Battle-tested over years of operation
4. **Tooling**: Full-featured admin dashboard exists
5. **Support**: Established support channels and documentation
### Current Limitations of Nostr-Native
| Limitation | Status | Mitigation |
| ---------------------- | ------------- | ---------------------------------- |
| Dashboard not complete | In progress | Use relay queries directly |
| Limited wallet support | Growing | ShockWallet, others adopting CLINK |
| No offline mode yet | Cashu planned | Requires internet currently |
| Less documentation | Improving | This document helps |
---
## Conclusion
The Nostr-native architecture represents a fundamental shift from centralized to decentralized ATM operation. It trades the comprehensive compliance features of lamassu-server for simplicity, privacy, and resilience.
**Choose Nostr-Native if:**
- You operate in jurisdictions without KYC requirements
- You want minimal infrastructure overhead
- You value customer privacy
- You're comfortable with emerging technology
**Stick with Traditional if:**
### Stick with `lamassu-server` v8.1.5 if
- You need built-in compliance features
- You require extensive fleet management tools today
- You need to support any Lightning wallet
- You prefer mature, battle-tested systems
- You need a mature admin dashboard *today*
- You have existing infrastructure investment in PostgreSQL + LND tooling
- You're comfortable being on a code line that no longer receives upstream updates (v8.1.5 is the last open release; bug fixes in v8.1.6+ are paid-license-only)
**Consider Hybrid if:**
### Choose Lamassu's current commercial offering if
- You want to evaluate both approaches
- You're planning a gradual migration
- You have mixed regulatory requirements across locations
- You want vendor support, official updates, and a commercial SLA
- You're willing to pay the per-machine subscription
- You operate in a regulated jurisdiction where KYC tooling matters
This last option is outside the scope of this document — see [lamassu.is](https://lamassu.is) for their current commercial terms.
---
## Further Reading
## Trade-offs & honest assessment
- [CLINK Protocol Specification](https://github.com/shocknet/clink)
- [Lightning.Pub Documentation](https://github.com/shocknet/Lightning.Pub)
- [Nostr Protocol (NIP-01)](https://github.com/nostr-protocol/nips/blob/master/01.md)
- [NIP-44 Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md)
- [ndebit Cash-In Flow](./ndebit-cash-in-flow.md)
### Where bitSpire excels
1. **Simplicity** — LNbits + a relay is a much smaller surface than a stateful application server + DB + admin tooling
2. **Privacy** — no central transaction logs by design
3. **Resilience** — no single point of failure on the ATM side; LNbits is replaceable
4. **Interoperability** — works with any BOLT11 / LNURL-withdraw wallet
5. **Speed** — push-based settlement detection skips the LND-poll cycle
6. **Cost** — no Lamassu license fees; no commercial admin-server hosting
### Where traditional (`lamassu-server` ≤ v8.1.5) is still better
1. **Compliance** — built-in KYC/AML if your jurisdiction requires it
2. **Tooling** — mature admin dashboard, fleet management, transaction reporting
3. **Maturity** — battle-tested in production over many years
4. **Documentation** — established support channels for operators (community-maintained for v8.1.5; commercial for v8.1.6+)
### Current limitations of bitSpire
| Limitation | Status | Mitigation |
|---|---|---|
| No fleet dashboard | Planned, not built | Per-ATM `journalctl` + custom scripts |
| Limited transaction reporting | SQLite per ATM | Aggregate via operator-side ETL if needed |
| No offline mode | `@bitSpire/cashu` is a placeholder | Requires LNbits connectivity currently |
| Smaller operator community | Growing | This doc + the README walkthrough |
---
## Further reading
- [LNbits nostr-native-transport docs](https://git.atitlan.io/aiolabs/lnbits) — the wire spec for what's actually happening on kind-21000
- [NIP-01 Nostr basics](https://github.com/nostr-protocol/nips/blob/master/01.md)
- [NIP-44 v2 encryption](https://github.com/nostr-protocol/nips/blob/master/44.md)
- [LUD-03 LNURL-withdraw](https://github.com/lnurl/luds/blob/luds/03.md)
- [CLINK Protocol](./clink-protocol.md) — historical reference, dormant on `dev`
- [bitSpire deployment walkthrough](../deploy/nixos/README.md)
- [machine-installation.md](./machine-installation.md) — high-level deployment overview

View file

@ -33,7 +33,7 @@ The location buys (or finances) the machine and runs their own stack.
| Aspect | Details |
| ----------------- | ---------------------------------------- |
| Machine ownership | Them |
| Liquidity | Their own Lightning node + Lightning.Pub |
| Liquidity | Their own Lightning node + LNbits |
| Software updates | Them (we provide releases) |
| Cash management | Them |
| Fee configuration | Them |
@ -107,15 +107,15 @@ We're selective about where we place machines. The goal is to find locations wit
For the managed model to work at scale, we need:
- **Remote monitoring** — machine status, cash levels, error alerts over [[clink-protocol|CLINK]]/Nostr
- **Operator dashboard** — manage cassettes, view transactions, update config remotely
- **Multi-machine support** — one Lightning.Pub instance serving multiple ATMs
- **Remote monitoring** — machine status, cash levels, error alerts over nostr (kind-30078 service beacons + kind-21003 management commands)
- **Operator dashboard** — manage cassettes, view transactions, update config remotely (planned)
- **Multi-machine support** — one LNbits instance can host wallets for many ATMs; each ATM auto-creates its own wallet from its nostr pubkey
- **Fee configuration** — per-machine fee % set by operator
- **Availability broadcast** — public Nostr events indicating cash-in/cash-out availability (see [[ndebit-cash-in-flow|cash-in flow]])
- **Availability broadcast** — public kind-30078 events indicating cash-in/cash-out availability and reserves
## Related
- [[device-configuration]] — hardware setup for different machine models
- [[machine-installation]] — NixOS installation on ATM hardware
- [[clink-protocol]] — CLINK protocol for ATM-wallet communication
- [[ndebit-cash-in-flow]] — cash-in payment flow
- [[machine-installation]] — deployment overview for ATM hardware
- [Deploy/NixOS README](../deploy/nixos/README.md) — full deployment walkthrough
- [[architecture-comparison]] — bitSpire vs traditional lamassu-server

View file

@ -4,23 +4,23 @@ This document describes how to configure the ATM hardware for different machine
## Overview
The ATM application supports multiple Lamassu machine models out of the box. Configuration is handled through:
bitSpire ships built-in presets for the hardware platforms we've tested on. Configuration is handled through:
1. **Machine presets** - Built-in defaults for known hardware (Sintra, Gaia)
2. **Environment variables** - Override any setting at runtime
3. **Runtime overrides** - Programmatic configuration
1. **Machine presets** — built-in defaults for known hardware (Sintra, tejo, douro, batm3)
2. **Environment variables** — override any setting at runtime
3. **Runtime overrides** — programmatic configuration
## Supported Machine Models
## Supported machine models
### Sintra (Default)
### Sintra
The Lamassu Sintra (Gen 2) uses:
The Sintra (Aaeon UP Board-based, Intel Atom x5-Z8350) uses:
| Device | Protocol | Path |
| -------------- | -------- | ------------ |
| Bill Validator | ID003 | `/dev/ttyJ5` |
| Bill Dispenser | F56 | `/dev/ttyJ7` |
| Printer | Nippon | `/dev/ttyJ4` |
| Device | Protocol | Path | Underlying device |
|---|---|---|---|
| Bill Validator | ID003 | `/dev/ttyJ5` | FTDI USB-serial → `/dev/ttyUSB1` |
| Bill Dispenser | F56 | `/dev/ttyJ7` | SoC MMIO UART → `/dev/ttyS4` |
| Printer | Nippon | `/dev/ttyJ4` | FTDI USB-serial → `/dev/ttyUSB0` |
**Hardware:**
@ -28,9 +28,19 @@ The Lamassu Sintra (Gen 2) uses:
- Validator: JCM iVIZION
- Dispenser: Fujitsu F53/F56
### Gaia
**Sintra-specific gotcha:** the kernel allocates `ttyS0..ttyS3` as placeholder serial nodes that error on any I/O. Only `ttyS0` (legacy 8250 at I/O 0x3f8) and `ttyS4` (SoC MMIO 16550A at 0xa171b000) are real on this hardware. The `bitspire-atm` NixOS module's `upboard.nix` keeps the kernel console on `tty0` only (not `ttyS4`) so userspace can claim `ttyS4` for the F56 dispenser.
The Lamassu Gaia uses similar hardware with different device paths. Verify paths on your specific unit.
### tejo
The Tejo runs on the same Aaeon UP Board family as Sintra; same validator + dispenser layout. Uses the same `upboard.nix` hardware module.
### Douro
Runs on a Dell OptiPlex 9030 AIO (Intel i7-4790S, Intel HD 4600) — Lamassu's stock Douro motherboard. SATA SSD instead of eMMC, eGalax touchscreen, dispenser/validator on USB-attached FTDI bridges. See `deploy/nixos/hardware/douro.nix` for the specifics.
### BATM3
The "batm3" target in this repo is **not the stock GeneralBytes BATM3** — it's a custom modification where the original ARM/Android board has been physically replaced with a Dell OptiPlex 9030 AIO (same hardware family as the Douro). The chassis, cash-handling units (MEI SCR validator/recycler + Puloon F56 dispenser), and screen are stock BATM3; everything compute-side is grafted in. See `deploy/nixos/hardware/batm3.nix` for the boot configuration; functionally it shares the Dell-board layout with Douro but with different cash hardware on the inside.
## Environment Variables
@ -155,15 +165,19 @@ On a Sintra running Linux, verify the serial devices exist:
ls -la /dev/ttyJ*
```
Expected output:
Expected output on a working Sintra:
```
lrwxrwxrwx 1 root root 10 Jan 29 12:00 /dev/ttyJ4 -> ttyS4
lrwxrwxrwx 1 root root 10 Jan 29 12:00 /dev/ttyJ5 -> ttyS5
lrwxrwxrwx 1 root root 10 Jan 29 12:00 /dev/ttyJ7 -> ttyS7
lrwxrwxrwx 1 root root 7 May 13 09:22 /dev/ttyJ4 -> ttyUSB0
lrwxrwxrwx 1 root root 7 May 13 09:22 /dev/ttyJ5 -> ttyUSB1
lrwxrwxrwx 1 root root 5 May 13 09:22 /dev/ttyJ7 -> ttyS4
```
If the symlinks don't exist, check the udev rules or use the underlying `/dev/ttyS*` devices directly.
(`ttyUSB0`/`ttyUSB1` are the two FTDI USB-serial bridges — printer + validator. `ttyS4` is the SoC's on-carrier MMIO UART used for the F56 dispenser.)
If the symlinks don't exist, check the udev rules in `deploy/nixos/hardware/upboard.nix`, then run `mdev -s` (or `udevadm trigger` on a non-Alpine system) to repopulate `/dev`.
If you see a symlink pointing at `ttyS1`/`ttyS2`/`ttyS3` instead, those are kernel-allocated placeholder nodes that always error on I/O — the udev rule needs to map to `ttyS4` for Sintra. The current `upboard.nix` covers all the cases (`ttyS1`, `ttyS4`, `ttyS5`) so whichever real device shows up gets the `ttyJ7` alias.
## Troubleshooting

View file

@ -1,278 +1,96 @@
# Machine Installation
# Deploying bitSpire to a Sintra (or other ATM)
This document describes how to deploy the bitSpire ATM software to a Sintra machine. (Historical name: "Lamassu Next" — renamed on the `dev` branch as part of the LNbits-backend transition.)
This document gives the high-level shape of an ATM deployment. **For the step-by-step walkthrough — every command from `nix build` to a kiosk on a real Sintra — see [deploy/nixos/README.md](../deploy/nixos/README.md).** This file is the orientation doc that explains *why* the pipeline looks the way it does.
## Prerequisites
> The pre-NixOS workflow described in earlier versions of this file (manual AppImage scp, hand-written systemd unit, ad-hoc env files) has been retired. NixOS is the supported deployment path on `dev`. The historical AppImage build still exists for one-off testing on non-NixOS dev boxes, but it is not how production ATMs are installed.
### On the Sintra
## The pipeline at a glance
- Linux OS with X11 display server
- Network connectivity (WiFi or Ethernet)
- User account with `dialout` group membership (for serial port access)
### Backend Services
The ATM requires network access to:
| Service | Purpose | Required |
| ------------- | ------------------- | -------- |
| Nostr relay | CLINK communication | Yes |
| Lightning.Pub | Payment processing | Yes |
These can be self-hosted or provided by a third party.
## Building the Application
### Development Machine Setup
1. Enter the development environment:
```bash
cd lamassu-next
devenv shell
```
2. Build the Electron application:
```bash
cd apps/machine
pnpm build:electron
```
3. Build artifacts are created in `apps/machine/release/`:
```
release/
├── bitSpire-0.1.0.AppImage # Portable executable (recommended)
└── linux-unpacked/ # Unpacked application directory
```
## Deployment
### Option A: AppImage (Recommended)
The AppImage is a self-contained executable that works on any Linux distribution.
1. Copy to Sintra:
```bash
scp "apps/machine/release/bitSpire-0.1.0.AppImage" user@sintra:/opt/lamassu/
```
2. Make executable:
```bash
ssh user@sintra
chmod +x "/opt/lamassu/bitSpire-0.1.0.AppImage"
```
3. Test manually:
```bash
export DISPLAY=:0
"/opt/lamassu/bitSpire-0.1.0.AppImage" --no-sandbox
```
### Option B: Unpacked Directory
For faster startup times, deploy the unpacked application:
1. Copy to Sintra:
```bash
scp -r apps/machine/release/linux-unpacked user@sintra:/opt/lamassu/
```
2. Test manually:
```bash
ssh user@sintra
export DISPLAY=:0
/opt/lamassu/linux-unpacked/lamassu-machine --no-sandbox
```
## Configuration
### Environment Variables
Create a configuration file at `/opt/lamassu/.env`:
```bash
# Machine model (sintra, gaia, or custom)
VITE_LAMASSU_MACHINE_MODEL=sintra
# Fiat currency code (ISO 4217)
VITE_LAMASSU_FIAT_CODE=USD
# Custom device paths (optional, uses preset defaults if not set)
# VITE_LAMASSU_VALIDATOR_DEVICE=/dev/ttyJ5
# VITE_LAMASSU_DISPENSER_DEVICE=/dev/ttyJ7
# Cassette configuration (optional)
# VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100}]'
```
┌─────────────────┐ nix build .# ┌─────────────────────────┐
│ dev box │ ─disk-image-sintra──▶ │ result/nixos.img │
│ (Nix + flake) │ │ (8.5 GB sparse, GPT) │
└─────────────────┘ └────────────┬────────────┘
│ dd
▼
┌──────────────────────┐
│ USB stick │
└──────────┬───────────┘
│ carry to ATM
▼
┌─────────────────┐ ┌─────────────────────┐
│ Alpine live USB │ boot Sintra │ Sintra (UP Board) │
│ (toolchain) │ ─────────────────────▶ │ eMMC: empty │
└────────┬────────┘ └──────────┬──────────┘
│ apk add + dd USB → eMMC + parted resize + reboot
▼
┌────────────────────────────────────────────────────────────────┐
│ Sintra booted from eMMC, bitspire.service in needs-prov state │
└──────────────────────────────┬─────────────────────────────────┘
│ provision-atm.sh from dev box
▼
┌────────────────────────────────────────────────────────────────┐
│ /var/lib/bitspire/.env populated, service restarted, kiosk │
│ connects to LNbits over nostr-transport, ready for transactions │
└────────────────────────────────────────────────────────────────┘
```
See [Device Configuration](./device-configuration.md) for detailed configuration options.
## Why this pipeline
### Lightning.Pub Connection
Three properties matter for ATM software running unattended in retail locations:
The ATM needs to connect to a Lightning.Pub instance. Configure via environment:
1. **Reproducible boot media.** Two ATMs flashed from the same `nixos.img` boot identical environments — same kernel, same systemd units, same Electron build, same nix-store closure. No "works on my machine" drift between fleet units.
2. **Auditable provenance.** Every file on the ATM traces back to a derivation in `/nix/store/`, and every derivation traces back to a commit in this repo. There's no `pip install` reaching out to PyPI, no `npm install` pulling un-pinned packages, no `apt update` mutating the system underfoot.
3. **Safe in-place updates.** `nixos-rebuild switch` against the `dev` branch atomically installs a new generation; if the new generation fails to boot or activate, the previous one stays bootable. Auto-upgrade at 04:00 daily means an operator can patch the fleet by pushing to `dev`.
```bash
# Nostr relay WebSocket URL (required)
VITE_RELAY_URL=wss://your-relay.example.com
The disk-image approach (versus `nixos-install` from a live USB) is specifically to avoid the human-in-the-loop installation step. Every Sintra gets the same image; the only per-machine variation is the `.env` written at provisioning time.
# Lightning.Pub's Nostr public key (required)
# Get from: docker logs lamassu-lightning-pub | grep pubkey
VITE_LIGHTNING_PUB_PUBKEY=4be8e203a3341bb2b74a4dcbf8774e061437f63ec21af7ec3144c8d0a68e2f39
## What's in the image
# Lightning.Pub HTTP API URL (optional, for admin operations)
VITE_LIGHTNING_PUB_API_URL=https://lp.operator.com
Conceptually:
# ATM's Nostr private key (recommended for persistent identity)
# Generate with: npx @lamassu/nostr-client generate-keypair
# If not set, a new ephemeral identity is generated on each restart
VITE_ATM_PRIVATE_KEY=0123456789abcdef...
```
| Layer | Source | Purpose |
|---|---|---|
| **Kernel + initrd** | `nixpkgs` 24.05 + `upboard.nix` initrd modules | Boot the Sintra hardware (eMMC via `sdhci-acpi`, validator/dispenser at `ttyJ5`/`ttyJ7`) |
| **NixOS base** | `nixpkgs` 24.05 | systemd, Xorg, openbox, the `lamassu` user, sshd for provisioning |
| **bitspire.service** | `deploy/nixos/bitspire-atm.nix` | systemd unit that launches the Electron kiosk |
| **The Electron app** | `apps/machine` built into a nix derivation | The actual ATM UI + state machine + Lightning client |
| **Hardware-specific config** | `deploy/nixos/hardware/upboard.nix` (or `douro.nix`, `batm3.nix`) | udev rules, kernel modules, panel calibration |
**Important:** The `VITE_LIGHTNING_PUB_PUBKEY` is required. Without it, the ATM cannot communicate with Lightning.Pub.
## What's NOT in the image
## Auto-Start on Boot
The image is **identity-free** by design. After flashing, the ATM has no concept of:
- Which LNbits server to talk to
- Which nostr relay to use
- Its own nostr identity (signing key)
- Which fiat currency to display (defaulted from build args, but overridable)
### Systemd Service
All of these come from `/var/lib/bitspire/.env`, which is written by `provision-atm.sh` after first boot. This separation means a single image flavor can serve dev, staging, and prod just by changing the provisioning data.
Create `/etc/systemd/system/lamassu-kiosk.service`:
## Pre-deployment checklist
```ini
[Unit]
Description=bitSpire ATM Kiosk
After=graphical.target network-online.target
Wants=network-online.target
Before flashing a Sintra you'll want:
[Service]
Type=simple
User=lamassu
Environment=DISPLAY=:0
EnvironmentFile=/opt/lamassu/.env
WorkingDirectory=/opt/lamassu
ExecStart=/opt/lamassu/linux-unpacked/lamassu-machine --no-sandbox
Restart=always
RestartSec=5
- [ ] **An Alpine live USB** for use as the installer-OS on the Sintra (Alpine is small, has a working busybox toolchain, and apk is fast). The bitSpire image itself is *not* a live system — it expects to live on the eMMC.
- [ ] **A second USB stick** to flash the bitSpire image onto (this is what you'll dd between on the Sintra).
- [ ] **A running LNbits instance** with the nostr-native-transport branch built in. The dev compose at `~/dev/local/docker/regtest` provides one; production deployments point at `lnbits.aiolabs.dev` or your operator's instance.
- [ ] **Network reachability between the Sintra and the LNbits host.** The ATM connects to the relay endpoint at `ws://<lnbits-host>:5001/nostrrelay/test` (no separate strfry container — LNbits ships its own `nostrrelay` extension).
- [ ] **The LNbits server pubkey** (`docker logs <lnbits-container> | grep 'Public key (share this)'`).
- [ ] **A freshly generated nostr private key** for the ATM (`openssl rand -hex 32`). Each ATM should have its own; never share keys between machines.
[Install]
WantedBy=graphical.target
```
## After deployment
Enable and start:
Once the kiosk is up, useful things to know:
```bash
sudo systemctl daemon-reload
sudo systemctl enable lamassu-kiosk
sudo systemctl start lamassu-kiosk
```
- **Service status:** `ssh lamassu@<atm> 'sudo systemctl status bitspire'`
- **Live log tail:** `ssh lamassu@<atm> 'sudo journalctl -u bitspire -f'`
- **Re-provision (e.g., wrong relay URL):** rerun `provision-atm.sh` from the dev box with the new env vars
- **Push a code change without reflashing:** `nixos-rebuild switch --flake .#sintra-installed --target-host lamassu@<atm> --use-remote-sudo`
- **Inspect transaction history:** `ssh lamassu@<atm> 'sudo bash /etc/nixos/atm-transactions.sh'` (queries `/var/lib/bitspire/state.db`)
### Monitoring
## Related documentation
```bash
# Check status
sudo systemctl status lamassu-kiosk
# View logs
sudo journalctl -u lamassu-kiosk -f
# Restart after configuration changes
sudo systemctl restart lamassu-kiosk
```
## Hardware Verification
### Serial Port Access
1. Verify devices exist:
```bash
ls -la /dev/ttyJ*
# Expected: /dev/ttyJ4 (printer), /dev/ttyJ5 (validator), /dev/ttyJ7 (dispenser)
```
2. Check user permissions:
```bash
groups
# Should include 'dialout'
```
3. If not in dialout group:
```bash
sudo usermod -a -G dialout $USER
# Logout and login for changes to take effect
```
### Display Configuration
The Sintra uses a 1080x1920 portrait display. Verify X11 is running:
```bash
echo $DISPLAY
# Should output :0 or similar
xdpyinfo | head -5
# Should show display information
```
## Troubleshooting
### Application Won't Start
1. **Missing display**: Ensure `DISPLAY=:0` is set
2. **Sandbox error**: Use `--no-sandbox` flag
3. **Permission denied**: Check file is executable (`chmod +x`)
### Hardware Not Responding
1. **Check serial ports exist**: `ls -la /dev/ttyJ*`
2. **Check permissions**: User must be in `dialout` group
3. **Check connections**: Ensure cables are properly seated
4. **Power cycle hardware**: Turn validator/dispenser off and on
### Network Issues
1. **Test relay connection**: `websocat wss://your-relay.example.com`
2. **Check DNS resolution**: `ping your-relay.example.com`
3. **Verify firewall**: Ensure outbound WebSocket connections allowed
### Logs
Application logs are written to:
- **Systemd**: `journalctl -u lamassu-kiosk`
- **Electron**: `~/.config/lamassu-machine/logs/`
## Updating
1. Build new version on development machine
2. Stop the service:
```bash
sudo systemctl stop lamassu-kiosk
```
3. Replace application files:
```bash
scp -r apps/machine/release/linux-unpacked/* user@sintra:/opt/lamassu/linux-unpacked/
```
4. Restart service:
```bash
sudo systemctl start lamassu-kiosk
```
## Security Notes
- The `--no-sandbox` flag is required for Electron on some Linux configurations. This is acceptable for a dedicated kiosk machine.
- Keep the ATM's nsec private key secure. It authorizes all transactions from this machine.
- Use a dedicated user account (`lamassu`) with minimal privileges.
- Consider firewall rules to restrict network access to only required services.
- [deploy/nixos/README.md](../deploy/nixos/README.md) — the full step-by-step walkthrough and NixOS module reference
- [device-configuration.md](./device-configuration.md) — hardware-specific configuration (validator types, cassette layouts, fiat currency)
- [adr/001-hal-architecture.md](./adr/001-hal-architecture.md) — why HAL is TypeScript-in-Node rather than Rust-via-Tauri
- [CLAUDE.md](../CLAUDE.md) — the dev-facing overview, including the hardware-specific gotchas we hard-learned on the first real Sintra flash