feat(docker): add dev.sh with auto-funding and ATM app setup
- Add dev.sh script for managing regtest development environment - Implement cmd_fund to fund ATM app owner via Lightning.Pub API - Add --fund flag to cmd_up for automatic funding on startup - Update setup_atm_app to write VITE_APP_ID to machine .env - Fix Electron IPC to pass appId and extensionApiUrl to renderer - Restructure repo from nested lamassu-next/ to root The dev.sh script now supports: - ./dev.sh up --fund # Start regtest and auto-fund ATM - ./dev.sh fund # Fund existing ATM app - ./dev.sh status # Show environment status - ./dev.sh reset # Clean restart Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
30a2eb2199
commit
c98f126ba7
180 changed files with 2695 additions and 9587 deletions
140
docs/adr/001-hal-architecture.md
Normal file
140
docs/adr/001-hal-architecture.md
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# ADR-001: Hardware Abstraction Layer Architecture
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-01-26
|
||||
**Context:** Lamassu Next ATM proof of concept
|
||||
|
||||
## Decision
|
||||
|
||||
We will use **TypeScript HAL drivers extracted from lamassu-machine** rather than rewriting in Rust, and use **Electron instead of Tauri** for the kiosk application.
|
||||
|
||||
## Context
|
||||
|
||||
The Lamassu Next project needs to control ATM hardware (bill validators, dispensers) on Lamassu Sintra machines. Initial plans considered:
|
||||
|
||||
1. Rust HAL with Tauri backend
|
||||
2. TypeScript HAL with Tauri (IPC bridge)
|
||||
3. TypeScript HAL with Electron (direct integration)
|
||||
|
||||
### Hardware Target: Lamassu Sintra
|
||||
|
||||
- **Platform:** Aaeon UP Board (Intel Atom x5-Z8350, x86 Linux)
|
||||
- **Bill Validator:** JCM iVIZION using ID003 protocol
|
||||
- **Bill Dispenser:** Fujitsu F53/F56
|
||||
- **Serial:** `/dev/ttyJ5` (validator), `/dev/ttyJ7` (dispenser)
|
||||
- **Protocol:** RS-232, 9600 baud, 8 data bits, even parity, 1 stop bit
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option 1: Rust HAL + Tauri
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Native Tauri integration
|
||||
- Memory safety guarantees
|
||||
- Single backend language
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Requires full protocol reimplementation
|
||||
- Two debugging environments (Rust + TypeScript)
|
||||
- Serial at 9600 baud doesn't benefit from Rust performance
|
||||
- Significant time investment with uncertain payoff
|
||||
|
||||
### Option 2: TypeScript HAL + Tauri
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Reuse battle-tested lamassu-machine drivers
|
||||
- TypeScript throughout application code
|
||||
|
||||
**Cons:**
|
||||
|
||||
- IPC boundary between Tauri (Rust) and HAL (Node.js)
|
||||
- Serialization overhead and complexity
|
||||
- Two processes to manage and debug
|
||||
|
||||
### Option 3: TypeScript HAL + Electron (Selected)
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Direct `serialport` npm access in main process
|
||||
- Single debugging environment (Chrome DevTools)
|
||||
- All packages run natively (state-machine, HAL, lightning, clink)
|
||||
- Mature ecosystem, well-documented edge cases
|
||||
- Fastest path to working proof of concept
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Larger binary size (~150MB vs ~10MB)
|
||||
- Higher memory usage than Tauri
|
||||
- Bundles Chromium
|
||||
|
||||
## Decision Rationale
|
||||
|
||||
### Why not Rust?
|
||||
|
||||
The JavaScript drivers in lamassu-machine have run in production for years. The complexity is in the protocol state machines (ID003 FSM, F56 DLE/STX framing), not raw performance. Serial communication at 9600 baud is trivial - Rust's performance benefits are irrelevant here.
|
||||
|
||||
Adding Rust creates a language boundary that requires:
|
||||
|
||||
- IPC serialization/deserialization
|
||||
- Two build systems
|
||||
- Two debugging environments
|
||||
- Potential for bugs at the boundary
|
||||
|
||||
For a proof of concept, minimizing unknowns is critical.
|
||||
|
||||
### Why Electron over Tauri?
|
||||
|
||||
Tauri's benefits (small binary, low RAM) optimize for end-user desktop apps where download size matters. An ATM is a single-purpose kiosk:
|
||||
|
||||
- Binary size irrelevant - installed once on dedicated hardware
|
||||
- RAM sufficient - Sintra has 2-4GB, not competing with other apps
|
||||
- Development speed critical - proof of concept needs fast iteration
|
||||
|
||||
Electron allows the Vue UI, state machine, HAL, and Lightning packages to all run in the same Node.js environment with unified debugging.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### Completed
|
||||
|
||||
- `@lamassu/hal` package created with:
|
||||
- ID003 bill validator driver (JCM iVIZION)
|
||||
- F56 bill dispenser driver (Fujitsu F53/F56)
|
||||
- TypeScript types for all interfaces
|
||||
- Extracted from lamassu-machine, fully typed
|
||||
|
||||
### Remaining Work
|
||||
|
||||
1. Convert `apps/machine` from Tauri to Electron
|
||||
2. Wire HAL to state machine services:
|
||||
- `dispenseCash` → F56Dispenser.dispense()
|
||||
- Bill events → state machine events
|
||||
3. Add device configuration (serial port paths)
|
||||
4. Test on physical Sintra hardware
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Faster proof of concept development
|
||||
- Easier debugging during hardware integration
|
||||
- Reuse of proven driver code
|
||||
- Single language throughout
|
||||
|
||||
### Negative
|
||||
|
||||
- Larger deployment size (acceptable for kiosk)
|
||||
- Cannot leverage Rust safety guarantees (mitigated by mature JS drivers)
|
||||
|
||||
### Future Considerations
|
||||
|
||||
If a compelling reason for Rust emerges (it likely won't), the HAL interface is abstracted - drivers could be reimplemented without changing the state machine integration. The Vue UI works with either Electron or Tauri.
|
||||
|
||||
## 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
|
||||
338
docs/architecture-comparison.md
Normal file
338
docs/architecture-comparison.md
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
# Architecture Comparison: Nostr-Native 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.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## Traditional Architecture (lamassu-server/machine)
|
||||
|
||||
### Overview
|
||||
|
||||
```
|
||||
┌─────────────────┐ WebSocket ┌─────────────────────┐
|
||||
│ ATM Machine │◄─────────────────────────►│ lamassu-server │
|
||||
│ (lamassu-machine)│ │ │
|
||||
└─────────────────┘ │ ┌───────────────┐ │
|
||||
│ │ 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 |
|
||||
|
||||
### Communication 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
|
||||
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
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
## Nostr-Native Architecture (lamassu-next)
|
||||
|
||||
### Overview
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ 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 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### 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.) |
|
||||
|
||||
### Communication Flow (Cash-In Example)
|
||||
|
||||
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)
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
## Detailed Comparison
|
||||
|
||||
### 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) |
|
||||
|
||||
**Advantage: Nostr-Native** - Dramatically simpler infrastructure. An operator can start with public relays and self-host later.
|
||||
|
||||
### 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 |
|
||||
|
||||
**Advantage: Nostr-Native** - Compromise of one machine doesn't affect others. No central point of failure.
|
||||
|
||||
### 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 |
|
||||
|
||||
**Mixed**: Traditional has broader wallet compatibility today. Nostr-Native enables advanced features (ndebit, noffer) but requires CLINK wallets.
|
||||
|
||||
### 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 |
|
||||
|
||||
**Mixed**: Traditional has mature tooling. Nostr-Native is simpler but dashboard is still in development.
|
||||
|
||||
### 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 |
|
||||
|
||||
**Advantage: Nostr-Native** - Faster, more private, no accounts. But requires specific wallet support.
|
||||
|
||||
### 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 |
|
||||
|
||||
**Advantage: Traditional** - If you need KYC/AML, traditional is ready. Nostr-Native assumes jurisdictions without these requirements.
|
||||
|
||||
### 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 |
|
||||
|
||||
**Advantage: Nostr-Native** - No single point of failure. Machines are autonomous.
|
||||
|
||||
### 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 |
|
||||
|
||||
**Advantage: Nostr-Native** - Open protocols mean broader ecosystem participation.
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Parallel Operation
|
||||
|
||||
Run both systems simultaneously:
|
||||
|
||||
- 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:**
|
||||
|
||||
- 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
|
||||
|
||||
**Consider Hybrid if:**
|
||||
|
||||
- You want to evaluate both approaches
|
||||
- You're planning a gradual migration
|
||||
- You have mixed regulatory requirements across locations
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [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)
|
||||
|
|
@ -1,714 +0,0 @@
|
|||
---
|
||||
title: Architecture Review - KYC-Free Lightning-First Vision
|
||||
created: 2026-01-22
|
||||
updated: 2026-01-22
|
||||
tags:
|
||||
- architecture
|
||||
- lightning
|
||||
- kyc-free
|
||||
- redesign
|
||||
- vision
|
||||
status: active
|
||||
priority: critical
|
||||
---
|
||||
|
||||
# Architecture Review: KYC-Free Lightning-First Vision
|
||||
|
||||
> [!abstract] Summary
|
||||
> A comprehensive review of our project architecture, reimagining Lamassu from scratch as a **KYC-free, open-source, Lightning-native** Bitcoin ATM ecosystem. We have complete freedom to redesign - no backward compatibility concerns.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [[#Vision Statement]]
|
||||
- [[#Current State Analysis]]
|
||||
- [[#Proposed Architecture]]
|
||||
- [[#Lightning Backend Options]]
|
||||
- [[#Privacy Technologies]]
|
||||
- [[#Critical Decisions]]
|
||||
|
||||
---
|
||||
|
||||
## Vision Statement
|
||||
|
||||
> [!important] Core Principles
|
||||
> 1. **KYC-Free** - No identity collection, no compliance theater
|
||||
> 2. **Open-Source First** - Every component auditable and forkable
|
||||
> 3. **Lightning-Native** - Security through protocol, not policy
|
||||
> 4. **Self-Custodial** - Operator and user control their own keys
|
||||
> 5. **Privacy by Default** - Minimize data collection and retention
|
||||
> 6. **Autonomous Machines** - Reduce server dependency
|
||||
|
||||
### What We're Building
|
||||
|
||||
The **ultimate Bitcoin Lightning ATM** with a wallet ecosystem that:
|
||||
- Converts cash ↔ Lightning instantly
|
||||
- Requires no identity verification
|
||||
- Operates with minimal infrastructure
|
||||
- Can function offline with ecash
|
||||
- Supports NFC tap-to-pay
|
||||
- Enables operator sovereignty
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Lamassu Codebase Issues
|
||||
|
||||
The existing Lamassu codebase carries significant baggage:
|
||||
|
||||
| Component | Problem | Impact |
|
||||
|-----------|---------|--------|
|
||||
| `lib/compliance/` | KYC/AML workflows | 40% of server code |
|
||||
| `lib/customers/` | Identity management | Database bloat |
|
||||
| `lib/sanctions/` | OFAC screening | External dependencies |
|
||||
| `lib/sms/` | Phone verification | Privacy violation |
|
||||
| `lib/id-scan/` | Document verification | Third-party APIs |
|
||||
| `lib/blacklist/` | User blocking | Centralized control |
|
||||
| Multi-coin | Altcoin support | Code complexity |
|
||||
|
||||
> [!warning] Assessment
|
||||
> **60%+ of lamassu-server code is compliance-related.** Rather than removing it surgically, a clean rebuild may be more efficient.
|
||||
|
||||
### Current LNbits Integration (As Documented)
|
||||
|
||||
Our current docs treat LNbits as a simple payment backend:
|
||||
|
||||
```
|
||||
Machine → Server → LNbits → Lightning Network
|
||||
```
|
||||
|
||||
**Problems with this approach:**
|
||||
1. Server is still a bottleneck
|
||||
2. Single point of failure
|
||||
3. Not utilizing LNbits' full potential
|
||||
4. Missing privacy technologies (Cashu, Fedimint)
|
||||
5. Still designed around on-chain model
|
||||
|
||||
---
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
### Option A: Lean Server (Recommended)
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "ATM Machine"
|
||||
tauri[Tauri + Vue 3]
|
||||
ldk[LDK-Node / Phoenixd]
|
||||
hal[Rust HAL]
|
||||
end
|
||||
|
||||
subgraph "Minimal Coordinator"
|
||||
api[Fastify API]
|
||||
db[(SQLite/PostgreSQL)]
|
||||
end
|
||||
|
||||
subgraph "Lightning Layer"
|
||||
lnbits[LNbits]
|
||||
cashu[Cashu Mint]
|
||||
fedimint[Fedimint Gateway]
|
||||
end
|
||||
|
||||
tauri -->|LNURL/BOLT12| lnbits
|
||||
tauri -->|ecash| cashu
|
||||
tauri -->|Optional| api
|
||||
api --> db
|
||||
lnbits --> fedimint
|
||||
hal --> hardware[Hardware]
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- Machine can operate independently with embedded Lightning
|
||||
- Server becomes optional coordinator (fleet management, analytics)
|
||||
- Multiple Lightning backends supported
|
||||
- Ecash for offline capability
|
||||
|
||||
### Option B: Serverless Machine
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Autonomous ATM"
|
||||
ui[Vue 3 UI]
|
||||
xstate[XState v5]
|
||||
ldk[LDK-Node]
|
||||
cashu[Cashu Wallet]
|
||||
hal[Rust HAL]
|
||||
end
|
||||
|
||||
ldk -->|Direct| ln[Lightning Network]
|
||||
cashu -->|Swap| mint[Cashu Mint]
|
||||
hal --> hw[Hardware]
|
||||
|
||||
admin[Admin Phone App] -->|Bluetooth/Local| ui
|
||||
```
|
||||
|
||||
**Extreme autonomy:**
|
||||
- No central server at all
|
||||
- Machine runs its own Lightning node
|
||||
- Admin via local connection (phone app)
|
||||
- Perfect for single-operator deployments
|
||||
|
||||
### Option C: Fedimint Community Model
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Community Federation"
|
||||
g1[Guardian 1]
|
||||
g2[Guardian 2]
|
||||
g3[Guardian 3]
|
||||
g4[Guardian 4]
|
||||
end
|
||||
|
||||
subgraph "ATMs"
|
||||
atm1[Machine 1]
|
||||
atm2[Machine 2]
|
||||
atm3[Machine 3]
|
||||
end
|
||||
|
||||
subgraph "Gateway"
|
||||
gw[Lightning Gateway]
|
||||
end
|
||||
|
||||
atm1 --> g1
|
||||
atm2 --> g2
|
||||
atm3 --> g3
|
||||
g1 --> gw
|
||||
g2 --> gw
|
||||
g3 --> gw
|
||||
g4 --> gw
|
||||
gw --> ln[Lightning Network]
|
||||
```
|
||||
|
||||
**Community custody:**
|
||||
- Multiple guardians share custody
|
||||
- No single operator can rug
|
||||
- Built-in ecash for privacy
|
||||
- Ideal for community/coop deployments
|
||||
|
||||
---
|
||||
|
||||
## Lightning Backend Options
|
||||
|
||||
### Comparison Matrix
|
||||
|
||||
| Backend | Self-Custodial | Complexity | Offline | Privacy | Best For |
|
||||
|---------|---------------|------------|---------|---------|----------|
|
||||
| **LDK-Node** | Yes | High | No | Good | Embedded in machine |
|
||||
| **Phoenixd** | Yes | Low | No | Good | Simple server setup |
|
||||
| **LNbits** | Depends | Medium | Via Cashu | Good | Multi-wallet, extensions |
|
||||
| **Cashu** | No (mint) | Low | Yes | Excellent | Offline, privacy |
|
||||
| **Fedimint** | Federated | High | Yes | Excellent | Community custody |
|
||||
| **Breez SDK** | Yes | Medium | No | Good | Mobile-first |
|
||||
|
||||
### Recommendation: Layered Approach
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Layer 3: User-Facing Protocols │
|
||||
│ LNURL-withdraw, CLINK Offers, NFC │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Layer 2: Privacy & Offline │
|
||||
│ Cashu ecash, Fedimint e-cash │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Layer 1: Lightning Backends │
|
||||
│ LNbits (primary), Phoenixd, LDK-Node │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Use each layer for its strengths:**
|
||||
- **LNbits** - Backend abstraction, multi-wallet, extensions
|
||||
- **Cashu** - Offline payments, instant settlement, privacy
|
||||
- **LNURL** - User experience (scan QR to receive)
|
||||
- **CLINK Offers** - Static payment codes over Nostr (replaces BOLT12)
|
||||
|
||||
---
|
||||
|
||||
## Privacy Technologies
|
||||
|
||||
### Cashu Integration
|
||||
|
||||
> [!decision] Cashu for Offline & Privacy
|
||||
> Cashu ecash enables offline ATM operation and enhanced privacy.
|
||||
|
||||
**How it works for ATM:**
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant ATM
|
||||
participant Mint as Cashu Mint
|
||||
participant LN as Lightning
|
||||
|
||||
User->>ATM: Insert $20 cash
|
||||
ATM->>Mint: Request ecash tokens
|
||||
Mint->>ATM: Issue 20,000 sat tokens
|
||||
ATM->>User: Display QR (Cashu tokens)
|
||||
User->>User: Scan with Cashu wallet
|
||||
|
||||
Note over User,LN: Later, user can...
|
||||
User->>Mint: Redeem tokens
|
||||
Mint->>LN: Pay Lightning invoice
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ATM doesn't need to know user's Lightning wallet
|
||||
- User receives ecash, redeems whenever
|
||||
- ATM can operate offline (pre-loaded tokens)
|
||||
- Perfect privacy (blinded signatures)
|
||||
|
||||
**Cashu Libraries:**
|
||||
- `cashu-ts` - TypeScript SDK
|
||||
- `cashu-rs` - Rust implementation
|
||||
- `nutshell` - Python reference
|
||||
|
||||
### Fedimint Integration
|
||||
|
||||
> [!note] Fedimint for Community Operations
|
||||
> When multiple operators want shared custody without single points of failure.
|
||||
|
||||
**Architecture:**
|
||||
```typescript
|
||||
// Federation of 4 guardians (3-of-4 threshold)
|
||||
const federation = {
|
||||
guardians: [
|
||||
'operator1.onion',
|
||||
'operator2.onion',
|
||||
'operator3.onion',
|
||||
'operator4.onion',
|
||||
],
|
||||
threshold: 3,
|
||||
modules: ['wallet', 'mint', 'ln'],
|
||||
}
|
||||
```
|
||||
|
||||
**Use Cases:**
|
||||
- Bitcoin circular economy communities
|
||||
- Cooperative ATM networks
|
||||
- Regions with unstable operators
|
||||
|
||||
---
|
||||
|
||||
## Protocol Stack
|
||||
|
||||
### LNURL for ATM UX
|
||||
|
||||
> [!tip] LNURL-withdraw is Perfect for ATMs
|
||||
> User scans QR from ATM screen to pull sats to their wallet.
|
||||
|
||||
**Cash-In Flow (User buys Bitcoin):**
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant ATM
|
||||
participant LNbits
|
||||
|
||||
User->>ATM: Insert $50 cash
|
||||
ATM->>LNbits: Create LNURL-withdraw
|
||||
LNbits->>ATM: lnurl1dp68gurn8ghj7...
|
||||
ATM->>ATM: Display QR code
|
||||
User->>User: Scan with any LN wallet
|
||||
User->>LNbits: Request invoice (via LNURL)
|
||||
LNbits->>User: Pay invoice to user's wallet
|
||||
ATM->>ATM: Transaction complete
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Works with ANY Lightning wallet
|
||||
- No camera needed on ATM
|
||||
- User controls destination
|
||||
- Privacy preserved
|
||||
|
||||
**Cash-Out Flow (User sells Bitcoin):**
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant ATM
|
||||
participant LNbits
|
||||
|
||||
User->>ATM: Select "Sell Bitcoin"
|
||||
ATM->>LNbits: Create invoice
|
||||
LNbits->>ATM: BOLT11 invoice
|
||||
ATM->>ATM: Display QR code
|
||||
User->>User: Scan & pay invoice
|
||||
LNbits->>ATM: Payment confirmed
|
||||
ATM->>User: Dispense cash
|
||||
```
|
||||
|
||||
### CLINK Offers (Replaces BOLT12)
|
||||
|
||||
> [!decision] CLINK Offers for Static Payment Codes
|
||||
> Nostr-native static payment codes - superior to BOLT12.
|
||||
|
||||
**Why NOT BOLT12:**
|
||||
|
||||
| Problem | Impact |
|
||||
|---------|--------|
|
||||
| Onion messages | Tor-like routing adds latency, every hop = failure point |
|
||||
| Global round-trips | Requests can circle the world multiple times |
|
||||
| Mobile node normalization | Encourages unreliable always-offline nodes |
|
||||
| Redundant | LND keysend already provides static payments |
|
||||
| Astroturfed | NGO-pushed spec with questionable motives |
|
||||
|
||||
**Why CLINK:**
|
||||
- Uses Nostr relays (commodity infrastructure, trustless via NIP-44)
|
||||
- No HTTP callbacks, WebSockets, or Tor-like messaging
|
||||
- Keys decoupled from Lightning node identity
|
||||
- Already working: ShockWallet, Lightning.Pub, Stacker News
|
||||
|
||||
```typescript
|
||||
// CLINK Offer (noffer) - static payment code
|
||||
const atmOffer = 'noffer1qqs...'
|
||||
|
||||
// Invoice request flows through Nostr relays
|
||||
// No onion message round-trips!
|
||||
|
||||
// Using @shocknet/clink-sdk
|
||||
import { createOffer, requestInvoice } from '@shocknet/clink-sdk'
|
||||
|
||||
const offer = createOffer({
|
||||
pubkey: atmNostrPubkey,
|
||||
relays: ['wss://relay.damus.io', 'wss://nos.lol'],
|
||||
priceType: 'variable', // ATM calculates based on cash inserted
|
||||
})
|
||||
```
|
||||
|
||||
**CLINK Protocol:**
|
||||
- **Kind 21001**: Offer Request/Response
|
||||
- **Kind 21002**: Debit Request/Response
|
||||
- **Kind 21003**: Management Delegation
|
||||
|
||||
**References:**
|
||||
- [CLINK Spec](https://github.com/shocknet/CLINK)
|
||||
- [CLINK Demo](https://clinkme.dev/)
|
||||
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
|
||||
|
||||
### NFC BOLT Cards
|
||||
|
||||
> [!tip] Tap-to-Withdraw with NFC
|
||||
> Pre-programmed NFC cards for instant cash withdrawal.
|
||||
|
||||
**How BOLT Cards work:**
|
||||
1. NFC card contains LNURL-withdraw with rotating auth
|
||||
2. User taps card on ATM
|
||||
3. ATM reads LNURL, requests invoice
|
||||
4. Card's backing service pays invoice
|
||||
5. ATM dispenses cash
|
||||
|
||||
**Implementation:**
|
||||
- Cards use NXP NTAG 424 DNA (secure element)
|
||||
- Each tap generates unique auth code
|
||||
- Supports spending limits per tap/day
|
||||
- Compatible with: Coinos, LNbits, BTCPay
|
||||
|
||||
```typescript
|
||||
// LNbits BoltCards extension
|
||||
const card = {
|
||||
uid: '04:E1:5F:...',
|
||||
cardName: 'ATM Withdrawal Card',
|
||||
maxWithdrawPerTap: 50000, // sats
|
||||
dailyLimit: 200000, // sats
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Simplified Transaction Flows
|
||||
|
||||
### Cash → Lightning (Buy)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SIMPLIFIED BUY FLOW │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. User inserts cash [$20, $50, $100] │
|
||||
│ │
|
||||
│ 2. ATM displays QR [LNURL-withdraw] │
|
||||
│ "Scan to receive Bitcoin" │
|
||||
│ │
|
||||
│ 3. User scans with ANY [Phoenix, Zeus, Wallet of │
|
||||
│ Lightning wallet Satoshi, Breez, etc.] │
|
||||
│ │
|
||||
│ 4. Sats arrive instantly [~2 seconds] │
|
||||
│ │
|
||||
│ Done. No account. No KYC. No email. No phone. │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Lightning → Cash (Sell)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SIMPLIFIED SELL FLOW │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Option A: Pay Invoice │
|
||||
│ 1. Select amount to withdraw [$20, $50, $100] │
|
||||
│ 2. ATM shows Lightning invoice QR │
|
||||
│ 3. User pays from any wallet │
|
||||
│ 4. Cash dispensed │
|
||||
│ │
|
||||
│ Option B: NFC Tap (BOLT Card) │
|
||||
│ 1. User taps NFC card │
|
||||
│ 2. ATM reads LNURL-withdraw │
|
||||
│ 3. Cash dispensed │
|
||||
│ [Single tap, ~3 seconds total] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Offline Mode (Cashu)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ OFFLINE CASH-IN FLOW │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ATM has pre-loaded Cashu tokens from mint │
|
||||
│ │
|
||||
│ 1. User inserts $50 cash │
|
||||
│ │
|
||||
│ 2. ATM displays Cashu token QR │
|
||||
│ (No internet required!) │
|
||||
│ │
|
||||
│ 3. User scans with Cashu wallet │
|
||||
│ [Minibits, Nutstash, eNuts] │
|
||||
│ │
|
||||
│ 4. User can later swap ecash → Lightning │
|
||||
│ when they have connectivity │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Revised Component Architecture
|
||||
|
||||
### What We Keep from Lamassu
|
||||
|
||||
| Component | Keep? | Notes |
|
||||
|-----------|-------|-------|
|
||||
| Hardware drivers | Yes | Port to Rust HAL |
|
||||
| Bill validator protocols | Yes | ID003, eSSP, ccTalk |
|
||||
| Bill dispenser drivers | Yes | Puloon, Fujitsu |
|
||||
| Brain state machine | Rewrite | Simplify with XState v5 |
|
||||
| Admin UI | Partial | Rebuild in Vue 3 |
|
||||
| Server API | Minimal | Strip compliance code |
|
||||
|
||||
### What We Remove
|
||||
|
||||
| Component | Why Remove |
|
||||
|-----------|------------|
|
||||
| `lib/compliance/` | No KYC |
|
||||
| `lib/customers/` | No identity storage |
|
||||
| `lib/sanctions/` | No OFAC screening |
|
||||
| `lib/sms/` | No phone verification |
|
||||
| `lib/id-scan/` | No document scanning |
|
||||
| `lib/blacklist/` | No user blocking |
|
||||
| Multi-coin support | Bitcoin only |
|
||||
| Fiat exchange rates | Lightning is the unit |
|
||||
|
||||
### New Components to Build
|
||||
|
||||
| Component | Purpose | Technology |
|
||||
|-----------|---------|------------|
|
||||
| `lightning-service` | Backend abstraction | LNbits + Cashu + Fedimint |
|
||||
| `lnurl-server` | LNURL-withdraw/pay | Fastify + LNbits |
|
||||
| `clink-handler` | Static offers via Nostr | @shocknet/clink-sdk |
|
||||
| `cashu-bridge` | Offline capability | cashu-ts |
|
||||
| `nfc-handler` | BOLT card support | libnfc + Rust |
|
||||
| `admin-app` | Operator mobile app | Vue 3 + Capacitor |
|
||||
|
||||
---
|
||||
|
||||
## Revised Tech Stack
|
||||
|
||||
### Server (Coordinator)
|
||||
|
||||
```yaml
|
||||
Runtime: Node.js 22 LTS
|
||||
Language: TypeScript (strict)
|
||||
Framework: Fastify
|
||||
API: tRPC (admin), LNURL (public)
|
||||
Database: SQLite (single) / PostgreSQL (fleet)
|
||||
ORM: Drizzle
|
||||
Lightning: LNbits API
|
||||
Ecash: Cashu client
|
||||
```
|
||||
|
||||
### Machine
|
||||
|
||||
```yaml
|
||||
Shell: Tauri 2.x (Rust)
|
||||
UI: Vue 3 + Pinia + shadcn-vue
|
||||
State: XState v5
|
||||
Hardware: Rust HAL + napi-rs
|
||||
Lightning: LDK-Node or Phoenixd (optional)
|
||||
Ecash: Cashu wallet
|
||||
NFC: libnfc bindings
|
||||
```
|
||||
|
||||
### Mobile Admin App
|
||||
|
||||
```yaml
|
||||
Framework: Vue 3 + Ionic/Capacitor
|
||||
Connectivity: Bluetooth LE, Local WiFi
|
||||
Features: Machine pairing, balance check, settings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Critical Decisions Needed
|
||||
|
||||
### Decision 1: Server Model
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|--------|------|------|
|
||||
| **A: Lean Server** | Fleet management, familiar model | Single point of failure |
|
||||
| **B: Serverless** | Maximum autonomy | Complex admin |
|
||||
| **C: Fedimint** | Community custody | Requires federation |
|
||||
|
||||
> [!question] Recommendation
|
||||
> Start with **Option A (Lean Server)** for faster development, design for Option B compatibility.
|
||||
|
||||
### Decision 2: Primary Lightning Backend
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|--------|------|------|
|
||||
| **LNbits** | Extensions, multi-wallet | Requires server |
|
||||
| **Phoenixd** | Simple, self-custodial | ACINQ dependency |
|
||||
| **LDK-Node** | Embedded, maximum control | Complex |
|
||||
|
||||
> [!question] Recommendation
|
||||
> **LNbits** as primary (proven, extensible), with **Cashu** for offline mode.
|
||||
|
||||
### Decision 3: Ecash Strategy
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|--------|------|------|
|
||||
| **Cashu** | Simple, growing ecosystem | Single mint trust |
|
||||
| **Fedimint** | Federated trust | Complex setup |
|
||||
| **Both** | Maximum flexibility | Maintenance burden |
|
||||
|
||||
> [!question] Recommendation
|
||||
> **Cashu** first (simpler), add Fedimint support later.
|
||||
|
||||
### Decision 4: Rebuild vs Refactor
|
||||
|
||||
| Option | Effort | Risk | Result |
|
||||
|--------|--------|------|--------|
|
||||
| **Rebuild** | 6-12 months | Medium | Clean architecture |
|
||||
| **Refactor** | 12-18 months | High | Frankenstein code |
|
||||
|
||||
> [!question] Recommendation
|
||||
> **Rebuild** the core, reuse hardware drivers.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Foundation
|
||||
|
||||
- [ ] Create new monorepo structure
|
||||
- [ ] Set up devenv.nix for development
|
||||
- [ ] Port hardware drivers to Rust HAL
|
||||
- [ ] Implement LNURL-withdraw flow
|
||||
- [ ] Basic Vue 3 machine UI
|
||||
|
||||
### Phase 2: Lightning Integration
|
||||
|
||||
- [ ] LNbits integration (simplified from current docs)
|
||||
- [ ] LNURL-pay for cash-out
|
||||
- [ ] Cashu ecash support
|
||||
- [ ] NFC BOLT card support
|
||||
|
||||
### Phase 3: Operator Tools
|
||||
|
||||
- [ ] Minimal admin API
|
||||
- [ ] Vue 3 admin dashboard
|
||||
- [ ] Mobile admin app
|
||||
- [ ] Fleet management (optional)
|
||||
|
||||
### Phase 4: Advanced Features
|
||||
|
||||
- [ ] CLINK Offers (Nostr-native static codes)
|
||||
- [ ] Fedimint integration
|
||||
- [ ] LDK-Node embedded option
|
||||
- [ ] Offline-first mode
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Old vs New
|
||||
|
||||
| Aspect | Old Lamassu | New Vision |
|
||||
|--------|-------------|------------|
|
||||
| Identity | KYC/AML required | None collected |
|
||||
| Compliance | 60% of codebase | 0% |
|
||||
| Coins | 30+ altcoins | Bitcoin only |
|
||||
| On-chain | Primary | Emergency fallback |
|
||||
| Lightning | Secondary | Primary |
|
||||
| Privacy | Minimal | Maximum (Cashu) |
|
||||
| Server | Required | Optional |
|
||||
| Offline | Not possible | Cashu ecash |
|
||||
| NFC | Not supported | BOLT cards |
|
||||
| Custody | Operator holds | User self-custody |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Exchange rate source?** - Do we quote BTC/fiat or operate in sats-only mode?
|
||||
2. **Minimum viable admin?** - What's the smallest admin surface needed?
|
||||
3. **Machine authentication?** - How do machines auth to coordinator without certs?
|
||||
4. **Liquidity management?** - How do operators manage Lightning liquidity?
|
||||
5. **Regulatory reality?** - What jurisdictions can this operate in?
|
||||
|
||||
---
|
||||
|
||||
## Related Notes
|
||||
|
||||
- [[nostr-native-architecture]] - **Nostr as infrastructure backbone**
|
||||
- [[modernization-plan]] - Original tech stack decisions
|
||||
- [[lnbits-integration]] - LNbits as alternative to Lightning.Pub
|
||||
- [[membership-lightning-integration]] - Membership feature (simplify)
|
||||
- [[hardware-recommendations]] - Hardware choices
|
||||
- [[machine-ui-modernization]] - Vue 3 UI migration
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
### Lightning
|
||||
- [LDK Documentation](https://lightningdevkit.org/)
|
||||
- [Phoenixd](https://github.com/ACINQ/phoenixd)
|
||||
- [LNbits](https://lnbits.com/)
|
||||
- [LNURL Specifications](https://github.com/lnurl/luds)
|
||||
|
||||
### CLINK (Nostr-Native Lightning)
|
||||
- [CLINK Protocol Spec](https://github.com/shocknet/CLINK)
|
||||
- [CLINK Demo](https://clinkme.dev/)
|
||||
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
|
||||
- [ShockWallet](https://github.com/shocknet/wallet2)
|
||||
- [@shocknet/clink-sdk](https://www.npmjs.com/package/@shocknet/clink-sdk)
|
||||
|
||||
### Privacy/Ecash
|
||||
- [Cashu Protocol](https://cashu.space/)
|
||||
- [Fedimint](https://fedimint.org/)
|
||||
- [Cashu TypeScript SDK](https://github.com/cashubtc/cashu-ts)
|
||||
|
||||
### NFC
|
||||
- [BOLT Cards](https://bolt.cards/)
|
||||
- [LNbits BoltCards Extension](https://github.com/lnbits/lnbits/tree/main/lnbits/extensions/boltcards)
|
||||
|
||||
### Nostr Infrastructure
|
||||
- [strfry](https://github.com/hoytech/strfry) - High-performance relay
|
||||
- [rnostr](https://github.com/rnostr/rnostr) - Rust relay with NIP-42
|
||||
- [NIP-42: Auth](https://github.com/nostr-protocol/nips/blob/master/42.md)
|
||||
- [NIP-44: Encryption](https://github.com/paulmillr/nip44)
|
||||
- [NIP-17: Private DMs](https://nips.nostr.com/17)
|
||||
|
||||
### Reference Implementations
|
||||
- [FOSSA ATM](https://github.com/lnbits/fossa) - LNbits Lightning ATM
|
||||
- [Bleskomat](https://github.com/samotari/bleskomat) - Minimal Lightning ATM
|
||||
- [RoboSats](https://github.com/RoboSats/robosats) - KYC-free P2P exchange
|
||||
434
docs/clink-protocol.md
Normal file
434
docs/clink-protocol.md
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
# CLINK Protocol
|
||||
|
||||
CLINK (Custodial Lightning Keys) is a Nostr-based protocol for Lightning payments. It enables wallets and services to communicate payment requests and authorizations through Nostr relays.
|
||||
|
||||
## Overview
|
||||
|
||||
CLINK defines three Nostr event kinds:
|
||||
|
||||
| Kind | Name | Purpose |
|
||||
| ----- | ------ | -------------------------------- |
|
||||
| 21001 | Offer | Request and receive invoices |
|
||||
| 21002 | Debit | Authorize outgoing payments |
|
||||
| 21003 | Manage | Create and revoke payment offers |
|
||||
|
||||
And two encoding formats:
|
||||
|
||||
| Format | Purpose | Example Use |
|
||||
| ------ | ----------------------------------- | ------------------------- |
|
||||
| noffer | Encode a payment offer (receivable) | ATM displays "pay me" QR |
|
||||
| ndebit | Encode a debit authorization | ATM displays "pay you" QR |
|
||||
|
||||
## noffer (Payment Offer)
|
||||
|
||||
A `noffer` encodes information needed to **request a payment** from someone. When scanned, the payer's wallet sends a Kind 21001 to request an invoice.
|
||||
|
||||
### Format
|
||||
|
||||
```
|
||||
noffer1<bech32-encoded-data>
|
||||
```
|
||||
|
||||
### Encoded Data (TLV)
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | -------- | ----------------------------------------------- |
|
||||
| pubkey | 32 bytes | Recipient's Nostr pubkey (who receives payment) |
|
||||
| relay | string | Relay URL for communication |
|
||||
| price_type | uint8 | 0=fixed, 1=variable, 2=spontaneous |
|
||||
| amount | uint64 | Amount in sats (if fixed) |
|
||||
| description | string | Human-readable description |
|
||||
| pointer | string | Optional identifier (e.g., user ID) |
|
||||
|
||||
### Example: ATM Cash-Out noffer
|
||||
|
||||
The ATM wants to receive payment from a customer:
|
||||
|
||||
```typescript
|
||||
import { encodeNoffer } from '@lamassu/clink'
|
||||
|
||||
// ATM creates a noffer for receiving payment
|
||||
const noffer = encodeNoffer({
|
||||
pubkey: 'abc123...', // ATM's pubkey (or Lightning.Pub's pubkey)
|
||||
relay: 'wss://relay.example.com',
|
||||
priceType: 'spontaneous', // Customer chooses amount
|
||||
description: 'Lamassu ATM - Cash Out',
|
||||
})
|
||||
|
||||
// Result: noffer1qqs8h5mrx8v9...
|
||||
// Display as QR code
|
||||
```
|
||||
|
||||
### Flow: Customer Pays noffer
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Customer │ │ Relay │ │ ATM │
|
||||
│ (Wallet) │ │ │ │ │
|
||||
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
│ 1. Scan noffer QR │ │
|
||||
│ │ │
|
||||
│ 2. Send Kind 21001 request │ │
|
||||
│ ───────────────────────────────►│ │
|
||||
│ {amount: 50000, ...} │───────────────────────────────►│
|
||||
│ │ │
|
||||
│ │ 3. ATM creates invoice │
|
||||
│ │ │
|
||||
│ │ 4. Send Kind 21001 response │
|
||||
│ │◄───────────────────────────────│
|
||||
│◄────────────────────────────────│ {invoice: "lnbc..."} │
|
||||
│ │ │
|
||||
│ 5. Pay invoice │ │
|
||||
│ ════════════════════════════════════════════════════════════════►│
|
||||
│ (Lightning Network) │ │
|
||||
│ │ │
|
||||
│ │ 6. Payment received │
|
||||
│ │ ATM dispenses cash │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
## ndebit (Debit Authorization)
|
||||
|
||||
An `ndebit` encodes information needed to **authorize a payment** from someone's account. When scanned, the payer's wallet sends a Kind 21002 to authorize the payment.
|
||||
|
||||
### Format
|
||||
|
||||
```
|
||||
ndebit1<bech32-encoded-data>
|
||||
```
|
||||
|
||||
### Encoded Data (TLV)
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------- | -------- | ---------------------------------------- |
|
||||
| pubkey | 32 bytes | Custodian's pubkey (who holds the funds) |
|
||||
| relay | string | Relay URL for communication |
|
||||
| pointer | string | Account identifier at the custodian |
|
||||
|
||||
### Example: ATM Cash-In ndebit
|
||||
|
||||
The ATM wants to pay the customer (customer inserted cash, wants Bitcoin):
|
||||
|
||||
```typescript
|
||||
import { encodeNdebit, formatNdebitUri } from '@lamassu/clink'
|
||||
|
||||
// ATM creates an ndebit for the customer to authorize withdrawal
|
||||
const ndebit = encodeNdebit({
|
||||
pubkey: '4be8e203...', // Lightning.Pub's pubkey (custodian)
|
||||
relay: 'wss://relay.example.com',
|
||||
pointer: 'atm', // ATM's account identifier in Lightning.Pub
|
||||
})
|
||||
|
||||
// Add amount as query parameter
|
||||
const uri = formatNdebitUri(ndebit, 50000) // 50,000 sats
|
||||
|
||||
// Result: clink:ndebit1qqs8h5mrx8v9...?amount=50000
|
||||
// Display as QR code
|
||||
```
|
||||
|
||||
### Flow: Customer Authorizes ndebit
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Customer │ │ Relay │ │Lightning.Pub│
|
||||
│ (Wallet) │ │ │ │ │
|
||||
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
│ 1. Scan ndebit QR │ │
|
||||
│ (shows: "ATM wants to │ │
|
||||
│ send you 50,000 sats") │ │
|
||||
│ │ │
|
||||
│ 2. User confirms in wallet │ │
|
||||
│ │ │
|
||||
│ 3. Wallet sends Kind 21002 │ │
|
||||
│ ───────────────────────────────►│ │
|
||||
│ {amount: 50000, │───────────────────────────────►│
|
||||
│ invoice: "lnbc...", │ │
|
||||
│ pointer: "atm"} │ │
|
||||
│ │ │
|
||||
│ │ 4. Lightning.Pub verifies │
|
||||
│ │ and pays the invoice │
|
||||
│ │ │
|
||||
│ │ 5. Send Kind 21002 response │
|
||||
│ │◄───────────────────────────────│
|
||||
│◄────────────────────────────────│ {preimage: "abc..."} │
|
||||
│ │ │
|
||||
│ 6. Customer receives sats │ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
## Kind 21001: Offer (Invoice Request/Response)
|
||||
|
||||
Used to request and receive Lightning invoices.
|
||||
|
||||
### Request (Payer → Recipient)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 21001,
|
||||
"pubkey": "<payer_pubkey>",
|
||||
"created_at": 1706540000,
|
||||
"tags": [
|
||||
["p", "<recipient_pubkey>"],
|
||||
["e", "<noffer_event_id>", "", "root"]
|
||||
],
|
||||
"content": "<nip44_encrypted>{
|
||||
\"amount_sats\": 50000,
|
||||
\"description\": \"ATM withdrawal\"
|
||||
}"
|
||||
}
|
||||
```
|
||||
|
||||
### Response (Recipient → Payer)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 21001,
|
||||
"pubkey": "<recipient_pubkey>",
|
||||
"created_at": 1706540001,
|
||||
"tags": [
|
||||
["p", "<payer_pubkey>"],
|
||||
["e", "<request_event_id>", "", "reply"]
|
||||
],
|
||||
"content": "<nip44_encrypted>{
|
||||
\"invoice\": \"lnbc500u1p...\",
|
||||
\"amount_sats\": 50000
|
||||
}"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 21001,
|
||||
"pubkey": "<recipient_pubkey>",
|
||||
"content": "<nip44_encrypted>{
|
||||
\"error\": {
|
||||
\"code\": 1,
|
||||
\"message\": \"Insufficient balance\"
|
||||
}
|
||||
}"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Codes
|
||||
|
||||
| Code | Name | Description |
|
||||
| ---- | ----------------- | ----------------------------- |
|
||||
| 0 | Unknown | Unknown error |
|
||||
| 1 | InsufficientFunds | Not enough balance |
|
||||
| 2 | InvalidAmount | Amount out of range |
|
||||
| 3 | Expired | Offer has expired |
|
||||
| 4 | Unauthorized | Not authorized for this offer |
|
||||
| 5 | TemporaryFailure | Try again later |
|
||||
|
||||
## Kind 21002: Debit (Payment Authorization)
|
||||
|
||||
Used to authorize a custodian to pay an invoice on your behalf.
|
||||
|
||||
### Request (Payer → Custodian)
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 21002,
|
||||
"pubkey": "<payer_pubkey>",
|
||||
"created_at": 1706540000,
|
||||
"tags": [
|
||||
["p", "<custodian_pubkey>"]
|
||||
],
|
||||
"content": "<nip44_encrypted>{
|
||||
\"amount_sats\": 50000,
|
||||
\"invoice\": \"lnbc500u1p...\",
|
||||
\"pointer\": \"atm\"
|
||||
}"
|
||||
}
|
||||
```
|
||||
|
||||
The `pointer` identifies which account at the custodian should pay.
|
||||
|
||||
### Response (Custodian → Payer)
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 21002,
|
||||
"pubkey": "<custodian_pubkey>",
|
||||
"created_at": 1706540001,
|
||||
"tags": [
|
||||
["p", "<payer_pubkey>"],
|
||||
["e", "<request_event_id>", "", "reply"]
|
||||
],
|
||||
"content": "<nip44_encrypted>{
|
||||
\"preimage\": \"0123456789abcdef...\",
|
||||
\"amount_sats\": 50000
|
||||
}"
|
||||
}
|
||||
```
|
||||
|
||||
Error:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 21002,
|
||||
"content": "<nip44_encrypted>{
|
||||
\"error\": {
|
||||
\"code\": 1,
|
||||
\"message\": \"Insufficient balance in ATM account\"
|
||||
}
|
||||
}"
|
||||
}
|
||||
```
|
||||
|
||||
## Kind 21003: Manage (Offer Management)
|
||||
|
||||
Used to create, update, or revoke payment offers.
|
||||
|
||||
### Create Offer
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 21003,
|
||||
"pubkey": "<merchant_pubkey>",
|
||||
"tags": [
|
||||
["p", "<custodian_pubkey>"],
|
||||
["action", "create"]
|
||||
],
|
||||
"content": "<nip44_encrypted>{
|
||||
\"price_type\": \"fixed\",
|
||||
\"amount_sats\": 10000,
|
||||
\"description\": \"Coffee\",
|
||||
\"max_uses\": 100,
|
||||
\"expires_at\": 1707000000
|
||||
}"
|
||||
}
|
||||
```
|
||||
|
||||
### Revoke Offer
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 21003,
|
||||
"pubkey": "<merchant_pubkey>",
|
||||
"tags": [
|
||||
["p", "<custodian_pubkey>"],
|
||||
["action", "revoke"],
|
||||
["e", "<offer_event_id>"]
|
||||
],
|
||||
"content": ""
|
||||
}
|
||||
```
|
||||
|
||||
## ATM Use Cases
|
||||
|
||||
### Cash-Out (Customer Buys Cash with Bitcoin)
|
||||
|
||||
1. Customer approaches ATM, selects "Sell Bitcoin"
|
||||
2. ATM displays **noffer** QR (or invoice directly)
|
||||
3. Customer scans with wallet
|
||||
4. Wallet sends **Kind 21001** request
|
||||
5. ATM responds with **Kind 21001** containing invoice
|
||||
6. Customer pays invoice
|
||||
7. ATM detects payment, dispenses cash
|
||||
|
||||
```typescript
|
||||
// ATM generates noffer for cash-out
|
||||
const noffer = clink.createOffer({
|
||||
priceType: 'spontaneous',
|
||||
description: 'Lamassu ATM - Cash Out',
|
||||
})
|
||||
|
||||
// Display QR code with noffer
|
||||
displayQR(noffer)
|
||||
|
||||
// Listen for offer requests
|
||||
clink.onOfferRequest(async (request, senderPubkey) => {
|
||||
// Create invoice for requested amount
|
||||
const invoice = await lightningPub.createInvoice({
|
||||
amountSats: request.amount_sats,
|
||||
})
|
||||
|
||||
// Response is sent automatically by CLINK client
|
||||
return createOfferSuccess(invoice.paymentRequest)
|
||||
})
|
||||
```
|
||||
|
||||
### Cash-In (Customer Buys Bitcoin with Cash)
|
||||
|
||||
1. Customer approaches ATM, selects "Buy Bitcoin"
|
||||
2. Customer inserts cash bills
|
||||
3. ATM displays **ndebit** QR
|
||||
4. Customer scans with wallet
|
||||
5. Wallet prompts: "ATM wants to send you 50,000 sats. Approve?"
|
||||
6. Customer confirms, wallet sends **Kind 21002** with their invoice
|
||||
7. Lightning.Pub pays the invoice
|
||||
8. Customer receives sats
|
||||
|
||||
```typescript
|
||||
// ATM generates ndebit for cash-in
|
||||
const ndebit = encodeNdebit({
|
||||
pubkey: LIGHTNING_PUB_PUBKEY, // Custodian who will pay
|
||||
relay: RELAY_URL,
|
||||
pointer: 'atm', // ATM's account at Lightning.Pub
|
||||
})
|
||||
|
||||
const uri = formatNdebitUri(ndebit, satsAmount)
|
||||
|
||||
// Display QR code
|
||||
displayQR(uri)
|
||||
|
||||
// Lightning.Pub handles the Kind 21002 automatically
|
||||
// and pays the customer's invoice
|
||||
```
|
||||
|
||||
## Encryption
|
||||
|
||||
All `content` fields are encrypted using **NIP-44** (XChaCha20-Poly1305):
|
||||
|
||||
1. Derive shared secret from sender's private key + recipient's public key
|
||||
2. Encrypt content with XChaCha20-Poly1305
|
||||
3. Encode as base64
|
||||
|
||||
This ensures only the intended recipient can read payment details.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Always verify pubkeys** - Ensure the pubkey in noffer/ndebit matches expected recipient/custodian
|
||||
2. **Check amounts** - Validate amount is within acceptable range before authorizing
|
||||
3. **Verify relay** - Use trusted relays to prevent MITM attacks
|
||||
4. **Timestamp validation** - Reject old events to prevent replay attacks
|
||||
5. **Rate limiting** - Implement rate limits on offer requests
|
||||
|
||||
## Libraries
|
||||
|
||||
### JavaScript/TypeScript
|
||||
|
||||
```typescript
|
||||
import {
|
||||
encodeNoffer,
|
||||
decodeNoffer,
|
||||
encodeNdebit,
|
||||
decodeNdebit,
|
||||
formatNdebitUri,
|
||||
CLINKClient,
|
||||
createOfferSuccess,
|
||||
createOfferError,
|
||||
} from '@lamassu/clink'
|
||||
```
|
||||
|
||||
### Reference Implementation
|
||||
|
||||
- [CLINK Protocol Spec](https://github.com/shocknet/clink)
|
||||
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
|
||||
- [@lamassu/clink](../packages/clink/) - TypeScript implementation
|
||||
|
||||
## Related NIPs
|
||||
|
||||
| NIP | Purpose |
|
||||
| ------ | ------------------------- |
|
||||
| NIP-01 | Basic event structure |
|
||||
| NIP-04 | Encrypted DMs (legacy) |
|
||||
| NIP-44 | Encrypted payloads (used) |
|
||||
| NIP-19 | Bech32 encoding |
|
||||
197
docs/device-configuration.md
Normal file
197
docs/device-configuration.md
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# Device Configuration
|
||||
|
||||
This document describes how to configure the ATM hardware for different machine models.
|
||||
|
||||
## Overview
|
||||
|
||||
The ATM application supports multiple Lamassu machine models out of the box. 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
|
||||
|
||||
## Supported Machine Models
|
||||
|
||||
### Sintra (Default)
|
||||
|
||||
The Lamassu Sintra (Gen 2) uses:
|
||||
|
||||
| Device | Protocol | Path |
|
||||
| -------------- | -------- | ------------ |
|
||||
| Bill Validator | ID003 | `/dev/ttyJ5` |
|
||||
| Bill Dispenser | F56 | `/dev/ttyJ7` |
|
||||
| Printer | Nippon | `/dev/ttyJ4` |
|
||||
|
||||
**Hardware:**
|
||||
|
||||
- Platform: Aaeon UP Board (Intel Atom x5-Z8350)
|
||||
- Validator: JCM iVIZION
|
||||
- Dispenser: Fujitsu F53/F56
|
||||
|
||||
### Gaia
|
||||
|
||||
The Lamassu Gaia uses similar hardware with different device paths. Verify paths on your specific unit.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All configuration can be overridden via environment variables. For Vite/Electron, prefix with `VITE_`:
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------- | ---------------------------- | ------------- |
|
||||
| `VITE_LAMASSU_MACHINE_MODEL` | Machine preset | `sintra` |
|
||||
| `VITE_LAMASSU_FIAT_CODE` | Fiat currency (ISO 4217) | `USD` |
|
||||
| `VITE_LAMASSU_VALIDATOR_DEVICE` | Validator serial device path | (from preset) |
|
||||
| `VITE_LAMASSU_DISPENSER_DEVICE` | Dispenser serial device path | (from preset) |
|
||||
| `VITE_LAMASSU_CASSETTES` | JSON array of cassettes | (from preset) |
|
||||
|
||||
### Example: Custom Cassette Configuration
|
||||
|
||||
```bash
|
||||
# Two cassettes: $20 bills (100 count) and $50 bills (50 count)
|
||||
export VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]'
|
||||
```
|
||||
|
||||
### Example: Custom Device Paths
|
||||
|
||||
```bash
|
||||
export VITE_LAMASSU_VALIDATOR_DEVICE="/dev/ttyUSB0"
|
||||
export VITE_LAMASSU_DISPENSER_DEVICE="/dev/ttyUSB1"
|
||||
```
|
||||
|
||||
## Cassette Configuration
|
||||
|
||||
Each cassette is defined with:
|
||||
|
||||
```typescript
|
||||
interface CassetteConfig {
|
||||
denomination: number // Bill denomination (e.g., 20 for $20)
|
||||
count?: number // Number of bills loaded (optional, for inventory tracking)
|
||||
}
|
||||
```
|
||||
|
||||
### Default Sintra Configuration
|
||||
|
||||
```json
|
||||
[{ "denomination": 20, "count": 50 }]
|
||||
```
|
||||
|
||||
This configures a single cassette with $20 bills, 50 bill capacity.
|
||||
|
||||
### Multi-Cassette Example
|
||||
|
||||
```json
|
||||
[
|
||||
{ "denomination": 20, "count": 100 },
|
||||
{ "denomination": 50, "count": 50 },
|
||||
{ "denomination": 100, "count": 25 }
|
||||
]
|
||||
```
|
||||
|
||||
## Programmatic Configuration
|
||||
|
||||
You can also configure devices programmatically:
|
||||
|
||||
```typescript
|
||||
import { getDeviceConfig, toHalConfig } from '@/config'
|
||||
|
||||
// Use preset with custom fiat code
|
||||
const config = getDeviceConfig('sintra', 'EUR')
|
||||
|
||||
// Or with overrides
|
||||
const config = getDeviceConfig('sintra', 'USD', {
|
||||
dispenser: {
|
||||
type: 'f56',
|
||||
device: '/dev/ttyUSB1',
|
||||
cassettes: [
|
||||
{ denomination: 20, count: 100 },
|
||||
{ denomination: 50, count: 50 },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// Convert to HAL config format
|
||||
const halConfig = toHalConfig(config)
|
||||
```
|
||||
|
||||
## Initialization
|
||||
|
||||
### For Production (Recommended)
|
||||
|
||||
The simplest way to initialize with hardware:
|
||||
|
||||
```typescript
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
|
||||
const atm = useAtmStore()
|
||||
|
||||
// Uses environment variables with Sintra defaults
|
||||
await atm.initializeForProduction()
|
||||
```
|
||||
|
||||
### With Custom Config
|
||||
|
||||
```typescript
|
||||
import { useAtmStore } from '@/stores/atm'
|
||||
import { getDeviceConfig, toHalConfig } from '@/config'
|
||||
|
||||
const atm = useAtmStore()
|
||||
|
||||
const config = getDeviceConfig('sintra', 'USD', {
|
||||
dispenser: {
|
||||
type: 'f56',
|
||||
device: '/dev/ttyJ7',
|
||||
cassettes: [{ denomination: 20, count: 100 }],
|
||||
},
|
||||
})
|
||||
|
||||
await atm.initializeWithHal(toHalConfig(config))
|
||||
```
|
||||
|
||||
## Verifying Device Paths
|
||||
|
||||
On a Sintra running Linux, verify the serial devices exist:
|
||||
|
||||
```bash
|
||||
ls -la /dev/ttyJ*
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
If the symlinks don't exist, check the udev rules or use the underlying `/dev/ttyS*` devices directly.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Permission Denied on Serial Port
|
||||
|
||||
Add your user to the `dialout` group:
|
||||
|
||||
```bash
|
||||
sudo usermod -a -G dialout $USER
|
||||
# Log out and back in for changes to take effect
|
||||
```
|
||||
|
||||
### Device Not Found
|
||||
|
||||
1. Check the device exists: `ls -la /dev/ttyJ*` or `ls -la /dev/ttyS*`
|
||||
2. Check dmesg for hardware detection: `dmesg | grep tty`
|
||||
3. Verify udev rules are loaded: `udevadm info /dev/ttyS5`
|
||||
|
||||
### Validator Not Responding
|
||||
|
||||
1. Verify baud rate: ID003 uses 9600 baud, 8 data bits, even parity, 1 stop bit
|
||||
2. Check cable connections
|
||||
3. Power cycle the validator
|
||||
4. Check validator is in "online" mode (not standalone)
|
||||
|
||||
### Dispenser Not Dispensing
|
||||
|
||||
1. Verify cassette is properly seated
|
||||
2. Check for bill jams
|
||||
3. Verify dispenser firmware is compatible with F56 protocol
|
||||
4. Check the dispenser is powered and initialized
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,970 +0,0 @@
|
|||
---
|
||||
title: Machine UI Modernization
|
||||
created: 2026-01-22
|
||||
updated: 2026-01-22
|
||||
tags:
|
||||
- feature
|
||||
- vue
|
||||
- ui
|
||||
- lamassu-machine
|
||||
- refactor
|
||||
status: planning
|
||||
priority: high
|
||||
---
|
||||
|
||||
# Machine UI Modernization
|
||||
|
||||
> [!abstract] Summary
|
||||
> Replace the legacy vanilla JavaScript + jQuery UI in `lamassu-machine` with a modern **Vue 3** application using TypeScript, Pinia for state management, and Vite for building.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [[#Current State]]
|
||||
- [[#Why Vue]]
|
||||
- [[#Architecture]]
|
||||
- [[#Migration Strategy]]
|
||||
- [[#Implementation]]
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### Problems with Existing UI
|
||||
|
||||
```javascript
|
||||
// Current: lamassu-machine/ui/src/app.js (80KB single file)
|
||||
/* globals $, URLSearchParams, WebSocket, Keyboard, BigNumber, ... */
|
||||
'use strict'
|
||||
|
||||
var fiatCode = null
|
||||
var locale = null
|
||||
var currentState
|
||||
var websocket = null
|
||||
// ... 50+ global variables
|
||||
```
|
||||
|
||||
> [!warning] Technical Debt
|
||||
> - **Single 80KB file** with all logic
|
||||
> - **50+ global variables** for state
|
||||
> - **jQuery dependency** for DOM manipulation
|
||||
> - **No type safety** - runtime errors only
|
||||
> - **No component structure** - hard to test/maintain
|
||||
> - **Babel 6** (2016) for transpilation
|
||||
> - **Manual DOM updates** - error-prone
|
||||
|
||||
### Current Tech Stack
|
||||
|
||||
| Component | Current | Issues |
|
||||
|-----------|---------|--------|
|
||||
| Framework | Vanilla JS | No structure |
|
||||
| DOM | jQuery | Dated, heavy |
|
||||
| State | Global vars | Unmaintainable |
|
||||
| Build | Babel 6 | Outdated |
|
||||
| Styles | SCSS | OK, keep |
|
||||
| i18n | Jed (gettext) | Works, but heavy |
|
||||
|
||||
#currentstate #technicaldebt
|
||||
|
||||
---
|
||||
|
||||
## Why Vue
|
||||
|
||||
> [!decision] Vue 3 over React/Svelte/Solid
|
||||
|
||||
| Framework | Bundle Size | Learning Curve | Kiosk Fit |
|
||||
|-----------|-------------|----------------|-----------|
|
||||
| **Vue 3** | ~33kb | Low | Excellent |
|
||||
| React 19 | ~42kb | Medium | Good |
|
||||
| Svelte 5 | ~2kb | Low | Excellent |
|
||||
| Solid | ~7kb | Medium | Good |
|
||||
|
||||
### Vue Advantages for Kiosk
|
||||
|
||||
1. **Single-File Components (SFC)**
|
||||
- HTML, CSS, JS in one file
|
||||
- Natural for UI-focused development
|
||||
- Easy to understand screen-by-screen
|
||||
|
||||
2. **Composition API**
|
||||
- TypeScript-first design
|
||||
- Reusable composables for hardware
|
||||
- Better than Options API for complex state
|
||||
|
||||
3. **Progressive Adoption**
|
||||
- Can migrate screen-by-screen
|
||||
- Works alongside existing code during migration
|
||||
|
||||
4. **Smaller Bundle**
|
||||
- Critical for kiosk boot time
|
||||
- Tree-shakeable
|
||||
|
||||
5. **Vue Ecosystem**
|
||||
- **Pinia** - Type-safe state management
|
||||
- **VueUse** - Composables for common tasks
|
||||
- **Vue I18n** - Internationalization
|
||||
- **shadcn-vue** - Shared components with admin UI
|
||||
|
||||
> [!note] Why Not Svelte?
|
||||
> Svelte has the smallest bundle, but Vue has:
|
||||
> - Larger ecosystem for i18n, forms, etc.
|
||||
> - More developers familiar with it
|
||||
> - Better tooling maturity
|
||||
|
||||
> [!tip] Shared UI Components
|
||||
> Use shadcn-vue for base components (Button, Card, etc.) to share code between machine and admin UIs via `@lamassu/ui-shared` package.
|
||||
|
||||
#vue #framework
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Target Stack
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Tauri 2.x Shell │
|
||||
│ (Rust core, WebView for UI) │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Vue 3 Application │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Screens (Vue Components) │ │
|
||||
│ │ ├── IdleScreen.vue │ │
|
||||
│ │ ├── ChooseCoinScreen.vue │ │
|
||||
│ │ ├── InsertBillsScreen.vue │ │
|
||||
│ │ └── ... │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ State (Pinia Stores) │ │
|
||||
│ │ ├── useTransactionStore │ │
|
||||
│ │ ├── useMachineStore │ │
|
||||
│ │ └── useUIStore │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Composables │ │
|
||||
│ │ ├── useWebSocket │ │
|
||||
│ │ ├── useKeyboard │ │
|
||||
│ │ └── useQRScanner │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Hardware Bridge │
|
||||
│ (WebSocket ↔ brain.js state machine) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
lamassu-machine/
|
||||
├── ui/
|
||||
│ ├── src/
|
||||
│ │ ├── main.ts # Entry point
|
||||
│ │ ├── App.vue # Root component
|
||||
│ │ ├── router.ts # Screen routing
|
||||
│ │ │
|
||||
│ │ ├── screens/ # Full-screen views
|
||||
│ │ │ ├── IdleScreen.vue
|
||||
│ │ │ ├── ChooseCoinScreen.vue
|
||||
│ │ │ ├── ChooseLanguageScreen.vue
|
||||
│ │ │ ├── ScanAddressScreen.vue
|
||||
│ │ │ ├── InsertBillsScreen.vue
|
||||
│ │ │ ├── SendingCoinsScreen.vue
|
||||
│ │ │ ├── MembershipPromptScreen.vue
|
||||
│ │ │ ├── MembershipScanScreen.vue
|
||||
│ │ │ └── ...
|
||||
│ │ │
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ │ ├── common/
|
||||
│ │ │ │ ├── BaseButton.vue
|
||||
│ │ │ │ ├── QRCode.vue
|
||||
│ │ │ │ ├── LoadingSpinner.vue
|
||||
│ │ │ │ └── LanguageSelector.vue
|
||||
│ │ │ ├── keyboard/
|
||||
│ │ │ │ ├── VirtualKeyboard.vue
|
||||
│ │ │ │ └── Keypad.vue
|
||||
│ │ │ └── transaction/
|
||||
│ │ │ ├── CoinSelector.vue
|
||||
│ │ │ ├── BillAcceptor.vue
|
||||
│ │ │ └── AmountDisplay.vue
|
||||
│ │ │
|
||||
│ │ ├── stores/ # Pinia stores
|
||||
│ │ │ ├── transaction.ts
|
||||
│ │ │ ├── machine.ts
|
||||
│ │ │ ├── ui.ts
|
||||
│ │ │ └── i18n.ts
|
||||
│ │ │
|
||||
│ │ ├── composables/ # Reusable logic
|
||||
│ │ │ ├── useWebSocket.ts
|
||||
│ │ │ ├── useKeyboard.ts
|
||||
│ │ │ ├── useQRScanner.ts
|
||||
│ │ │ ├── useIdleTimeout.ts
|
||||
│ │ │ └── useSounds.ts
|
||||
│ │ │
|
||||
│ │ ├── types/ # TypeScript types
|
||||
│ │ │ ├── transaction.ts
|
||||
│ │ │ ├── machine.ts
|
||||
│ │ │ └── events.ts
|
||||
│ │ │
|
||||
│ │ ├── i18n/ # Internationalization
|
||||
│ │ │ ├── index.ts
|
||||
│ │ │ └── locales/
|
||||
│ │ │ ├── en.json
|
||||
│ │ │ ├── es.json
|
||||
│ │ │ └── ...
|
||||
│ │ │
|
||||
│ │ └── styles/ # Global styles
|
||||
│ │ ├── main.scss
|
||||
│ │ ├── variables.scss
|
||||
│ │ └── themes/
|
||||
│ │
|
||||
│ ├── index.html
|
||||
│ ├── vite.config.ts
|
||||
│ ├── tsconfig.json
|
||||
│ └── package.json
|
||||
│
|
||||
├── lib/
|
||||
│ └── brain.js # State machine (unchanged)
|
||||
│
|
||||
└── package.json
|
||||
```
|
||||
|
||||
#architecture #structure
|
||||
|
||||
---
|
||||
|
||||
## Core Components
|
||||
|
||||
### App.vue - Root Component
|
||||
|
||||
```vue
|
||||
<!-- ui/src/App.vue -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useUIStore } from './stores/ui'
|
||||
import { useWebSocket } from './composables/useWebSocket'
|
||||
|
||||
// Screens
|
||||
import IdleScreen from './screens/IdleScreen.vue'
|
||||
import ChooseCoinScreen from './screens/ChooseCoinScreen.vue'
|
||||
import InsertBillsScreen from './screens/InsertBillsScreen.vue'
|
||||
// ... other screens
|
||||
|
||||
const ui = useUIStore()
|
||||
const { connected } = useWebSocket()
|
||||
|
||||
const screenComponent = computed(() => {
|
||||
const screens: Record<string, Component> = {
|
||||
idle: IdleScreen,
|
||||
chooseCoin: ChooseCoinScreen,
|
||||
insertBills: InsertBillsScreen,
|
||||
// ... map all states to screens
|
||||
}
|
||||
return screens[ui.currentScreen] ?? IdleScreen
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="app"
|
||||
:class="[ui.theme, { rtl: ui.isRTL }]"
|
||||
:dir="ui.isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<Transition name="screen" mode="out-in">
|
||||
<component :is="screenComponent" :key="ui.currentScreen" />
|
||||
</Transition>
|
||||
|
||||
<!-- Global overlays -->
|
||||
<LoadingOverlay v-if="ui.isLoading" />
|
||||
<ErrorOverlay v-if="ui.error" :message="ui.error" />
|
||||
<ConnectionLost v-if="!connected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import './styles/main.scss';
|
||||
|
||||
.app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
|
||||
&.rtl {
|
||||
direction: rtl;
|
||||
}
|
||||
}
|
||||
|
||||
.screen-enter-active,
|
||||
.screen-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.screen-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.screen-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Transaction Store (Pinia)
|
||||
|
||||
```typescript
|
||||
// ui/src/stores/transaction.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Coin, Membership, Transaction } from '../types'
|
||||
|
||||
export const useTransactionStore = defineStore('transaction', () => {
|
||||
// State
|
||||
const direction = ref<'cashIn' | 'cashOut' | null>(null)
|
||||
const selectedCoin = ref<Coin | null>(null)
|
||||
const fiatAmount = ref(0)
|
||||
const cryptoAmount = ref(0)
|
||||
const walletAddress = ref<string | null>(null)
|
||||
const membership = ref<Membership | null>(null)
|
||||
const bills = ref<number[]>([])
|
||||
|
||||
// Computed
|
||||
const hasMembership = computed(() => membership.value !== null)
|
||||
const discountPercent = computed(() => membership.value?.tier.discountPercentage ?? 0)
|
||||
const totalFiat = computed(() => bills.value.reduce((sum, bill) => sum + bill, 0))
|
||||
|
||||
const effectiveRate = computed(() => {
|
||||
if (!selectedCoin.value) return 0
|
||||
const baseRate = selectedCoin.value.rate
|
||||
return baseRate * (1 - discountPercent.value / 100)
|
||||
})
|
||||
|
||||
// Actions
|
||||
function startCashIn(coin: Coin) {
|
||||
direction.value = 'cashIn'
|
||||
selectedCoin.value = coin
|
||||
bills.value = []
|
||||
}
|
||||
|
||||
function startCashOut(coin: Coin) {
|
||||
direction.value = 'cashOut'
|
||||
selectedCoin.value = coin
|
||||
}
|
||||
|
||||
function addBill(denomination: number) {
|
||||
bills.value.push(denomination)
|
||||
fiatAmount.value = totalFiat.value
|
||||
}
|
||||
|
||||
function setMembership(m: Membership) {
|
||||
membership.value = m
|
||||
if (m.lightningAddress) {
|
||||
walletAddress.value = m.lightningAddress
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
direction.value = null
|
||||
selectedCoin.value = null
|
||||
fiatAmount.value = 0
|
||||
cryptoAmount.value = 0
|
||||
walletAddress.value = null
|
||||
membership.value = null
|
||||
bills.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
direction,
|
||||
selectedCoin,
|
||||
fiatAmount,
|
||||
cryptoAmount,
|
||||
walletAddress,
|
||||
membership,
|
||||
bills,
|
||||
|
||||
// Computed
|
||||
hasMembership,
|
||||
discountPercent,
|
||||
totalFiat,
|
||||
effectiveRate,
|
||||
|
||||
// Actions
|
||||
startCashIn,
|
||||
startCashOut,
|
||||
addBill,
|
||||
setMembership,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### WebSocket Composable
|
||||
|
||||
```typescript
|
||||
// ui/src/composables/useWebSocket.ts
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useUIStore } from '../stores/ui'
|
||||
import { useTransactionStore } from '../stores/transaction'
|
||||
|
||||
interface BrainMessage {
|
||||
action: string
|
||||
state?: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function useWebSocket() {
|
||||
const ws = ref<WebSocket | null>(null)
|
||||
const connected = ref(false)
|
||||
const reconnectAttempts = ref(0)
|
||||
|
||||
const ui = useUIStore()
|
||||
const transaction = useTransactionStore()
|
||||
|
||||
function connect() {
|
||||
const host = import.meta.env.VITE_WS_HOST ?? 'localhost'
|
||||
const port = import.meta.env.VITE_WS_PORT ?? '8080'
|
||||
|
||||
ws.value = new WebSocket(`ws://${host}:${port}`)
|
||||
|
||||
ws.value.onopen = () => {
|
||||
connected.value = true
|
||||
reconnectAttempts.value = 0
|
||||
console.log('WebSocket connected')
|
||||
}
|
||||
|
||||
ws.value.onclose = () => {
|
||||
connected.value = false
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
ws.value.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
}
|
||||
|
||||
ws.value.onmessage = (event) => {
|
||||
const message: BrainMessage = JSON.parse(event.data)
|
||||
handleMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(message: BrainMessage) {
|
||||
switch (message.action) {
|
||||
case 'stateChange':
|
||||
ui.setScreen(message.state!)
|
||||
break
|
||||
|
||||
case 'billInserted':
|
||||
transaction.addBill(message.data!.denomination as number)
|
||||
break
|
||||
|
||||
case 'membershipValidated':
|
||||
transaction.setMembership(message.data!.membership as Membership)
|
||||
break
|
||||
|
||||
case 'transactionComplete':
|
||||
transaction.reset()
|
||||
break
|
||||
|
||||
case 'error':
|
||||
ui.setError(message.data!.message as string)
|
||||
break
|
||||
|
||||
default:
|
||||
console.log('Unknown message:', message)
|
||||
}
|
||||
}
|
||||
|
||||
function send(action: string, data?: Record<string, unknown>) {
|
||||
if (ws.value?.readyState === WebSocket.OPEN) {
|
||||
ws.value.send(JSON.stringify({ action, data }))
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectAttempts.value < 10) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.value), 30000)
|
||||
setTimeout(() => {
|
||||
reconnectAttempts.value++
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => connect())
|
||||
onUnmounted(() => ws.value?.close())
|
||||
|
||||
return {
|
||||
connected,
|
||||
send,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example Screen Component
|
||||
|
||||
```vue
|
||||
<!-- ui/src/screens/MembershipPromptScreen.vue -->
|
||||
<script setup lang="ts">
|
||||
import { useWebSocket } from '../composables/useWebSocket'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseButton from '../components/common/BaseButton.vue'
|
||||
|
||||
const { send } = useWebSocket()
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleYes() {
|
||||
send('membershipYes')
|
||||
}
|
||||
|
||||
function handleNo() {
|
||||
send('membershipNo')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="screen membership-prompt">
|
||||
<div class="content">
|
||||
<div class="icon">
|
||||
<img src="/images/membership-card.svg" alt="" />
|
||||
</div>
|
||||
|
||||
<h1 class="title">{{ t('membership.prompt.title') }}</h1>
|
||||
<p class="subtitle">{{ t('membership.prompt.subtitle') }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
size="large"
|
||||
@click="handleYes"
|
||||
>
|
||||
{{ t('common.yes') }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
variant="secondary"
|
||||
size="large"
|
||||
@click="handleNo"
|
||||
>
|
||||
{{ t('common.no') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.membership-prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
|
||||
.content {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.25rem;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
#components #vue
|
||||
|
||||
---
|
||||
|
||||
## i18n Strategy
|
||||
|
||||
### Vue I18n Setup
|
||||
|
||||
```typescript
|
||||
// ui/src/i18n/index.ts
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
// Lazy load locales
|
||||
const messages = Object.fromEntries(
|
||||
Object.entries(
|
||||
import.meta.glob('./locales/*.json', { eager: true })
|
||||
).map(([path, module]) => {
|
||||
const locale = path.match(/\/(\w+)\.json$/)?.[1] ?? 'en'
|
||||
return [locale, (module as { default: Record<string, string> }).default]
|
||||
})
|
||||
)
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false, // Composition API
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
})
|
||||
|
||||
export function setLocale(locale: string) {
|
||||
i18n.global.locale.value = locale
|
||||
document.documentElement.lang = locale
|
||||
document.documentElement.dir = isRTL(locale) ? 'rtl' : 'ltr'
|
||||
}
|
||||
|
||||
function isRTL(locale: string): boolean {
|
||||
return ['ar', 'he', 'fa', 'ur'].includes(locale)
|
||||
}
|
||||
```
|
||||
|
||||
### Locale Files
|
||||
|
||||
```json
|
||||
// ui/src/i18n/locales/en.json
|
||||
{
|
||||
"common": {
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"continue": "Continue",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back"
|
||||
},
|
||||
"idle": {
|
||||
"tapToStart": "Tap to Start",
|
||||
"buyBitcoin": "Buy Bitcoin",
|
||||
"sellBitcoin": "Sell Bitcoin"
|
||||
},
|
||||
"membership": {
|
||||
"prompt": {
|
||||
"title": "Do you have a membership card?",
|
||||
"subtitle": "Scan your card for exclusive discounts"
|
||||
},
|
||||
"scan": {
|
||||
"title": "Scan your membership card",
|
||||
"instruction": "Hold your QR code to the scanner"
|
||||
},
|
||||
"valid": {
|
||||
"welcome": "Welcome, {tierName}!",
|
||||
"discount": "{percent}% discount applied",
|
||||
"autoSend": "Bitcoin will be sent to your wallet automatically"
|
||||
},
|
||||
"invalid": {
|
||||
"title": "Membership not recognized",
|
||||
"tryAgain": "Try Again",
|
||||
"skip": "Continue without membership"
|
||||
}
|
||||
},
|
||||
"transaction": {
|
||||
"insertBills": "Insert bills",
|
||||
"currentAmount": "Current amount: {amount}",
|
||||
"sendingCoins": "Sending {coin}...",
|
||||
"complete": "Transaction complete!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#i18n #localization
|
||||
|
||||
---
|
||||
|
||||
## Build Configuration
|
||||
|
||||
### Vite Config
|
||||
|
||||
```typescript
|
||||
// ui/vite.config.ts
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
|
||||
build: {
|
||||
target: 'chrome90', // Kiosk browser target
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
sourcemap: false,
|
||||
minify: 'esbuild',
|
||||
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ['vue', 'vue-router', 'pinia'],
|
||||
i18n: ['vue-i18n'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
},
|
||||
|
||||
// For kiosk: inline assets to reduce HTTP requests
|
||||
assetsInclude: ['**/*.svg', '**/*.png'],
|
||||
})
|
||||
```
|
||||
|
||||
### Package.json
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "lamassu-machine-ui",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"lint": "eslint src --ext .vue,.ts --fix",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0",
|
||||
"pinia": "^2.2.0",
|
||||
"vue-i18n": "^10.0.0",
|
||||
"@vueuse/core": "^11.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.0",
|
||||
"vite": "^6.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vue-tsc": "^2.1.0",
|
||||
"vitest": "^2.1.0",
|
||||
"@vue/test-utils": "^2.4.0",
|
||||
"sass": "^1.80.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.30.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#build #vite
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Setup & Parallel Development
|
||||
|
||||
> [!todo] Phase 1 Tasks
|
||||
|
||||
- [ ] Create new `ui/` directory structure
|
||||
- [ ] Set up Vite + Vue + TypeScript
|
||||
- [ ] Configure Pinia stores
|
||||
- [ ] Set up Vue I18n with existing translations
|
||||
- [ ] Create base components (Button, QRCode, etc.)
|
||||
- [ ] Implement WebSocket composable
|
||||
- [ ] Run Vue app alongside legacy app for testing
|
||||
|
||||
**Key principle:** Keep `brain.js` state machine unchanged. Only replace the UI layer.
|
||||
|
||||
### Phase 2: Screen Migration
|
||||
|
||||
> [!todo] Phase 2 Tasks
|
||||
|
||||
Migrate screens one-by-one, starting with simplest:
|
||||
|
||||
1. [ ] IdleScreen
|
||||
2. [ ] ChooseLanguageScreen
|
||||
3. [ ] ChooseCoinScreen
|
||||
4. [ ] MembershipPromptScreen (new)
|
||||
5. [ ] MembershipScanScreen (new)
|
||||
6. [ ] ScanAddressScreen
|
||||
7. [ ] InsertBillsScreen
|
||||
8. [ ] SendingCoinsScreen
|
||||
9. [ ] CompleteScreen
|
||||
10. [ ] ErrorScreen
|
||||
|
||||
### Phase 3: Component Polish
|
||||
|
||||
> [!todo] Phase 3 Tasks
|
||||
|
||||
- [ ] Virtual keyboard component
|
||||
- [ ] QR scanner integration
|
||||
- [ ] Animations and transitions
|
||||
- [ ] Touch gesture support
|
||||
- [ ] Accessibility (a11y)
|
||||
- [ ] RTL language support
|
||||
|
||||
### Phase 4: Testing & Cleanup
|
||||
|
||||
> [!todo] Phase 4 Tasks
|
||||
|
||||
- [ ] Unit tests for stores
|
||||
- [ ] Component tests with Vue Test Utils
|
||||
- [ ] E2E tests with Playwright
|
||||
- [ ] Remove legacy `ui/src/app.js`
|
||||
- [ ] Remove jQuery dependency
|
||||
- [ ] Update documentation
|
||||
|
||||
#migration #phases
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Component Tests
|
||||
|
||||
```typescript
|
||||
// ui/src/screens/__tests__/MembershipPromptScreen.test.ts
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import MembershipPromptScreen from '../MembershipPromptScreen.vue'
|
||||
|
||||
describe('MembershipPromptScreen', () => {
|
||||
it('renders prompt text', () => {
|
||||
const wrapper = mount(MembershipPromptScreen, {
|
||||
global: {
|
||||
plugins: [createTestingPinia()],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('membership card')
|
||||
})
|
||||
|
||||
it('emits membershipYes when Yes clicked', async () => {
|
||||
const send = vi.fn()
|
||||
vi.mock('../composables/useWebSocket', () => ({
|
||||
useWebSocket: () => ({ send, connected: ref(true) }),
|
||||
}))
|
||||
|
||||
const wrapper = mount(MembershipPromptScreen)
|
||||
await wrapper.find('[data-test="yes-btn"]').trigger('click')
|
||||
|
||||
expect(send).toHaveBeenCalledWith('membershipYes')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Store Tests
|
||||
|
||||
```typescript
|
||||
// ui/src/stores/__tests__/transaction.test.ts
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useTransactionStore } from '../transaction'
|
||||
|
||||
describe('Transaction Store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('calculates total fiat from bills', () => {
|
||||
const store = useTransactionStore()
|
||||
|
||||
store.addBill(20)
|
||||
store.addBill(20)
|
||||
store.addBill(10)
|
||||
|
||||
expect(store.totalFiat).toBe(50)
|
||||
})
|
||||
|
||||
it('applies membership discount to rate', () => {
|
||||
const store = useTransactionStore()
|
||||
|
||||
store.selectedCoin = { code: 'BTC', rate: 100 }
|
||||
store.setMembership({
|
||||
tier: { discountPercentage: 15 },
|
||||
})
|
||||
|
||||
expect(store.effectiveRate).toBe(85) // 15% off
|
||||
})
|
||||
|
||||
it('resets all state', () => {
|
||||
const store = useTransactionStore()
|
||||
|
||||
store.startCashIn({ code: 'BTC', rate: 100 })
|
||||
store.addBill(20)
|
||||
store.reset()
|
||||
|
||||
expect(store.direction).toBeNull()
|
||||
expect(store.bills).toEqual([])
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#testing #vitest
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Bundle Size Targets
|
||||
|
||||
| Chunk | Target | Reason |
|
||||
|-------|--------|--------|
|
||||
| Vue core | < 40kb | Framework |
|
||||
| App code | < 50kb | Screens + components |
|
||||
| i18n | < 30kb | Lazy load locales |
|
||||
| **Total** | **< 120kb** | Fast kiosk boot |
|
||||
|
||||
### Optimization Strategies
|
||||
|
||||
1. **Lazy load screens**
|
||||
```typescript
|
||||
const InsertBillsScreen = defineAsyncComponent(
|
||||
() => import('./screens/InsertBillsScreen.vue')
|
||||
)
|
||||
```
|
||||
|
||||
2. **Preload critical screens**
|
||||
```typescript
|
||||
// Preload next likely screen
|
||||
router.beforeEach((to, from) => {
|
||||
if (to.name === 'chooseCoin') {
|
||||
import('./screens/ScanAddressScreen.vue')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
3. **Inline critical CSS**
|
||||
- First-paint styles inlined in HTML
|
||||
- Component styles loaded with components
|
||||
|
||||
4. **Image optimization**
|
||||
- SVG for icons (scalable, small)
|
||||
- WebP for photos
|
||||
- Lazy load non-critical images
|
||||
|
||||
#performance #optimization
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [[modernization-plan]] - Overall modernization roadmap
|
||||
- [[membership-lightning-integration]] - Membership feature
|
||||
- [[Machine State Management]] - XState migration (brain.js)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,626 +0,0 @@
|
|||
---
|
||||
title: Hardware Recommendations for Lamassu Machine
|
||||
created: 2026-01-22
|
||||
updated: 2026-01-22
|
||||
tags:
|
||||
- hardware
|
||||
- modernization
|
||||
- open-source
|
||||
- raspberry-pi
|
||||
- bill-handling
|
||||
status: draft
|
||||
---
|
||||
|
||||
# Hardware Recommendations for Lamassu Machine
|
||||
|
||||
> [!abstract] Summary
|
||||
> Hardware component recommendations for modernizing the Lamassu Bitcoin ATM, prioritizing **open-source compatibility**, **parts availability**, and **long-term support**. All components selected have existing open-source drivers or well-documented protocols.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [[#Compute Platform]]
|
||||
- [[#Bill Validators]]
|
||||
- [[#Bill Dispensers]]
|
||||
- [[#Touch Displays]]
|
||||
- [[#Thermal Printers]]
|
||||
- [[#NFC Readers]]
|
||||
- [[#Protocol Support]]
|
||||
- [[#DIY Reference Projects]]
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
> [!important] Selection Criteria
|
||||
> 1. **Open-source drivers** - Existing community or vendor-provided open-source implementations
|
||||
> 2. **Parts availability** - Globally accessible, not vendor-locked
|
||||
> 3. **Protocol documentation** - Well-documented communication protocols
|
||||
> 4. **Industrial longevity** - 5+ year production commitment
|
||||
> 5. **NixOS compatibility** - Clean builds without binary blobs where possible
|
||||
|
||||
---
|
||||
|
||||
## Compute Platform
|
||||
|
||||
### Primary Recommendation: Raspberry Pi Compute Module 5
|
||||
|
||||
> [!decision] Raspberry Pi CM5 with Industrial Carrier Board
|
||||
> Best balance of performance, ecosystem, and long-term availability (10-year production commitment).
|
||||
|
||||
| Specification | Value |
|
||||
|--------------|-------|
|
||||
| CPU | Broadcom BCM2712, 4-core Cortex-A76 @ 2.4GHz |
|
||||
| RAM | 2GB / 4GB / 8GB LPDDR4X-4267 |
|
||||
| Storage | 16GB / 32GB / 64GB eMMC (optional) |
|
||||
| Connectivity | PCIe 2.0 x1, USB 3.0, Gigabit Ethernet |
|
||||
| I/O | 2x MIPI DSI, 2x MIPI CSI, 30+ GPIO |
|
||||
| Production | 10-year commitment through 2035 |
|
||||
| Price | $45 (4GB) - $90 (8GB + 64GB eMMC) |
|
||||
|
||||
**Why CM5:**
|
||||
- Raspberry Pi Foundation's industrial commitment
|
||||
- Massive ecosystem of carrier boards
|
||||
- NixOS has first-class ARM64 support
|
||||
- Existing lamassu-machine runs on Pi
|
||||
|
||||
**Recommended Carrier Boards:**
|
||||
|
||||
| Board | Features | Price |
|
||||
|-------|----------|-------|
|
||||
| **Waveshare CM5-IO-BASE-A** | Full-size, HDMI x2, USB 3.0 x2, M.2 slot | ~$35 |
|
||||
| **Waveshare CM5-DISP-BASE** | Built-in 7" touchscreen, compact | ~$75 |
|
||||
| **Toradex Aster** | Industrial, wide temp, PoE | ~$150 |
|
||||
| **BIGTREETECH CB1** | 3D printer heritage, robust | ~$40 |
|
||||
|
||||
> [!tip] Waveshare CM5-DISP-BASE
|
||||
> Combines carrier board + 7" touchscreen in one unit. Ideal for compact kiosk designs.
|
||||
|
||||
### Alternative: Pine64 StarPro64 (RISC-V)
|
||||
|
||||
> [!note] Future-Proof Option
|
||||
> For organizations wanting to support open silicon and avoid ARM licensing.
|
||||
|
||||
| Specification | Value |
|
||||
|--------------|-------|
|
||||
| CPU | StarFive JH7110, 4-core SiFive U74 @ 1.5GHz |
|
||||
| RAM | 8GB LPDDR4 |
|
||||
| Storage | M.2 NVMe, microSD, eMMC |
|
||||
| GPU | IMG BXE-4-32 (open-source driver in progress) |
|
||||
| Price | ~$90 |
|
||||
|
||||
**RISC-V Considerations:**
|
||||
- Linux kernel support improving rapidly
|
||||
- NixOS has experimental RISC-V builds
|
||||
- Performance ~60% of Pi 5 currently
|
||||
- Fully open ISA (no licensing fees)
|
||||
|
||||
**Verdict:** Use CM5 for production now, evaluate RISC-V for 2028+ deployments.
|
||||
|
||||
---
|
||||
|
||||
## Bill Validators
|
||||
|
||||
### Primary Recommendation: Innovative Technology NV200
|
||||
|
||||
> [!decision] ITL NV200 with eSSP Protocol
|
||||
> Industry standard with excellent open-source library support.
|
||||
|
||||
| Specification | Value |
|
||||
|--------------|-------|
|
||||
| Capacity | Up to 600 notes stacked |
|
||||
| Note Width | 60mm - 85mm |
|
||||
| Validation Speed | <1 second |
|
||||
| Interface | USB or TTL serial |
|
||||
| Protocol | eSSP (encrypted SSP) |
|
||||
| Recognition | 96 currencies, 4-way insertion |
|
||||
|
||||
**Open-Source Support:**
|
||||
|
||||
```bash
|
||||
# Node.js eSSP library
|
||||
npm install encrypted-ssp
|
||||
|
||||
# Python library
|
||||
pip install ssp-protocol
|
||||
```
|
||||
|
||||
| Library | Language | Repo |
|
||||
|---------|----------|------|
|
||||
| encrypted-ssp | Node.js | github.com/nickatnight/encrypted-ssp |
|
||||
| ssp-server | Node.js | github.com/paysyslabs/ssp-server |
|
||||
| ssp-protocol | Python | github.com/paysyslabs/ssp-protocol |
|
||||
| eSSP.NET | C# | github.com/essp-library/essp-dotnet |
|
||||
|
||||
**NV200 Variants:**
|
||||
|
||||
| Model | Feature | Use Case |
|
||||
|-------|---------|----------|
|
||||
| NV200 | Stacker only | Standard ATM |
|
||||
| NV200 Spectral | Enhanced counterfeit detection | High-risk areas |
|
||||
| NV200 + SMART Payout | Recycling + dispensing | Two-way machines |
|
||||
|
||||
### Alternative: MEI Cashflow Series
|
||||
|
||||
> [!note] Alternative for US/Canada
|
||||
> Strong in North American market with ccTalk protocol support.
|
||||
|
||||
| Model | Note Capacity | Protocol |
|
||||
|-------|--------------|----------|
|
||||
| MEI Cashflow SC66 | 600 | MDB, ccTalk |
|
||||
| MEI Cashflow SC83 | 1,000 | MDB, ccTalk, eSSP |
|
||||
| MEI Cashflow SC Advance | 1,500 | All protocols |
|
||||
|
||||
**ccTalk Library:**
|
||||
```bash
|
||||
# C++/Qt library
|
||||
git clone https://github.com/nickatnight/cctalk-cpp
|
||||
```
|
||||
|
||||
### Existing Lamassu Drivers
|
||||
|
||||
The current lamassu-machine already supports:
|
||||
|
||||
| Driver | Protocol | File |
|
||||
|--------|----------|------|
|
||||
| `id003` | ID-003 | `lib/id003/` |
|
||||
| `ccnet` | CCNET | `lib/ccnet.js` |
|
||||
| `mei` | MEI proprietary | `lib/mei/` |
|
||||
| `ssp` | SSP/eSSP | `lib/ssp.js` |
|
||||
|
||||
> [!success] Reuse Strategy
|
||||
> Port existing JavaScript drivers to Rust HAL layer with napi-rs bindings.
|
||||
|
||||
---
|
||||
|
||||
## Bill Dispensers
|
||||
|
||||
### Primary Recommendation: Puloon LCDM-1000
|
||||
|
||||
> [!decision] Puloon LCDM Series
|
||||
> Best-documented protocol with existing lamassu-machine support.
|
||||
|
||||
| Model | Cassettes | Capacity per Cassette | Interface |
|
||||
|-------|-----------|----------------------|-----------|
|
||||
| LCDM-1000 | 1 | 1,000 notes | RS-232 |
|
||||
| LCDM-2000 | 2 | 1,000 notes each | RS-232 |
|
||||
| LCDM-4000 | 4 | 500 notes each | RS-232 |
|
||||
|
||||
**Open-Source Driver:**
|
||||
```bash
|
||||
# Existing lamassu-machine driver
|
||||
lib/puloon/puloonrs232.js
|
||||
|
||||
# Rust implementation available
|
||||
github.com/nickatnight/puloon-rs
|
||||
```
|
||||
|
||||
**Puloon Protocol:**
|
||||
- Simple ASCII command set
|
||||
- Documented in public datasheet
|
||||
- 9600 baud RS-232
|
||||
|
||||
### Alternative: Fujitsu F53/F56
|
||||
|
||||
> [!note] Higher Volume Option
|
||||
> For high-traffic locations needing larger capacity.
|
||||
|
||||
| Model | Cassettes | Capacity | Interface |
|
||||
|-------|-----------|----------|-----------|
|
||||
| F53 | 4 | 2,500 notes total | USB, RS-232 |
|
||||
| F56 | 6 | 4,000 notes total | USB, RS-232 |
|
||||
|
||||
**Open-Source Support:**
|
||||
```bash
|
||||
# Existing lamassu-machine driver
|
||||
lib/f56/
|
||||
|
||||
# Python implementation
|
||||
github.com/fujitsu-atm/f53-python
|
||||
```
|
||||
|
||||
### Existing Lamassu Dispenser Support
|
||||
|
||||
| Driver | Hardware | File |
|
||||
|--------|----------|------|
|
||||
| `puloon` | LCDM series | `lib/puloon/` |
|
||||
| `f56` | Fujitsu F53/F56 | `lib/f56/` |
|
||||
| `genmega` | Genmega dispensers | `lib/genmega/` |
|
||||
| `gsr50` | GSR50 recycler | `lib/gsr50/` |
|
||||
| `hcm2` | Hitachi HCM2 | `lib/hcm2/` |
|
||||
|
||||
---
|
||||
|
||||
## Touch Displays
|
||||
|
||||
### Primary Recommendation: Elo Touch Solutions I-Series
|
||||
|
||||
> [!decision] Elo I-Series 4.0 (Linux)
|
||||
> Industrial-grade with native Linux support and open-source touch drivers.
|
||||
|
||||
| Model | Size | Resolution | Features |
|
||||
|-------|------|------------|----------|
|
||||
| ESY15i5 | 15.6" | 1920x1080 | ARM Cortex-A73, Android/Linux |
|
||||
| ESY22i5 | 21.5" | 1920x1080 | ARM Cortex-A73, Android/Linux |
|
||||
|
||||
**Linux Support:**
|
||||
- Native Linux kernel touch driver
|
||||
- No proprietary blobs required
|
||||
- evdev/libinput compatible
|
||||
|
||||
**Advantages:**
|
||||
- Designed for 24/7 kiosk operation
|
||||
- Anti-glare, anti-fingerprint coating
|
||||
- Wide temperature range (-20°C to 50°C)
|
||||
- 3-year warranty
|
||||
|
||||
### Budget Alternative: Waveshare + Raspberry Pi
|
||||
|
||||
| Model | Size | Resolution | Price |
|
||||
|-------|------|------------|-------|
|
||||
| Waveshare 10.1" | 10.1" | 1280x800 | ~$90 |
|
||||
| Waveshare 13.3" | 13.3" | 1920x1080 | ~$150 |
|
||||
| Waveshare 15.6" | 15.6" | 1920x1080 | ~$180 |
|
||||
|
||||
**Advantages:**
|
||||
- Direct DSI connection to CM5
|
||||
- Single-cable solution (power + video + touch)
|
||||
- Mainline Linux kernel support
|
||||
|
||||
### Industrial Open-Frame: Faytech
|
||||
|
||||
> [!note] Custom Enclosure Option
|
||||
> For building into existing or custom ATM enclosures.
|
||||
|
||||
| Model | Size | Features |
|
||||
|-------|------|----------|
|
||||
| FT116TMBCAP | 11.6" | Open-frame, PCAP touch |
|
||||
| FT156TMBCAP | 15.6" | Open-frame, PCAP touch |
|
||||
| FT215TMBCAP | 21.5" | Open-frame, PCAP touch |
|
||||
|
||||
- IP65 front bezel available
|
||||
- VESA mount compatible
|
||||
- USB touch, HDMI video
|
||||
|
||||
### Experimental: E-Ink Displays
|
||||
|
||||
> [!warning] Experimental - Testing Only
|
||||
> E-ink displays have significant trade-offs for interactive kiosk use. Document for evaluation purposes.
|
||||
|
||||
**Potential Benefits:**
|
||||
- Perfect sunlight readability (reflective, no glare)
|
||||
- Ultra-low power (~90% less than LCD)
|
||||
- No eye strain, no flicker
|
||||
- Unique aesthetic differentiator
|
||||
- Solar-powered remote deployment possible
|
||||
|
||||
**Limitations:**
|
||||
- Slow refresh rates (even 33-75Hz feels choppy)
|
||||
- Limited touch options on large panels
|
||||
- High cost for frontlit panels
|
||||
- Color (Kaleido 3) only 150 PPI, washed out
|
||||
- Ghosting requires periodic full refresh
|
||||
|
||||
#### High-Refresh E-Ink Options
|
||||
|
||||
| Display | Size | Resolution | Refresh | Frontlight | Price |
|
||||
|---------|------|------------|---------|------------|-------|
|
||||
| **Modos Paper** | 13.3" | 1600×1200 | 75Hz | No | ~$400 |
|
||||
| **Modos Paper** | 6" | 1448×1072 | 75Hz | No | ~$199 |
|
||||
| DASUNG Paperlike 253 | 25.3" | 3200×1800 | 33Hz | Yes | ~$2,250 |
|
||||
| DASUNG Paperlike Color | 25.3" | Kaleido 3 | ~15Hz | Yes | ~$3,000+ |
|
||||
|
||||
#### Open Source: Modos Paper Monitor
|
||||
|
||||
> [!tip] Best Option for Testing
|
||||
> Open-source FPGA controller with 75Hz refresh - ships January 2026.
|
||||
|
||||
- **Repository:** github.com/nickatnight/caster (FPGA controller)
|
||||
- **Refresh:** 75Hz with sub-100ms latency
|
||||
- **Power:** ~1.5W continuous
|
||||
- **Controller:** AMD Spartan-6 FPGA with pixel-level management
|
||||
- **Connectivity:** HDMI, USB-C
|
||||
- **Limitation:** Monochrome only, no built-in touch
|
||||
|
||||
**Touch Integration:**
|
||||
Pair with capacitive touch overlay (e.g., ILITEK controller) for touch input.
|
||||
|
||||
#### Development Boards
|
||||
|
||||
| Board | Size | Resolution | Interface | Price |
|
||||
|-------|------|------------|-----------|-------|
|
||||
| Waveshare 10.3" HAT | 10.3" | 1872×1404 | SPI/USB | ~$200 |
|
||||
| Waveshare 7.8" HAT | 7.8" | 1872×1404 | SPI | ~$120 |
|
||||
| GooDisplay GDEY042T81 | 4.2" | 400×300 | SPI | ~$25 |
|
||||
|
||||
#### Industrial/Outdoor E-Ink
|
||||
|
||||
For outdoor signage or secondary display:
|
||||
|
||||
| Vendor | Sizes | Features |
|
||||
|--------|-------|----------|
|
||||
| SEEKINK | 13.3" - 32" | IP65, -25°C to 65°C, solar option |
|
||||
| Geniatech | 10" - 75" | CMS/API built-in, outdoor rated |
|
||||
| E Ink Marquee | Various | Full color outdoor signage |
|
||||
|
||||
#### Recommended Use Cases
|
||||
|
||||
| Scenario | E-Ink Suitable? | Notes |
|
||||
|----------|-----------------|-------|
|
||||
| Outdoor/direct sunlight | Yes | Primary advantage |
|
||||
| Solar-powered remote ATM | Yes | Ultra-low power |
|
||||
| Standard indoor kiosk | No | LCD better for interaction |
|
||||
| High-interaction UI | No | Refresh too slow |
|
||||
| Idle-mode signage | Yes | Static content while waiting |
|
||||
| Secondary info display | Yes | Rates, fees, location info |
|
||||
|
||||
#### Hybrid Approach
|
||||
|
||||
Consider dual-display architecture:
|
||||
- **Primary:** Standard LCD for transactions
|
||||
- **Secondary:** E-ink for idle advertising, static info, outdoor-facing
|
||||
|
||||
**References:**
|
||||
- [Modos Paper - Crowd Supply](https://www.crowdsupply.com/modos-tech/modos-paper-monitor)
|
||||
- [E-Paper 75Hz - IEEE Spectrum](https://spectrum.ieee.org/e-paper-display-modos)
|
||||
- [DASUNG Paperlike 253](https://shop.dasung.com/products/dasung-25-3-e-ink-monitor-paperlike-253)
|
||||
- [Waveshare E-Paper](https://www.waveshare.com/product/raspberry-pi/displays/e-paper.htm)
|
||||
- [SEEKINK Outdoor](https://www.seekink.com/outdoor-e-ink-display/)
|
||||
|
||||
---
|
||||
|
||||
## Thermal Printers
|
||||
|
||||
### Protocol: ESC/POS
|
||||
|
||||
> [!decision] ESC/POS Compatible Printers
|
||||
> Universal protocol with extensive open-source library support.
|
||||
|
||||
**ESC/POS Libraries:**
|
||||
|
||||
| Library | Language | Features |
|
||||
|---------|----------|----------|
|
||||
| `escpos-rs` | Rust | Async, image support |
|
||||
| `node-thermal-printer` | Node.js | Multiple protocols |
|
||||
| `python-escpos` | Python | Widely used |
|
||||
| `escpos-php` | PHP | Legacy systems |
|
||||
|
||||
### Recommended Models
|
||||
|
||||
| Model | Paper Width | Interface | Price |
|
||||
|-------|-------------|-----------|-------|
|
||||
| **Epson TM-T88VI** | 80mm | USB, Ethernet, Bluetooth | ~$350 |
|
||||
| **Star TSP143IV** | 80mm | USB, Ethernet | ~$280 |
|
||||
| **Custom KUBE II** | 80mm | USB, Serial, Ethernet | ~$200 |
|
||||
| **Goojprt JP-80H** | 80mm | USB, Serial | ~$60 |
|
||||
|
||||
> [!tip] Budget Option
|
||||
> Goojprt/MUNBYN/Rongta Chinese printers are ESC/POS compatible at 1/5 the price. Suitable for testing and low-volume deployments.
|
||||
|
||||
**NixOS Integration:**
|
||||
```nix
|
||||
services.printing = {
|
||||
enable = true;
|
||||
drivers = [ pkgs.epson-escpr ];
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NFC Readers
|
||||
|
||||
### Primary Recommendation: ACR122U
|
||||
|
||||
> [!decision] ACS ACR122U with libnfc
|
||||
> Industry standard, excellent open-source support.
|
||||
|
||||
| Specification | Value |
|
||||
|--------------|-------|
|
||||
| Chip | NXP PN532 |
|
||||
| Standards | ISO 14443A/B, MIFARE, FeliCa |
|
||||
| Interface | USB 2.0 |
|
||||
| Read Distance | Up to 50mm |
|
||||
| Price | ~$35 |
|
||||
|
||||
**libnfc Support:**
|
||||
```bash
|
||||
# NixOS
|
||||
environment.systemPackages = [ pkgs.libnfc pkgs.mfoc pkgs.mfcuk ];
|
||||
|
||||
# Rust
|
||||
cargo add nfc
|
||||
|
||||
# Node.js
|
||||
npm install nfc-pcsc
|
||||
```
|
||||
|
||||
### Alternative: PN532 Module
|
||||
|
||||
> [!note] DIY/Embedded Option
|
||||
> Direct SPI/I2C connection to Raspberry Pi GPIO.
|
||||
|
||||
| Module | Interface | Price |
|
||||
|--------|-----------|-------|
|
||||
| Adafruit PN532 | SPI, I2C, UART | ~$40 |
|
||||
| Elechouse PN532 | SPI, I2C, UART | ~$15 |
|
||||
| Waveshare PN532 | SPI, I2C, UART | ~$12 |
|
||||
|
||||
**GPIO Connection:**
|
||||
```
|
||||
PN532 → Raspberry Pi
|
||||
VCC → 3.3V (Pin 1)
|
||||
GND → GND (Pin 6)
|
||||
SDA → GPIO 2 (Pin 3)
|
||||
SCL → GPIO 3 (Pin 5)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Protocol Support Summary
|
||||
|
||||
### Bill Handling Protocols
|
||||
|
||||
| Protocol | Description | Open-Source Support |
|
||||
|----------|-------------|---------------------|
|
||||
| **eSSP** | Encrypted SSP (ITL) | Node.js, Python, C# |
|
||||
| **SSP** | Standard SSP (ITL) | Node.js, Python |
|
||||
| **ccTalk** | Serial coin/note protocol | C++, Qt |
|
||||
| **ID-003** | JCM bill validator | JavaScript (lamassu) |
|
||||
| **CCNET** | CashCode protocol | JavaScript (lamassu) |
|
||||
| **MDB** | Vending standard | C, Rust |
|
||||
|
||||
### Recommended Protocol Stack
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Rust HAL Layer"
|
||||
essp[eSSP Driver]
|
||||
cctalk[ccTalk Driver]
|
||||
puloon[Puloon RS232]
|
||||
escpos[ESC/POS]
|
||||
nfc[libnfc]
|
||||
end
|
||||
|
||||
subgraph "napi-rs Bindings"
|
||||
napi[Node.js FFI]
|
||||
end
|
||||
|
||||
subgraph "TypeScript Application"
|
||||
app[Tauri + Vue 3]
|
||||
end
|
||||
|
||||
app --> napi
|
||||
napi --> essp
|
||||
napi --> cctalk
|
||||
napi --> puloon
|
||||
napi --> escpos
|
||||
napi --> nfc
|
||||
|
||||
essp --> nv200[NV200]
|
||||
cctalk --> mei[MEI Cashflow]
|
||||
puloon --> lcdm[Puloon LCDM]
|
||||
escpos --> printer[Thermal Printer]
|
||||
nfc --> reader[ACR122U]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DIY Reference Projects
|
||||
|
||||
### FOSSA Bitcoin ATM
|
||||
|
||||
> [!example] LNbits + Lightning
|
||||
> Open-source Lightning ATM using ESP32 + NV10 bill acceptor.
|
||||
|
||||
- **Repository:** github.com/lnbits/fossa
|
||||
- **Hardware:** ESP32, ITL NV10, SSD1306 OLED
|
||||
- **Protocol:** eSSP over serial
|
||||
- **Backend:** LNbits
|
||||
|
||||
### Bleskomat
|
||||
|
||||
> [!example] Minimal Lightning ATM
|
||||
> Coin-based Lightning vending machine.
|
||||
|
||||
- **Repository:** github.com/samotari/bleskomat
|
||||
- **Hardware:** ESP32, coin acceptor
|
||||
- **Protocol:** ccTalk
|
||||
- **Interesting:** Ultra-low-cost design
|
||||
|
||||
### Open Bitcoin ATM (Legacy)
|
||||
|
||||
> [!example] Historical Reference
|
||||
> Early open-source Bitcoin ATM project (2013-2016).
|
||||
|
||||
- **Repository:** github.com/mayosmith/OpenBitcoinATM
|
||||
- **Hardware:** Raspberry Pi, various bill acceptors
|
||||
- **Status:** Archived, but useful for protocol reference
|
||||
|
||||
---
|
||||
|
||||
## Recommended Bill of Materials
|
||||
|
||||
### Minimum Viable ATM
|
||||
|
||||
| Component | Model | Est. Price |
|
||||
|-----------|-------|------------|
|
||||
| Compute | Raspberry Pi CM5 (4GB) + Waveshare carrier | $80 |
|
||||
| Display | Waveshare 10.1" DSI touch | $90 |
|
||||
| Bill Validator | ITL NV200 (used/refurb) | $300-500 |
|
||||
| Printer | ESC/POS thermal | $60-100 |
|
||||
| NFC | ACR122U | $35 |
|
||||
| Enclosure | Custom fabrication | $200-500 |
|
||||
| **Total** | | **$765-1,305** |
|
||||
|
||||
### Full-Featured ATM
|
||||
|
||||
| Component | Model | Est. Price |
|
||||
|-----------|-------|------------|
|
||||
| Compute | Raspberry Pi CM5 (8GB) + industrial carrier | $150 |
|
||||
| Display | Elo ESY15i5 | $800 |
|
||||
| Bill Validator | ITL NV200 Spectral | $600 |
|
||||
| Bill Dispenser | Puloon LCDM-2000 | $800 |
|
||||
| Printer | Epson TM-T88VI | $350 |
|
||||
| NFC | ACR122U | $35 |
|
||||
| Enclosure | Industrial steel cabinet | $1,000-2,000 |
|
||||
| **Total** | | **$3,735-4,735** |
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Port Existing Drivers
|
||||
|
||||
1. Audit current lamassu-machine drivers:
|
||||
- `lib/id003/` → Rust HAL
|
||||
- `lib/ccnet.js` → Rust HAL
|
||||
- `lib/puloon/` → Rust HAL
|
||||
- `lib/mei/` → Rust HAL
|
||||
- `lib/ssp.js` → Rust HAL
|
||||
|
||||
2. Create napi-rs bindings for TypeScript consumption
|
||||
|
||||
3. Test with existing hardware inventory
|
||||
|
||||
### Phase 2: Add New Protocol Support
|
||||
|
||||
1. Implement eSSP for NV200 support
|
||||
2. Add ESC/POS for universal printer support
|
||||
3. Integrate libnfc for NFC readers
|
||||
|
||||
### Phase 3: Hardware Validation
|
||||
|
||||
1. Create NixOS hardware test image
|
||||
2. Validate all components on CM5
|
||||
3. Document any quirks or workarounds
|
||||
|
||||
---
|
||||
|
||||
## Vendor Contacts
|
||||
|
||||
| Component | Vendor | Contact |
|
||||
|-----------|--------|---------|
|
||||
| Bill Validators | Innovative Technology | sales@innovative-technology.com |
|
||||
| Bill Validators | MEI (Crane) | info@cranepi.com |
|
||||
| Bill Dispensers | Puloon Technology | sales@puloon.com |
|
||||
| Displays | Elo Touch | sales@elotouch.com |
|
||||
| Displays | Waveshare | service@waveshare.com |
|
||||
| Compute | Raspberry Pi | For bulk: sales@raspberrypi.com |
|
||||
|
||||
---
|
||||
|
||||
## Related Notes
|
||||
|
||||
- [[modernization-plan]] - Overall modernization roadmap
|
||||
- [[machine-ui-modernization]] - Vue 3 + Tauri migration
|
||||
- [[lnbits-integration]] - Lightning backend integration
|
||||
- [[NixOS Configuration]] - Production deployment
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [ITL NV200 Datasheet](https://innovative-technology.com/products/nv200/)
|
||||
- [eSSP Protocol Guide](https://github.com/paysyslabs/ssp-server/wiki)
|
||||
- [Raspberry Pi CM5 Documentation](https://www.raspberrypi.com/documentation/computers/compute-module.html)
|
||||
- [libnfc Documentation](http://nfc-tools.org/index.php/Libnfc)
|
||||
- [ESC/POS Command Reference](https://reference.epson-biz.com/modules/ref_escpos/index.php)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,825 +0,0 @@
|
|||
---
|
||||
title: LNbits Integration
|
||||
created: 2026-01-22
|
||||
updated: 2026-01-22
|
||||
tags:
|
||||
- integration
|
||||
- lightning
|
||||
- lnbits
|
||||
- api
|
||||
status: reference
|
||||
---
|
||||
|
||||
# LNbits Integration
|
||||
|
||||
> [!abstract] Summary
|
||||
> LNbits serves as the Lightning Network backend for Lamassu ATMs, abstracting the underlying Lightning node implementation and providing a clean REST API for payments.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [[#Why LNbits]]
|
||||
- [[#API Reference]]
|
||||
- [[#Deployment Architecture]]
|
||||
- [[#Configuration]]
|
||||
|
||||
---
|
||||
|
||||
## Why LNbits
|
||||
|
||||
> [!decision] Choice of Lightning Backend
|
||||
> LNbits was chosen over direct LND/CLN integration for these reasons:
|
||||
|
||||
| Feature | LNbits | Direct LND/CLN |
|
||||
|---------|--------|----------------|
|
||||
| Backend Abstraction | 30+ implementations | Single implementation |
|
||||
| API Complexity | Simple REST | gRPC/REST varies |
|
||||
| Multi-wallet | Built-in | Custom implementation |
|
||||
| User Management | Built-in | None |
|
||||
| Extensions | Rich ecosystem | None |
|
||||
| Self-hostable | Yes | Yes |
|
||||
| Open Source | MIT License | Varies |
|
||||
|
||||
**Supported Backends:**
|
||||
- LND (lndrest, lndgrpc)
|
||||
- Core Lightning (CLN, CLNRest)
|
||||
- Eclair
|
||||
- LNPay, OpenNode, Alby
|
||||
- Breez, Phoenix
|
||||
- NWC (Nostr Wallet Connect)
|
||||
- And 20+ more...
|
||||
|
||||
#lnbits #lightning
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### Authentication
|
||||
|
||||
LNbits uses API keys for authentication:
|
||||
|
||||
| Key Type | Header | Permissions |
|
||||
|----------|--------|-------------|
|
||||
| Admin Key | `X-API-KEY: {adminkey}` | Full wallet control |
|
||||
| Invoice Key | `X-API-KEY: {invoicekey}` | Create invoices, view payments |
|
||||
|
||||
```typescript
|
||||
const headers = {
|
||||
'X-API-KEY': config.adminKey,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
```
|
||||
|
||||
### Core Endpoints
|
||||
|
||||
#### Get Wallet Info
|
||||
|
||||
```http
|
||||
GET /api/v1/wallet
|
||||
X-API-KEY: {adminkey}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": "wallet-uuid",
|
||||
"name": "ATM Wallet",
|
||||
"balance": 1500000
|
||||
}
|
||||
```
|
||||
|
||||
> [!note] Balance Units
|
||||
> All amounts in LNbits API are in **millisatoshis (msat)**. Divide by 1000 for satoshis.
|
||||
|
||||
#### Create Invoice (Receive)
|
||||
|
||||
```http
|
||||
POST /api/v1/payments
|
||||
X-API-KEY: {invoicekey}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"out": false,
|
||||
"amount": 50000,
|
||||
"memo": "ATM Cash-out",
|
||||
"expiry": 600,
|
||||
"webhook": "https://lamassu.example.com/api/webhook/payment"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"payment_hash": "abc123...",
|
||||
"payment_request": "lnbc500u1p...",
|
||||
"checking_id": "xyz789..."
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `out` | `false` for receiving, `true` for sending |
|
||||
| `amount` | Amount in **satoshis** |
|
||||
| `memo` | Invoice description |
|
||||
| `expiry` | Seconds until expiration (default: 3600) |
|
||||
| `webhook` | Optional callback URL |
|
||||
|
||||
#### Pay Invoice (Send)
|
||||
|
||||
```http
|
||||
POST /api/v1/payments
|
||||
X-API-KEY: {adminkey}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"out": true,
|
||||
"bolt11": "lnbc500u1p..."
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"payment_hash": "abc123...",
|
||||
"checking_id": "xyz789...",
|
||||
"fee": 5
|
||||
}
|
||||
```
|
||||
|
||||
#### Check Payment Status
|
||||
|
||||
```http
|
||||
GET /api/v1/payments/{checking_id}
|
||||
X-API-KEY: {invoicekey}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"paid": true,
|
||||
"pending": false,
|
||||
"preimage": "def456...",
|
||||
"payment_hash": "abc123...",
|
||||
"amount": 50000,
|
||||
"fee": 5,
|
||||
"memo": "ATM Cash-out",
|
||||
"time": 1706018400,
|
||||
"bolt11": "lnbc500u1p..."
|
||||
}
|
||||
```
|
||||
|
||||
#### LNURL Scan
|
||||
|
||||
```http
|
||||
GET /api/v1/lnurlscan/{code}
|
||||
X-API-KEY: {invoicekey}
|
||||
```
|
||||
|
||||
Decodes LNURL or Lightning Address and returns metadata.
|
||||
|
||||
**Response (Lightning Address):**
|
||||
```json
|
||||
{
|
||||
"kind": "pay",
|
||||
"domain": "walletofsatoshi.com",
|
||||
"callback": "https://walletofsatoshi.com/lnurlp/user/callback",
|
||||
"minSendable": 1000,
|
||||
"maxSendable": 100000000000,
|
||||
"metadata": "[['text/plain', 'Sats for user']]",
|
||||
"allowsNostr": true,
|
||||
"commentAllowed": 255
|
||||
}
|
||||
```
|
||||
|
||||
#### Pay to LNURL
|
||||
|
||||
```http
|
||||
POST /api/v1/payments/lnurl
|
||||
X-API-KEY: {adminkey}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"callback": "https://walletofsatoshi.com/lnurlp/user/callback",
|
||||
"amount": 50000,
|
||||
"comment": "ATM withdrawal"
|
||||
}
|
||||
```
|
||||
|
||||
#api #endpoints
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Client
|
||||
|
||||
### Full Implementation
|
||||
|
||||
```typescript
|
||||
// packages/server/lib/lightning/lnbits-client.ts
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
// Schemas
|
||||
const WalletInfoSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
balance: z.number(), // msat
|
||||
})
|
||||
|
||||
const CreateInvoiceResponseSchema = z.object({
|
||||
payment_hash: z.string(),
|
||||
payment_request: z.string(),
|
||||
checking_id: z.string(),
|
||||
})
|
||||
|
||||
const PaymentResponseSchema = z.object({
|
||||
payment_hash: z.string(),
|
||||
checking_id: z.string(),
|
||||
fee: z.number().optional(),
|
||||
})
|
||||
|
||||
const PaymentStatusSchema = z.object({
|
||||
paid: z.boolean(),
|
||||
pending: z.boolean(),
|
||||
preimage: z.string().nullable(),
|
||||
payment_hash: z.string(),
|
||||
amount: z.number(),
|
||||
fee: z.number(),
|
||||
memo: z.string().nullable(),
|
||||
time: z.number(),
|
||||
bolt11: z.string(),
|
||||
})
|
||||
|
||||
const LnurlPayResponseSchema = z.object({
|
||||
kind: z.literal('pay'),
|
||||
callback: z.string(),
|
||||
minSendable: z.number(),
|
||||
maxSendable: z.number(),
|
||||
metadata: z.string(),
|
||||
commentAllowed: z.number().optional(),
|
||||
})
|
||||
|
||||
// Types
|
||||
export interface LNbitsConfig {
|
||||
baseUrl: string
|
||||
adminKey: string
|
||||
invoiceKey: string
|
||||
walletId: string
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
bolt11: string
|
||||
paymentHash: string
|
||||
checkingId: string
|
||||
expiresAt: Date
|
||||
}
|
||||
|
||||
export interface PaymentResult {
|
||||
success: boolean
|
||||
paymentHash: string
|
||||
checkingId: string
|
||||
feeSats?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface PaymentStatus {
|
||||
paid: boolean
|
||||
pending: boolean
|
||||
preimage: string | null
|
||||
amountSats: number
|
||||
feeSats: number
|
||||
}
|
||||
|
||||
// Client Implementation
|
||||
export class LNbitsClient {
|
||||
private baseUrl: string
|
||||
private adminKey: string
|
||||
private invoiceKey: string
|
||||
private timeout: number
|
||||
|
||||
constructor(config: LNbitsConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '')
|
||||
this.adminKey = config.adminKey
|
||||
this.invoiceKey = config.invoiceKey
|
||||
this.timeout = config.timeout ?? 30000
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
key: 'admin' | 'invoice',
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const apiKey = key === 'admin' ? this.adminKey : this.invoiceKey
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'X-API-KEY': apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(`LNbits API error: ${response.status} - ${error}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
// Wallet Operations
|
||||
|
||||
async getWalletInfo(): Promise<{ id: string; name: string; balanceSats: number }> {
|
||||
const data = await this.request('GET', '/api/v1/wallet', 'admin')
|
||||
const parsed = WalletInfoSchema.parse(data)
|
||||
return {
|
||||
id: parsed.id,
|
||||
name: parsed.name,
|
||||
balanceSats: Math.floor(parsed.balance / 1000),
|
||||
}
|
||||
}
|
||||
|
||||
async getBalance(): Promise<number> {
|
||||
const info = await this.getWalletInfo()
|
||||
return info.balanceSats
|
||||
}
|
||||
|
||||
// Invoice Operations
|
||||
|
||||
async createInvoice(
|
||||
amountSats: number,
|
||||
memo: string,
|
||||
options?: {
|
||||
expiry?: number
|
||||
webhook?: string
|
||||
}
|
||||
): Promise<Invoice> {
|
||||
const data = await this.request('POST', '/api/v1/payments', 'invoice', {
|
||||
out: false,
|
||||
amount: amountSats,
|
||||
memo,
|
||||
expiry: options?.expiry ?? 600,
|
||||
webhook: options?.webhook,
|
||||
})
|
||||
|
||||
const parsed = CreateInvoiceResponseSchema.parse(data)
|
||||
return {
|
||||
bolt11: parsed.payment_request,
|
||||
paymentHash: parsed.payment_hash,
|
||||
checkingId: parsed.checking_id,
|
||||
expiresAt: new Date(Date.now() + (options?.expiry ?? 600) * 1000),
|
||||
}
|
||||
}
|
||||
|
||||
async getPaymentStatus(checkingId: string): Promise<PaymentStatus> {
|
||||
const data = await this.request('GET', `/api/v1/payments/${checkingId}`, 'invoice')
|
||||
const parsed = PaymentStatusSchema.parse(data)
|
||||
return {
|
||||
paid: parsed.paid,
|
||||
pending: parsed.pending,
|
||||
preimage: parsed.preimage,
|
||||
amountSats: parsed.amount,
|
||||
feeSats: parsed.fee,
|
||||
}
|
||||
}
|
||||
|
||||
async waitForPayment(
|
||||
checkingId: string,
|
||||
timeoutMs: number = 600000
|
||||
): Promise<PaymentStatus> {
|
||||
const startTime = Date.now()
|
||||
const pollInterval = 2000
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const status = await this.getPaymentStatus(checkingId)
|
||||
if (status.paid) return status
|
||||
if (!status.pending) throw new Error('Payment failed or expired')
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
||||
}
|
||||
|
||||
throw new Error('Payment timeout')
|
||||
}
|
||||
|
||||
// Payment Operations
|
||||
|
||||
async payInvoice(bolt11: string): Promise<PaymentResult> {
|
||||
try {
|
||||
const data = await this.request('POST', '/api/v1/payments', 'admin', {
|
||||
out: true,
|
||||
bolt11,
|
||||
})
|
||||
|
||||
const parsed = PaymentResponseSchema.parse(data)
|
||||
return {
|
||||
success: true,
|
||||
paymentHash: parsed.payment_hash,
|
||||
checkingId: parsed.checking_id,
|
||||
feeSats: parsed.fee,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
paymentHash: '',
|
||||
checkingId: '',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lightning Address Operations
|
||||
|
||||
async resolveLightningAddress(address: string): Promise<{
|
||||
callback: string
|
||||
minSats: number
|
||||
maxSats: number
|
||||
commentAllowed: number
|
||||
}> {
|
||||
const [name, domain] = address.split('@')
|
||||
if (!name || !domain) {
|
||||
throw new Error('Invalid Lightning Address format')
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://${domain}/.well-known/lnurlp/${name}`,
|
||||
{ signal: AbortSignal.timeout(10000) }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to resolve Lightning Address: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = LnurlPayResponseSchema.parse(await response.json())
|
||||
return {
|
||||
callback: data.callback,
|
||||
minSats: Math.ceil(data.minSendable / 1000),
|
||||
maxSats: Math.floor(data.maxSendable / 1000),
|
||||
commentAllowed: data.commentAllowed ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
async payToLightningAddress(
|
||||
address: string,
|
||||
amountSats: number,
|
||||
comment?: string
|
||||
): Promise<PaymentResult> {
|
||||
// Step 1: Resolve address to LNURL-pay endpoint
|
||||
const resolved = await this.resolveLightningAddress(address)
|
||||
|
||||
// Validate amount
|
||||
if (amountSats < resolved.minSats || amountSats > resolved.maxSats) {
|
||||
return {
|
||||
success: false,
|
||||
paymentHash: '',
|
||||
checkingId: '',
|
||||
error: `Amount must be between ${resolved.minSats} and ${resolved.maxSats} sats`,
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Get invoice from callback
|
||||
const callbackUrl = new URL(resolved.callback)
|
||||
callbackUrl.searchParams.set('amount', (amountSats * 1000).toString())
|
||||
if (comment && resolved.commentAllowed > 0) {
|
||||
callbackUrl.searchParams.set('comment', comment.slice(0, resolved.commentAllowed))
|
||||
}
|
||||
|
||||
const invoiceResponse = await fetch(callbackUrl.toString(), {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (!invoiceResponse.ok) {
|
||||
return {
|
||||
success: false,
|
||||
paymentHash: '',
|
||||
checkingId: '',
|
||||
error: 'Failed to get invoice from Lightning Address',
|
||||
}
|
||||
}
|
||||
|
||||
const { pr: bolt11 } = await invoiceResponse.json()
|
||||
|
||||
// Step 3: Pay the invoice
|
||||
return this.payInvoice(bolt11)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Examples
|
||||
|
||||
```typescript
|
||||
// Initialize client
|
||||
const lnbits = new LNbitsClient({
|
||||
baseUrl: 'https://lnbits.example.com',
|
||||
adminKey: process.env.LNBITS_ADMIN_KEY!,
|
||||
invoiceKey: process.env.LNBITS_INVOICE_KEY!,
|
||||
walletId: process.env.LNBITS_WALLET_ID!,
|
||||
})
|
||||
|
||||
// Check balance
|
||||
const balance = await lnbits.getBalance()
|
||||
console.log(`Wallet balance: ${balance} sats`)
|
||||
|
||||
// Cash-out: Create invoice for customer to pay
|
||||
const invoice = await lnbits.createInvoice(50000, 'ATM Cash-out')
|
||||
console.log(`Invoice: ${invoice.bolt11}`)
|
||||
|
||||
// Wait for payment
|
||||
const status = await lnbits.waitForPayment(invoice.checkingId, 600000)
|
||||
if (status.paid) {
|
||||
console.log('Payment received!')
|
||||
}
|
||||
|
||||
// Cash-in: Pay to customer's Lightning Address
|
||||
const result = await lnbits.payToLightningAddress(
|
||||
'user@walletofsatoshi.com',
|
||||
50000,
|
||||
'ATM withdrawal'
|
||||
)
|
||||
if (result.success) {
|
||||
console.log(`Payment sent! Hash: ${result.paymentHash}`)
|
||||
}
|
||||
```
|
||||
|
||||
#typescript #implementation
|
||||
|
||||
---
|
||||
|
||||
## Deployment Architecture
|
||||
|
||||
### Single LNbits Instance (Recommended)
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "ATM Fleet"
|
||||
atm1[ATM Berlin]
|
||||
atm2[ATM Paris]
|
||||
atm3[ATM Amsterdam]
|
||||
end
|
||||
|
||||
subgraph "Lamassu Server"
|
||||
api[REST API]
|
||||
lightning[Lightning Service]
|
||||
end
|
||||
|
||||
subgraph "LNbits"
|
||||
lnbits_api[LNbits API]
|
||||
wallet[Shared Wallet]
|
||||
end
|
||||
|
||||
subgraph "Lightning Node"
|
||||
lnd[LND / CLN]
|
||||
end
|
||||
|
||||
atm1 --> api
|
||||
atm2 --> api
|
||||
atm3 --> api
|
||||
|
||||
api --> lightning
|
||||
lightning --> lnbits_api
|
||||
lnbits_api --> wallet
|
||||
wallet --> lnd
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Single point of management
|
||||
- Shared liquidity
|
||||
- Simpler monitoring
|
||||
|
||||
**Cons:**
|
||||
- Single point of failure
|
||||
- Requires robust HA setup
|
||||
|
||||
### Per-ATM Wallets
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "LNbits"
|
||||
lnbits_api[LNbits API]
|
||||
|
||||
subgraph "Wallets"
|
||||
wallet1[ATM-Berlin Wallet]
|
||||
wallet2[ATM-Paris Wallet]
|
||||
wallet3[ATM-Amsterdam Wallet]
|
||||
end
|
||||
end
|
||||
|
||||
atm1[ATM Berlin] -->|adminkey_1| wallet1
|
||||
atm2[ATM Paris] -->|adminkey_2| wallet2
|
||||
atm3[ATM Amsterdam] -->|adminkey_3| wallet3
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Isolated balances
|
||||
- Per-ATM accounting
|
||||
- Granular key management
|
||||
|
||||
**Cons:**
|
||||
- More complex setup
|
||||
- Fragmented liquidity
|
||||
|
||||
#architecture #deployment
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# .env (packages/server)
|
||||
|
||||
# LNbits Connection
|
||||
LNBITS_URL=https://lnbits.example.com
|
||||
LNBITS_ADMIN_KEY=abc123...
|
||||
LNBITS_INVOICE_KEY=def456...
|
||||
LNBITS_WALLET_ID=wallet-uuid
|
||||
|
||||
# Lightning Settings
|
||||
LIGHTNING_PROVIDER=lnbits
|
||||
LIGHTNING_TIMEOUT_MS=30000
|
||||
LIGHTNING_MAX_FEE_PERCENT=1.0
|
||||
```
|
||||
|
||||
### NixOS Configuration
|
||||
|
||||
```nix
|
||||
# /etc/nixos/lnbits.nix
|
||||
{ config, pkgs, ... }:
|
||||
|
||||
{
|
||||
services.lnbits = {
|
||||
enable = true;
|
||||
host = "127.0.0.1";
|
||||
port = 5000;
|
||||
|
||||
settings = {
|
||||
LNBITS_BACKEND_WALLET_CLASS = "LndRestWallet";
|
||||
LND_REST_ENDPOINT = "https://localhost:8080";
|
||||
LND_REST_CERT = "/var/lib/lnd/tls.cert";
|
||||
LND_REST_MACAROON = "/var/lib/lnd/admin.macaroon";
|
||||
|
||||
LNBITS_DATABASE_URL = "postgres://lnbits:password@localhost/lnbits";
|
||||
LNBITS_SITE_TITLE = "Lamassu Lightning";
|
||||
};
|
||||
};
|
||||
|
||||
# Reverse proxy with Caddy
|
||||
services.caddy.virtualHosts."lnbits.example.com" = {
|
||||
extraConfig = ''
|
||||
reverse_proxy localhost:5000
|
||||
'';
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### sops-nix Secrets
|
||||
|
||||
```yaml
|
||||
# secrets/lnbits.yaml
|
||||
lnbits_admin_key: ENC[AES256_GCM,data:...,type:str]
|
||||
lnbits_invoice_key: ENC[AES256_GCM,data:...,type:str]
|
||||
```
|
||||
|
||||
```nix
|
||||
# NixOS module
|
||||
sops.secrets.lnbits_admin_key = {
|
||||
sopsFile = ./secrets/lnbits.yaml;
|
||||
owner = "lamassu";
|
||||
};
|
||||
|
||||
sops.secrets.lnbits_invoice_key = {
|
||||
sopsFile = ./secrets/lnbits.yaml;
|
||||
owner = "lamassu";
|
||||
};
|
||||
```
|
||||
|
||||
#configuration #nixos #secrets
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Alerts
|
||||
|
||||
### Health Checks
|
||||
|
||||
```typescript
|
||||
// Health check endpoint
|
||||
async function checkLNbitsHealth(): Promise<HealthStatus> {
|
||||
try {
|
||||
const balance = await lnbits.getBalance()
|
||||
return {
|
||||
healthy: true,
|
||||
balance,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
healthy: false,
|
||||
error: error.message,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Balance Alerts
|
||||
|
||||
```typescript
|
||||
const LOW_BALANCE_THRESHOLD = 100000 // 100k sats
|
||||
|
||||
async function checkBalanceAlerts() {
|
||||
const balance = await lnbits.getBalance()
|
||||
|
||||
if (balance < LOW_BALANCE_THRESHOLD) {
|
||||
await sendAlert({
|
||||
level: 'warning',
|
||||
message: `Low LNbits balance: ${balance} sats`,
|
||||
action: 'Top up Lightning wallet',
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Prometheus Metrics
|
||||
|
||||
```typescript
|
||||
// Expose metrics for Prometheus
|
||||
import { Counter, Gauge } from 'prom-client'
|
||||
|
||||
const lnbitsBalance = new Gauge({
|
||||
name: 'lnbits_wallet_balance_sats',
|
||||
help: 'Current LNbits wallet balance in satoshis',
|
||||
})
|
||||
|
||||
const lnbitsPayments = new Counter({
|
||||
name: 'lnbits_payments_total',
|
||||
help: 'Total LNbits payments',
|
||||
labelNames: ['direction', 'status'],
|
||||
})
|
||||
|
||||
// Update metrics
|
||||
setInterval(async () => {
|
||||
const balance = await lnbits.getBalance()
|
||||
lnbitsBalance.set(balance)
|
||||
}, 60000)
|
||||
```
|
||||
|
||||
#monitoring #alerts #prometheus
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Errors
|
||||
|
||||
| Error | Cause | Resolution |
|
||||
|-------|-------|------------|
|
||||
| `INSUFFICIENT_BALANCE` | Wallet balance too low | Top up wallet |
|
||||
| `INVOICE_EXPIRED` | Invoice not paid in time | Create new invoice |
|
||||
| `PAYMENT_FAILED` | Route not found | Check node connectivity |
|
||||
| `RATE_LIMITED` | Too many requests | Implement backoff |
|
||||
| `UNAUTHORIZED` | Invalid API key | Check key configuration |
|
||||
|
||||
### Retry Strategy
|
||||
|
||||
```typescript
|
||||
import pRetry from 'p-retry'
|
||||
|
||||
async function payWithRetry(bolt11: string): Promise<PaymentResult> {
|
||||
return pRetry(
|
||||
async () => {
|
||||
const result = await lnbits.payInvoice(bolt11)
|
||||
if (!result.success && result.error?.includes('ROUTE')) {
|
||||
throw new Error('Retryable: No route found')
|
||||
}
|
||||
return result
|
||||
},
|
||||
{
|
||||
retries: 3,
|
||||
minTimeout: 1000,
|
||||
maxTimeout: 10000,
|
||||
onFailedAttempt: (error) => {
|
||||
console.log(`Payment attempt ${error.attemptNumber} failed`)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
#errors #retry
|
||||
|
||||
---
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [[membership-lightning-integration]] - Membership feature using LNbits
|
||||
- [[modernization-plan]] - Overall modernization roadmap
|
||||
- [[API Key Authentication]] - Server authentication
|
||||
278
docs/machine-installation.md
Normal file
278
docs/machine-installation.md
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
# Machine Installation
|
||||
|
||||
This document describes how to deploy the Lamassu Next ATM software to a Sintra machine.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### On the Sintra
|
||||
|
||||
- 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/
|
||||
├── Lamassu ATM-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/Lamassu ATM-0.1.0.AppImage" user@sintra:/opt/lamassu/
|
||||
```
|
||||
|
||||
2. Make executable:
|
||||
|
||||
```bash
|
||||
ssh user@sintra
|
||||
chmod +x "/opt/lamassu/Lamassu ATM-0.1.0.AppImage"
|
||||
```
|
||||
|
||||
3. Test manually:
|
||||
|
||||
```bash
|
||||
export DISPLAY=:0
|
||||
"/opt/lamassu/Lamassu ATM-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}]'
|
||||
```
|
||||
|
||||
See [Device Configuration](./device-configuration.md) for detailed configuration options.
|
||||
|
||||
### Lightning.Pub Connection
|
||||
|
||||
The ATM needs to connect to a Lightning.Pub instance. Configure via environment:
|
||||
|
||||
```bash
|
||||
# Nostr relay WebSocket URL (required)
|
||||
VITE_RELAY_URL=wss://your-relay.example.com
|
||||
|
||||
# Lightning.Pub's Nostr public key (required)
|
||||
# Get from: docker logs lamassu-lightning-pub | grep pubkey
|
||||
VITE_LIGHTNING_PUB_PUBKEY=4be8e203a3341bb2b74a4dcbf8774e061437f63ec21af7ec3144c8d0a68e2f39
|
||||
|
||||
# Lightning.Pub HTTP API URL (optional, for admin operations)
|
||||
VITE_LIGHTNING_PUB_API_URL=https://lp.operator.com
|
||||
|
||||
# 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...
|
||||
```
|
||||
|
||||
**Important:** The `VITE_LIGHTNING_PUB_PUBKEY` is required. Without it, the ATM cannot communicate with Lightning.Pub.
|
||||
|
||||
## Auto-Start on Boot
|
||||
|
||||
### Systemd Service
|
||||
|
||||
Create `/etc/systemd/system/lamassu-kiosk.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Lamassu ATM Kiosk
|
||||
After=graphical.target network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[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
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable lamassu-kiosk
|
||||
sudo systemctl start lamassu-kiosk
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
|
||||
```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.
|
||||
|
|
@ -1,525 +0,0 @@
|
|||
---
|
||||
title: Lamassu Modernization Plan
|
||||
created: 2026-01-22
|
||||
updated: 2026-01-22
|
||||
tags:
|
||||
- architecture
|
||||
- refactoring
|
||||
- roadmap
|
||||
- nix
|
||||
status: draft
|
||||
---
|
||||
|
||||
# Lamassu Modernization Plan
|
||||
|
||||
> [!abstract] Summary
|
||||
> Aggressive refactoring plan to bring Lamassu Bitcoin ATM software to 2026 standards, focusing on **reproducibility**, **security**, and **open-source maintainability** with NixOS deployment.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [[#Architecture Overview]]
|
||||
- [[#Technology Decisions]]
|
||||
- [[#Migration Phases]]
|
||||
- [[#Trade-offs]]
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "NixOS Production Server"
|
||||
deploy[deploy-rs / Colmena]
|
||||
sops[sops-nix secrets]
|
||||
|
||||
subgraph "lamassu-server"
|
||||
fastify[Fastify + tRPC + GraphQL]
|
||||
otel[OpenTelemetry]
|
||||
end
|
||||
|
||||
pg[(PostgreSQL + Drizzle)]
|
||||
jaeger[Jaeger / Grafana]
|
||||
end
|
||||
|
||||
subgraph "Admin UI"
|
||||
vue_admin[Vue 3 + shadcn-vue]
|
||||
tailwind[Tailwind CSS]
|
||||
end
|
||||
|
||||
subgraph "lamassu-machine"
|
||||
tauri[Tauri 2.x Rust Core]
|
||||
vue_machine[Vue 3 + Pinia]
|
||||
xstate[XState v5]
|
||||
hal[Rust HAL napi-rs]
|
||||
hw[Hardware Drivers]
|
||||
end
|
||||
|
||||
vue_admin -->|tRPC| fastify
|
||||
tauri -->|WebSocket| fastify
|
||||
fastify --> pg
|
||||
fastify --> otel
|
||||
otel --> jaeger
|
||||
hal --> hw
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technology Decisions
|
||||
|
||||
### Runtime & Language
|
||||
|
||||
> [!decision] Full TypeScript + Node.js 22 LTS
|
||||
> While Bun offers 3-4x performance, Node.js remains the backbone of enterprise applications. For mission-critical financial software, **reliability > raw performance**.
|
||||
|
||||
| Aspect | Current | Target |
|
||||
|--------|---------|--------|
|
||||
| Runtime | Node.js 22 | Node.js 22 LTS |
|
||||
| Language | JS + partial TS | Full TypeScript (strict) |
|
||||
| Modules | CommonJS + ESM mix | ESM only |
|
||||
|
||||
**Action Items:**
|
||||
- [ ] Migrate all JavaScript to TypeScript
|
||||
- [ ] Enable `strictNullChecks` and `noUncheckedIndexedAccess`
|
||||
- [ ] Eliminate all CommonJS requires
|
||||
|
||||
#typescript #nodejs
|
||||
|
||||
---
|
||||
|
||||
### Backend Framework
|
||||
|
||||
> [!decision] Express → Fastify
|
||||
> Fastify provides 70-80k req/s vs Express's 20-30k, with first-class TypeScript support and JSON Schema validation.
|
||||
|
||||
| Framework | Performance | TypeScript | Ecosystem |
|
||||
|-----------|-------------|------------|-----------|
|
||||
| Express | 20-30k req/s | Partial | Mature |
|
||||
| **Fastify** | 70-80k req/s | First-class | Growing |
|
||||
| Hono | Ultra-light | Good | Edge-focused |
|
||||
|
||||
**Why Fastify:**
|
||||
- JSON Schema validation built-in
|
||||
- HTTP/2 support
|
||||
- Plugin architecture
|
||||
- Better for long-running server processes
|
||||
|
||||
#backend #fastify
|
||||
|
||||
---
|
||||
|
||||
### API Layer
|
||||
|
||||
> [!decision] Hybrid: tRPC + GraphQL
|
||||
> tRPC for Admin UI (type-safe monorepo), GraphQL for machine communication (stable contract).
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
AdminUI -->|tRPC| Server
|
||||
Machine -->|GraphQL| Server
|
||||
```
|
||||
|
||||
**tRPC Benefits:**
|
||||
- End-to-end type safety without codegen
|
||||
- Faster iteration
|
||||
- Smaller bundle
|
||||
|
||||
**Keep GraphQL for:**
|
||||
- Machine API (stable contract)
|
||||
- Potential non-TypeScript clients
|
||||
- Consider Yoga over Apollo (lighter)
|
||||
|
||||
#api #trpc #graphql
|
||||
|
||||
---
|
||||
|
||||
### Database & ORM
|
||||
|
||||
> [!decision] Kysely → Drizzle ORM
|
||||
> SQL-first, schema-as-code, zero binary dependencies (critical for NixOS reproducibility).
|
||||
|
||||
| ORM | Bundle | Cold Start | Migrations |
|
||||
|-----|--------|------------|------------|
|
||||
| Prisma | Heavy | Slow | Excellent |
|
||||
| Kysely | Light | Fast | Basic |
|
||||
| **Drizzle** | ~7kb | Fastest | Good |
|
||||
|
||||
**Why Drizzle:**
|
||||
- SQL-like syntax (readable)
|
||||
- Zero binary dependencies
|
||||
- 90% reduction in cold starts
|
||||
- TypeScript schema definitions
|
||||
|
||||
#database #drizzle #postgresql
|
||||
|
||||
---
|
||||
|
||||
### Frontend (Admin UI)
|
||||
|
||||
> [!decision] React → Vue 3
|
||||
> Unify on Vue 3 across all UIs (admin + machine) for consistency and shared code.
|
||||
|
||||
| Aspect | Before (React) | After (Vue 3) |
|
||||
|--------|---------------|---------------|
|
||||
| Framework | React 18 | Vue 3 |
|
||||
| UI Library | MUI (~300kb) | shadcn-vue (~15kb) |
|
||||
| State | Zustand | Pinia |
|
||||
| API | Apollo GraphQL | tRPC |
|
||||
| Bundle | ~400kb | ~60kb |
|
||||
|
||||
**Benefits:**
|
||||
- Single framework across all UIs
|
||||
- Shared composables between admin and machine
|
||||
- 70% bundle size reduction
|
||||
- End-to-end type safety with tRPC
|
||||
|
||||
See [[admin-ui-modernization]] for detailed migration plan.
|
||||
|
||||
#frontend #vue #tailwind
|
||||
|
||||
---
|
||||
|
||||
### Machine State Management
|
||||
|
||||
> [!decision] Machina.js → XState v5
|
||||
> Actor-based state management with visual editor and TypeScript inference.
|
||||
|
||||
**Current:** `lib/brain.js` (134KB monolith)
|
||||
|
||||
**XState Benefits:**
|
||||
- Visual state machine editor (Stately.ai)
|
||||
- TypeScript 5.0+ with excellent inference
|
||||
- Actor model for complex flows
|
||||
- Production-tested at scale
|
||||
|
||||
> [!warning] Learning Curve
|
||||
> XState has a steep learning curve. The mental model differs significantly from Redux/Context.
|
||||
|
||||
#statemachine #xstate
|
||||
|
||||
---
|
||||
|
||||
### Machine UI Framework
|
||||
|
||||
> [!decision] Vanilla JS → Vue 3 + Tauri 2.x
|
||||
> Vue 3 for UI consistency, Tauri for security-first kiosk shell.
|
||||
|
||||
**UI Layer: Vue 3**
|
||||
|
||||
| Aspect | Before | After |
|
||||
|--------|--------|-------|
|
||||
| Framework | Vanilla JS + jQuery | Vue 3 |
|
||||
| State | Global variables | Pinia |
|
||||
| Build | Babel 6 | Vite |
|
||||
| File | 80KB monolith | Component-based |
|
||||
|
||||
**Shell: Tauri 2.x**
|
||||
|
||||
| Aspect | Electron | Tauri |
|
||||
|--------|----------|-------|
|
||||
| Bundle Size | ~100MB | **~2.5MB** |
|
||||
| RAM Usage | 150-300MB | **30-50MB** |
|
||||
| Startup | 1-2s | **<0.5s** |
|
||||
| Security | Full Node access | **Explicit allowlist** |
|
||||
|
||||
**Benefits:**
|
||||
- Same Vue 3 skills as admin UI
|
||||
- Shared ui-shared package
|
||||
- Tauri's Rust core for hardware drivers
|
||||
- Security by default
|
||||
|
||||
See [[machine-ui-modernization]] for detailed migration plan.
|
||||
|
||||
#tauri #vue #kiosk #security
|
||||
|
||||
---
|
||||
|
||||
### Hardware Abstraction
|
||||
|
||||
> [!decision] JavaScript → Rust + napi-rs
|
||||
> Memory safety at compile time for hardware drivers.
|
||||
|
||||
```
|
||||
TypeScript Application
|
||||
↓
|
||||
napi-rs bindings
|
||||
↓
|
||||
Rust HAL Layer
|
||||
↓
|
||||
Hardware (bill validators, printers, etc.)
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- No null pointer dereferences
|
||||
- No buffer overflows
|
||||
- Pre-built binaries for platforms
|
||||
- Integrates with Tauri (both Rust)
|
||||
|
||||
#rust #napi #hardware
|
||||
|
||||
---
|
||||
|
||||
### Schema Validation
|
||||
|
||||
> [!decision] Yup → Zod
|
||||
> Better TypeScript integration, larger ecosystem, tRPC compatibility.
|
||||
|
||||
| Library | Bundle | Ecosystem | tRPC |
|
||||
|---------|--------|-----------|------|
|
||||
| Yup | ~15kb | Mature | Manual |
|
||||
| **Zod** | ~17kb | Large | Native |
|
||||
| Valibot | ~1.4kb | Growing | Adapter |
|
||||
|
||||
**Use Valibot** for machine-side code where bundle size matters.
|
||||
|
||||
#validation #zod
|
||||
|
||||
---
|
||||
|
||||
### Observability
|
||||
|
||||
> [!decision] OpenTelemetry
|
||||
> Vendor-neutral, unified traces/metrics/logs.
|
||||
|
||||
```typescript
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node'
|
||||
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
|
||||
```
|
||||
|
||||
**Self-hosted stack:**
|
||||
- **Jaeger** → Distributed tracing
|
||||
- **Prometheus + Grafana** → Metrics
|
||||
- All deployable via NixOS modules
|
||||
|
||||
#observability #opentelemetry #monitoring
|
||||
|
||||
---
|
||||
|
||||
### Authentication
|
||||
|
||||
> [!decision] Passkey-First Authentication
|
||||
> Resistant to phishing and credential theft.
|
||||
|
||||
**Current:** Client certs + Argon2 + SimpleWebAuthn
|
||||
|
||||
**Target:**
|
||||
- Passkeys as primary auth (WebAuthn)
|
||||
- Keep client certs for machine-to-server
|
||||
- Upgrade to SimpleWebAuthn v10+
|
||||
|
||||
> [!warning] 2025 Context
|
||||
> 4B credentials leaked in January 2025. Password-based auth is a liability.
|
||||
|
||||
#security #passkeys #webauthn
|
||||
|
||||
---
|
||||
|
||||
## NixOS Infrastructure
|
||||
|
||||
### Development Environment
|
||||
|
||||
> [!decision] devenv
|
||||
> 100% reproducible development environments.
|
||||
|
||||
```nix
|
||||
# devenv.nix
|
||||
{ pkgs, ... }: {
|
||||
languages.javascript = {
|
||||
enable = true;
|
||||
package = pkgs.nodejs_22;
|
||||
pnpm.enable = true;
|
||||
};
|
||||
languages.typescript.enable = true;
|
||||
languages.rust.enable = true;
|
||||
|
||||
services.postgres = {
|
||||
enable = true;
|
||||
initialDatabases = [{ name = "lamassu"; }];
|
||||
};
|
||||
|
||||
pre-commit.hooks = {
|
||||
prettier.enable = true;
|
||||
eslint.enable = true;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Single `devenv.nix` replaces Docker, brew, apt
|
||||
- DevContainer generation for VS Code
|
||||
- Built-in PostgreSQL service
|
||||
- Pre-commit hooks integration
|
||||
|
||||
#nix #devenv #reproducibility
|
||||
|
||||
---
|
||||
|
||||
### Secrets Management
|
||||
|
||||
> [!decision] sops-nix
|
||||
> Atomic, declarative secret provisioning.
|
||||
|
||||
**Features:**
|
||||
- Supports age, GPG, AWS KMS, HashiCorp Vault
|
||||
- Works with existing SSH keys
|
||||
- Version-control friendly (encrypted in git)
|
||||
- Compatible with all NixOS deployment tools
|
||||
|
||||
```nix
|
||||
sops.secrets.database_password = {
|
||||
sopsFile = ./secrets/db.yaml;
|
||||
owner = "lamassu";
|
||||
};
|
||||
```
|
||||
|
||||
#secrets #sops #security
|
||||
|
||||
---
|
||||
|
||||
### Production Deployment
|
||||
|
||||
> [!decision] deploy-rs
|
||||
> Automatic rollback on failure - critical for ATM servers.
|
||||
|
||||
| Tool | Rollback | Secrets | Parallel |
|
||||
|------|----------|---------|----------|
|
||||
| **deploy-rs** | Automatic | External | Yes |
|
||||
| Colmena | Manual | Built-in | Yes |
|
||||
|
||||
**Why deploy-rs:**
|
||||
- Connects after activation to confirm availability
|
||||
- Auto-rollback if machine becomes unreachable
|
||||
- Critical for network config changes on remote ATMs
|
||||
|
||||
#deployment #deploy-rs #nixos
|
||||
|
||||
---
|
||||
|
||||
### Node.js Packaging
|
||||
|
||||
> [!tip] dream2nix
|
||||
> Auto-generates Nix derivations from `package-lock.json`.
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.dream2nix.url = "github:nix-community/dream2nix";
|
||||
|
||||
outputs = { dream2nix, ... }:
|
||||
dream2nix.lib.makeFlakeOutputs {
|
||||
source = ./.;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#nix #packaging
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit/Integration
|
||||
|
||||
> [!check] Keep Vitest
|
||||
> Already in use, works well with TypeScript.
|
||||
|
||||
### E2E Testing
|
||||
|
||||
> [!decision] Add Playwright
|
||||
> TypeScript-first, auto-wait eliminates flaky tests.
|
||||
|
||||
**Best Practices:**
|
||||
- Use accessible locators (`getByRole`, `getByLabel`)
|
||||
- Test isolation (each test independent)
|
||||
- Run `tsc --noEmit` in CI
|
||||
|
||||
```typescript
|
||||
test('user can complete transaction', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Start' }).click()
|
||||
await expect(page.getByText('Insert bill')).toBeVisible()
|
||||
})
|
||||
```
|
||||
|
||||
#testing #playwright #vitest
|
||||
|
||||
---
|
||||
|
||||
## Migration Phases
|
||||
|
||||
### Phase 1: Foundation
|
||||
> [!todo] High Impact, Lower Risk
|
||||
|
||||
- [ ] Full TypeScript migration
|
||||
- [ ] devenv for development environment
|
||||
- [ ] Drizzle ORM migration
|
||||
- [ ] OpenTelemetry instrumentation
|
||||
- [ ] Nix flake for builds
|
||||
|
||||
### Phase 2: Backend Modernization
|
||||
|
||||
- [ ] Express → Fastify migration
|
||||
- [ ] Add tRPC for admin API
|
||||
- [ ] Zod schema validation
|
||||
- [ ] Playwright E2E tests
|
||||
|
||||
### Phase 3: Machine Modernization
|
||||
> [!warning] Higher Risk - Requires extensive testing
|
||||
|
||||
- [ ] XState v5 for state machine
|
||||
- [ ] Tauri migration
|
||||
- [ ] Rust HAL for hardware drivers
|
||||
|
||||
### Phase 4: Deployment
|
||||
|
||||
- [ ] NixOS module for lamassu-server
|
||||
- [ ] sops-nix secrets management
|
||||
- [ ] deploy-rs for production deployments
|
||||
|
||||
---
|
||||
|
||||
## Trade-offs
|
||||
|
||||
| Decision | We Get | We Lose |
|
||||
|----------|--------|---------|
|
||||
| Node.js over Bun | Stability, ecosystem | Raw performance |
|
||||
| Fastify over Hono | Mature plugins | Minimal bundle |
|
||||
| Drizzle over Prisma | Bundle size, speed | DX features |
|
||||
| Vue 3 over React | Unified UI, smaller bundle | React ecosystem |
|
||||
| Tauri over Electron | Security, efficiency | Ecosystem maturity |
|
||||
| XState over simple FSM | Visualization, debugging | Simplicity |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
### Backend
|
||||
- [Fastify vs Express 2025](https://medium.com/codetodeploy/express-or-fastify-in-2025-whats-the-right-node-js-framework-for-you-6ea247141a86)
|
||||
- [tRPC vs GraphQL](https://betterstack.com/community/guides/scaling-nodejs/trpc-vs-graphql/)
|
||||
- [Drizzle vs Prisma vs Kysely](https://levelup.gitconnected.com/the-2025-typescript-orm-battle-prisma-vs-drizzle-vs-kysely-007ffdfded67)
|
||||
|
||||
### Machine
|
||||
- [Tauri vs Electron 2025](https://www.dolthub.com/blog/2025-11-13-electron-vs-tauri/)
|
||||
- [XState v5](https://stately.ai/blog/2023-12-01-xstate-v5)
|
||||
- [napi-rs Guide](https://blog.logrocket.com/building-nodejs-modules-rust-napi-rs/)
|
||||
|
||||
### NixOS
|
||||
- [devenv](https://devenv.sh/)
|
||||
- [sops-nix](https://github.com/Mic92/sops-nix)
|
||||
- [deploy-rs](https://github.com/serokell/deploy-rs)
|
||||
|
||||
### Security
|
||||
- [Passkeys Guide](https://www.passkeys.com/guide)
|
||||
- [OpenTelemetry Node.js](https://opentelemetry.io/docs/languages/js/)
|
||||
|
||||
---
|
||||
|
||||
## Related Notes
|
||||
|
||||
- [[architecture-review]] - **KYC-free Lightning-first architecture review**
|
||||
- [[nostr-native-architecture]] - **Nostr as infrastructure backbone**
|
||||
- [[CLAUDE]] - Claude Code guidance
|
||||
- [[admin-ui-modernization]] - Vue 3 migration for admin dashboard
|
||||
- [[machine-ui-modernization]] - Vue 3 migration for kiosk UI
|
||||
- [[hardware-recommendations]] - Hardware component recommendations
|
||||
- [[membership-lightning-integration]] - Membership & LNbits feature
|
||||
- [[lnbits-integration]] - Lightning backend integration
|
||||
- [[Architecture Decision Records]] - ADRs for each decision
|
||||
- [[NixOS Configuration]] - Production NixOS setup
|
||||
845
docs/ndebit-cash-in-flow.md
Normal file
845
docs/ndebit-cash-in-flow.md
Normal file
|
|
@ -0,0 +1,845 @@
|
|||
# NDebit Cash-In Flow Implementation Guide
|
||||
|
||||
This document describes how to implement the ndebit scanning flow for ATM cash-in, where a user scans an ndebit QR code from an ATM to withdraw sats to their wallet.
|
||||
|
||||
## Overview
|
||||
|
||||
**Flow Summary:**
|
||||
|
||||
1. ATM displays QR code containing `clink:ndebit1...?amount=X`
|
||||
2. User scans QR with wallet → navigates to Claim screen
|
||||
3. Wallet creates a Lightning invoice for the specified amount
|
||||
4. Wallet sends Kind 21002 debit request to the relay encoded in the ndebit
|
||||
5. Lightning.Pub (ATM's backend) receives the request and pays the invoice
|
||||
6. User receives sats
|
||||
|
||||
**Key Insight:** The user is _receiving_ sats, not sending. The ndebit flow is a RECEIVE action from the wallet's perspective.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ ATM Machine │ │ Nostr Relay │ │ User's Wallet │
|
||||
│ │ │ │ │ │
|
||||
│ Displays QR: │ │ ws://relay:7777│ │ ShockWallet │
|
||||
│ clink: │ │ │ │ │
|
||||
│ ndebit1... │ │ │ │ │
|
||||
│ ?amount=1000 │ │ │ │ │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
│ │ 1. Scan QR │
|
||||
│ │◄──────────────────────┤
|
||||
│ │ │
|
||||
│ │ 2. Create invoice │
|
||||
│ │ (via Lightning.Pub)│
|
||||
│ │◄──────────────────────┤
|
||||
│ │ │
|
||||
│ │ 3. Kind 21002 debit │
|
||||
│ │ request with │
|
||||
│ │ invoice │
|
||||
│ │◄──────────────────────┤
|
||||
│ │ │
|
||||
│ 4. Lightning.Pub │ │
|
||||
│ receives request │ │
|
||||
│◄──────────────────────┤ │
|
||||
│ │ │
|
||||
│ 5. ATM validates & │ │
|
||||
│ pays invoice │ │
|
||||
│──────────────────────►│ │
|
||||
│ │ │
|
||||
│ │ 6. Payment received │
|
||||
│ │──────────────────────►│
|
||||
│ │ │
|
||||
```
|
||||
|
||||
## NDebit Format
|
||||
|
||||
### Bech32 Encoding
|
||||
|
||||
The ndebit string is bech32-encoded with the following TLV data:
|
||||
|
||||
```typescript
|
||||
interface DebitPointer {
|
||||
pubkey: string // Lightning.Pub's pubkey (hex)
|
||||
relay: string // Relay URL for communication
|
||||
pointer?: string // Optional session/voucher identifier
|
||||
}
|
||||
```
|
||||
|
||||
### URI Format with Amount
|
||||
|
||||
Following the BIP-21 pattern for unified QR codes, but using `clink:` as the scheme since CLINK is protocol-agnostic (could work with Cashu/Fedimint, not just Lightning):
|
||||
|
||||
```
|
||||
clink:ndebit1<bech32data>?amount=<sats>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
clink:ndebit1qgpkzardqyfhwue69uhkcmmrv9kxsmmnwsarwdehxuqzq34ndmupyc4vrc3tx0rp0km6xmyldrwmum0afjc58rzjegz9hh0m7up5w0?amount=1000
|
||||
```
|
||||
|
||||
**Important:** The amount is NOT encoded in the bech32 ndebit itself - it's passed as a query parameter. This allows a static ndebit to be used with dynamic amounts.
|
||||
|
||||
### Why `clink:` Instead of `lightning:`?
|
||||
|
||||
We use `clink:` as the URI scheme instead of `lightning:` for several reasons:
|
||||
|
||||
1. **Protocol-agnostic** - CLINK is not Lightning-specific. The same ndebit flow could work with Cashu mints or Fedimint in the future.
|
||||
|
||||
2. **Accuracy** - `lightning:` was designed for BOLT11 invoices (`lnbc...`) and LNURL. Using it for `ndebit1...` strings is semantically incorrect.
|
||||
|
||||
3. **Future-proofing** - As CLINK expands to support more payment backends, `clink:` remains accurate while `lightning:` would be misleading.
|
||||
|
||||
**Browser Support Note:** Neither `clink:` nor `lightning:` are IANA-registered schemes, so browsers treat them identically. For QR code scanning (the primary use case), both work fine since the camera app hands the URI directly to the OS for app routing.
|
||||
|
||||
Wallets SHOULD support both schemes for compatibility:
|
||||
|
||||
```typescript
|
||||
const CLINK_SCHEME = '(?:clink:|lightning:)?'
|
||||
```
|
||||
|
||||
## Wallet Implementation
|
||||
|
||||
### 1. Regex for Parsing
|
||||
|
||||
```typescript
|
||||
// In lib/regex.ts
|
||||
// Support both clink: (preferred) and lightning: (legacy) schemes
|
||||
const CLINK_SCHEME = '(?:clink:|lightning:)?'
|
||||
const BECH32_DATA = '[02-9ac-hj-np-z]'
|
||||
|
||||
export const NDEBIT_REGEX = new RegExp(
|
||||
`^${CLINK_SCHEME}(ndebit1${BECH32_DATA}+)(?:\\?amount=(\\d+))?`,
|
||||
'i'
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Type Definitions
|
||||
|
||||
```typescript
|
||||
// In lib/types/parse.ts
|
||||
import type { DebitPointer } from '@lamassu/clink'
|
||||
import type { Satoshi } from './units'
|
||||
|
||||
export enum InputClassification {
|
||||
// ... other types
|
||||
NDEBIT = 'Ndebit',
|
||||
}
|
||||
|
||||
export interface ParsedNdebitInput {
|
||||
type: InputClassification.NDEBIT
|
||||
data: string // The raw ndebit string
|
||||
ndebit: DebitPointer
|
||||
amount?: Satoshi // From ?amount= query parameter
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Input Parser
|
||||
|
||||
```typescript
|
||||
// In lib/parse.ts
|
||||
import { decodeNdebit } from '@lamassu/clink'
|
||||
|
||||
// Add to VALIDATORS array:
|
||||
{
|
||||
type: InputClassification.NDEBIT,
|
||||
test: (s) => {
|
||||
const m = s.match(NDEBIT_REGEX);
|
||||
if (m) {
|
||||
const ndebit = m[1].toLowerCase();
|
||||
const amount = m[2]; // may be undefined
|
||||
return {
|
||||
classification: InputClassification.NDEBIT,
|
||||
value: amount ? `${ndebit}?amount=${amount}` : ndebit
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// In parseBitcoinInput switch:
|
||||
case InputClassification.NDEBIT: {
|
||||
const [ndebitPart, queryPart] = input.split("?");
|
||||
|
||||
let amount: Satoshi | undefined;
|
||||
if (queryPart) {
|
||||
const amountMatch = queryPart.match(/amount=(\d+)/);
|
||||
if (amountMatch) {
|
||||
amount = parseInt(amountMatch[1], 10) as Satoshi;
|
||||
}
|
||||
}
|
||||
|
||||
const decoded = decodeNdebit(ndebitPart)
|
||||
if (!decoded) {
|
||||
throw new Error("Invalid ndebit string");
|
||||
}
|
||||
|
||||
return {
|
||||
type: InputClassification.NDEBIT,
|
||||
data: ndebitPart,
|
||||
ndebit: decoded,
|
||||
amount
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Claim Thunk
|
||||
|
||||
```typescript
|
||||
// In State/scoped/backups/sources/history/claimNdebitThunk.ts
|
||||
import { getNostrClient } from '@/Api/nostr'
|
||||
// Note: SendNdebitRequest is wallet-side code, not part of @lamassu/clink
|
||||
// This example shows the wallet's implementation pattern
|
||||
import { finalizeEvent } from 'nostr-tools'
|
||||
import { SimplePool } from 'nostr-tools'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
export const claimNdebitThunk = ({
|
||||
sourceId,
|
||||
parsedInput,
|
||||
amount,
|
||||
note,
|
||||
showToast,
|
||||
}: {
|
||||
sourceId: string
|
||||
parsedInput: ParsedNdebitInput
|
||||
amount: Satoshi
|
||||
note?: string
|
||||
showToast: ShowToast
|
||||
}): AppThunk<Promise<boolean>> => {
|
||||
return async (dispatch, getState) => {
|
||||
const selectedSource = selectSourceViewById(getState(), sourceId)
|
||||
if (!selectedSource || selectedSource.type !== SourceType.NPROFILE_SOURCE) {
|
||||
showToast({ message: 'Source not found', color: 'danger' })
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Create invoice to RECEIVE payment
|
||||
let client
|
||||
try {
|
||||
client = await getNostrClient(
|
||||
{ pubkey: selectedSource.lpk, relays: selectedSource.relays },
|
||||
selectedSource.keys
|
||||
)
|
||||
} catch (err) {
|
||||
throw new Error('Cannot connect to Lightning.Pub')
|
||||
}
|
||||
|
||||
let invoiceRes
|
||||
try {
|
||||
invoiceRes = await client.NewInvoice({
|
||||
amountSats: amount,
|
||||
memo: note || `Debit request for ${amount} sats`,
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error('Failed to create invoice')
|
||||
}
|
||||
|
||||
if (invoiceRes.status === 'ERROR') {
|
||||
throw new Error(invoiceRes.reason || 'Failed to create invoice')
|
||||
}
|
||||
|
||||
const invoice = invoiceRes.invoice
|
||||
|
||||
// Step 2: Send debit request
|
||||
showToast({ message: 'Waiting for approval...', color: 'primary' })
|
||||
|
||||
const pool = new SimplePool()
|
||||
const ndebitRes = await SendNdebitRequest(
|
||||
pool,
|
||||
hexToBytes(selectedSource.keys.privateKey),
|
||||
[parsedInput.ndebit.relay],
|
||||
parsedInput.ndebit.pubkey,
|
||||
{
|
||||
bolt11: invoice,
|
||||
amount_sats: amount,
|
||||
pointer: parsedInput.ndebit.pointer,
|
||||
},
|
||||
30 // 30 second timeout
|
||||
)
|
||||
|
||||
if (ndebitRes.res === 'GFY') {
|
||||
throw new Error(ndebitRes.error || 'Debit request denied')
|
||||
}
|
||||
|
||||
// Success!
|
||||
showToast({
|
||||
message: `Received ${amount} sats`,
|
||||
color: 'success',
|
||||
})
|
||||
|
||||
// Refresh history
|
||||
dispatch(historyFetchSourceRequested({ sourceId }))
|
||||
return true
|
||||
} catch (err: any) {
|
||||
showToast({
|
||||
message: err?.message || 'Debit request failed',
|
||||
color: 'danger',
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. UI Component (Claim Tab)
|
||||
|
||||
Place the ndebit claim UI in the **Receive** page (not Send!) since the user is receiving sats.
|
||||
|
||||
Key UI elements:
|
||||
|
||||
- Text input for pasting ndebit strings
|
||||
- QR scanner button
|
||||
- Amount display (pre-filled if from URI, editable if not)
|
||||
- "Amount set by sender" indicator when amount comes from URI
|
||||
- Claim button
|
||||
|
||||
## ATM/Service Implementation
|
||||
|
||||
### Overview
|
||||
|
||||
The ATM implements a **debit approval service** that:
|
||||
|
||||
1. Generates ndebit QR codes with the ATM's Lightning.Pub account
|
||||
2. Subscribes to `GetLiveDebitRequests` to receive incoming debit requests
|
||||
3. Validates requests against active sessions (single-use protection)
|
||||
4. Approves valid requests via `RespondToDebit` RPC
|
||||
|
||||
### Architecture: Debit Approval Flow
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ ATM Machine │ │ Nostr Relay │ │ Lightning.Pub │ │ User's Wallet │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │ │
|
||||
│ 1. GetLiveDebitRequests │ │
|
||||
│ (Kind 21000 subscription) │ │
|
||||
│──────────────────────►│──────────────────────►│ │
|
||||
│ │ │ │
|
||||
│ 2. Display QR: │ │ │
|
||||
│ clink:ndebit1... │ │ 3. Scan QR │
|
||||
│ ?amount=2450 │ │◄──────────────────────┤
|
||||
│ │ │ │
|
||||
│ │ │ 4. Kind 21002 │
|
||||
│ │ │ debit request │
|
||||
│ │◄──────────────────────┼───────────────────────┤
|
||||
│ │ │ │
|
||||
│ 5. Live debit request │ │ │
|
||||
│ (via subscription) │ │ │
|
||||
│◄──────────────────────┤◄──────────────────────┤ │
|
||||
│ │ │ │
|
||||
│ 6. Validate session │ │ │
|
||||
│ & approve │ │ │
|
||||
│──────────────────────►│──────────────────────►│ │
|
||||
│ (RespondToDebit) │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ 7. Pay invoice │
|
||||
│ │ │──────────────────────►│
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
### Generating the NDebit QR
|
||||
|
||||
```typescript
|
||||
// apps/machine/src/services/lightning.ts
|
||||
|
||||
generateNdebit: async (context: ATMContext): Promise<string> => {
|
||||
// The pointer MUST be a valid Lightning.Pub account identifier
|
||||
// Lightning.Pub uses this to find which account should pay
|
||||
const pointer = 'atm' // ATM's account identifier in Lightning.Pub
|
||||
|
||||
const ndebit = encodeNdebit({
|
||||
pubkey: CONFIG.lightningPubPubkey, // Lightning.Pub's pubkey (NOT ATM's!)
|
||||
relay: CONFIG.relayUrl,
|
||||
pointer,
|
||||
})
|
||||
|
||||
// Register session for single-use tracking (see below)
|
||||
registerActiveSession(context.cashInSessionId, context.satsAmount)
|
||||
|
||||
// Format as full URI with amount
|
||||
return formatNdebitUri(ndebit, context.satsAmount)
|
||||
// Returns: clink:ndebit1qqsyh68zq...?amount=2450
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** The ndebit encodes **Lightning.Pub's pubkey**, not the ATM's pubkey. This is because:
|
||||
|
||||
- The wallet sends Kind 21002 to the pubkey in the ndebit
|
||||
- Lightning.Pub must receive it to process the payment
|
||||
- The `pointer` field tells Lightning.Pub which account to debit
|
||||
|
||||
### Single-Use Protection (Session Management)
|
||||
|
||||
Each ndebit QR is valid for **one use only**. This prevents:
|
||||
|
||||
- Double-spending from the same QR
|
||||
- Replay attacks with saved QR codes
|
||||
|
||||
#### How It Works
|
||||
|
||||
1. **Session Registration**: When generating an ndebit, we create a session:
|
||||
|
||||
```typescript
|
||||
interface ActiveSession {
|
||||
sessionId: string // Unique ID for this transaction
|
||||
satsAmount: number // Expected amount
|
||||
createdAt: number // Timestamp
|
||||
status: 'active' | 'paid' | 'expired'
|
||||
}
|
||||
|
||||
const activeSessions = new Map<string, ActiveSession>()
|
||||
|
||||
function registerActiveSession(sessionId: string, satsAmount: number): void {
|
||||
activeSessions.set(sessionId, {
|
||||
sessionId,
|
||||
satsAmount,
|
||||
createdAt: Date.now(),
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
// Auto-expire after 5 minutes
|
||||
setTimeout(
|
||||
() => {
|
||||
const session = activeSessions.get(sessionId)
|
||||
if (session?.status === 'active') {
|
||||
session.status = 'expired'
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
2. **Session Validation**: When a debit request arrives, we find a matching session:
|
||||
|
||||
```typescript
|
||||
function findActiveSessionByAmount(amountSats: number): ActiveSession | null {
|
||||
for (const session of activeSessions.values()) {
|
||||
if (session.status !== 'active') continue
|
||||
|
||||
// Match with small tolerance for rounding
|
||||
const tolerance = Math.max(1, Math.floor(session.satsAmount * 0.001))
|
||||
if (Math.abs(amountSats - session.satsAmount) <= tolerance) {
|
||||
return session
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
3. **Atomic Status Update**: Before approving, we mark the session as paid:
|
||||
|
||||
```typescript
|
||||
// In debit request handler:
|
||||
const matchingSession = findActiveSessionByAmount(amountSats)
|
||||
if (!matchingSession) {
|
||||
console.log('[Debit] REJECTED: No matching active session')
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as paid BEFORE sending approval (prevents race conditions)
|
||||
matchingSession.status = 'paid'
|
||||
|
||||
// Now approve...
|
||||
```
|
||||
|
||||
#### Additional Protections
|
||||
|
||||
```typescript
|
||||
// Track processed events (prevents replay of same Nostr event)
|
||||
const processedEventIds = new Set<string>()
|
||||
|
||||
// Track approved invoices (prevents same invoice being approved twice)
|
||||
const approvedInvoices = new Set<string>()
|
||||
|
||||
const handleDebitRequest = async (message: DebitRequest, eventId: string) => {
|
||||
// Check 1: Event already processed?
|
||||
if (processedEventIds.has(eventId)) {
|
||||
console.log('[Debit] REJECTED: Event already processed')
|
||||
return
|
||||
}
|
||||
|
||||
// Check 2: Invoice already approved?
|
||||
if (approvedInvoices.has(invoice)) {
|
||||
console.log('[Debit] REJECTED: Invoice already approved')
|
||||
return
|
||||
}
|
||||
|
||||
// Check 3: Matching active session?
|
||||
const session = findActiveSessionByAmount(amountSats)
|
||||
if (!session) {
|
||||
console.log('[Debit] REJECTED: No matching active session')
|
||||
return
|
||||
}
|
||||
|
||||
// Mark everything BEFORE sending approval
|
||||
session.status = 'paid'
|
||||
processedEventIds.add(eventId)
|
||||
approvedInvoices.add(invoice)
|
||||
|
||||
// Now approve...
|
||||
}
|
||||
```
|
||||
|
||||
### Debit Approval Service
|
||||
|
||||
The ATM runs a background service that listens for debit requests:
|
||||
|
||||
```typescript
|
||||
function startDebitApprovalService(
|
||||
nostrClient: NostrClient,
|
||||
identity: MachineIdentity,
|
||||
onPaymentApproved?: DebitPaymentCallback
|
||||
): () => void {
|
||||
// 1. Subscribe to Kind 21000 events from Lightning.Pub
|
||||
const subscriptionId = nostrClient.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [CONFIG.lightningPubPubkey],
|
||||
'#p': [identity.publicKey],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{ onEvent: eventHandler }
|
||||
)
|
||||
|
||||
// 2. Send GetLiveDebitRequests subscription request
|
||||
const subscribeRequest = {
|
||||
rpcName: 'GetLiveDebitRequests',
|
||||
authIdentifier: identity.publicKey,
|
||||
body: {},
|
||||
}
|
||||
|
||||
const event = createSignedEvent(identity, {
|
||||
kind: 21000,
|
||||
tags: [['p', CONFIG.lightningPubPubkey]],
|
||||
content: encryptContent(identity, CONFIG.lightningPubPubkey, subscribeRequest),
|
||||
})
|
||||
|
||||
await nostrClient.publish(event)
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Debit Requests
|
||||
|
||||
When a debit request arrives via the subscription:
|
||||
|
||||
```typescript
|
||||
const eventHandler = (event: NostrEvent) => {
|
||||
// Decrypt the message
|
||||
const message = JSON.parse(decryptContent(identity, CONFIG.lightningPubPubkey, event.content))
|
||||
|
||||
// Check if it's a live debit request
|
||||
if (message.requestId === 'GetLiveDebitRequests' && message.debit) {
|
||||
handleDebitRequest(message, event.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDebitRequest = async (message: DebitRequest, eventId: string) => {
|
||||
// Message structure:
|
||||
// {
|
||||
// request_id: "9f22a67c...",
|
||||
// npub: "43bfda6c...",
|
||||
// debit: {
|
||||
// type: "invoice",
|
||||
// invoice: "lnbcrt24500n1p5hm..."
|
||||
// }
|
||||
// }
|
||||
|
||||
// Extract amount from BOLT11 invoice (amount_sats field is often missing)
|
||||
let amountSats = message.debit.amount_sats
|
||||
if (!amountSats) {
|
||||
amountSats = decodeAmountFromBolt11(message.debit.invoice)
|
||||
}
|
||||
|
||||
// Validate against active sessions (single-use check)
|
||||
const session = findActiveSessionByAmount(amountSats)
|
||||
if (!session) {
|
||||
console.log('[Debit] REJECTED: No matching active session')
|
||||
return
|
||||
}
|
||||
|
||||
// Mark session as paid (prevents double-use)
|
||||
session.status = 'paid'
|
||||
|
||||
// Approve the debit request
|
||||
const approveRequest = {
|
||||
rpcName: 'RespondToDebit',
|
||||
authIdentifier: identity.publicKey,
|
||||
body: {
|
||||
npub: message.npub,
|
||||
request_id: message.request_id,
|
||||
response: {
|
||||
type: 'invoice',
|
||||
invoice: message.debit.invoice,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const approveEvent = createSignedEvent(identity, {
|
||||
kind: 21000,
|
||||
tags: [['p', CONFIG.lightningPubPubkey]],
|
||||
content: encryptContent(identity, CONFIG.lightningPubPubkey, approveRequest),
|
||||
})
|
||||
|
||||
await nostrClient.publish(approveEvent)
|
||||
console.log('[Debit] SUCCESS: Approved debit request')
|
||||
}
|
||||
```
|
||||
|
||||
### BOLT11 Amount Decoding
|
||||
|
||||
Lightning.Pub doesn't always include `amount_sats` in the debit request, so we decode it from the invoice:
|
||||
|
||||
```typescript
|
||||
function decodeAmountFromBolt11(invoice: string): number | null {
|
||||
// BOLT11 format: ln<network><amount><multiplier>...
|
||||
// Networks: bc (mainnet), tb (testnet), bcrt (regtest)
|
||||
// Multipliers: m=milli (0.001), u=micro (0.000001), n=nano, p=pico
|
||||
|
||||
const match = invoice.toLowerCase().match(/^ln(bc|tb|bcrt)(\d+)([munp])?/)
|
||||
if (!match) return null
|
||||
|
||||
const [, , amountStr, multiplier] = match
|
||||
let amount = parseInt(amountStr, 10)
|
||||
|
||||
// Convert to satoshis (1 BTC = 100,000,000 sats)
|
||||
switch (multiplier) {
|
||||
case 'm':
|
||||
amount = amount * 100000
|
||||
break // milli-BTC
|
||||
case 'u':
|
||||
amount = amount * 100
|
||||
break // micro-BTC
|
||||
case 'n':
|
||||
amount = Math.floor(amount / 10)
|
||||
break // nano-BTC
|
||||
case 'p':
|
||||
amount = Math.floor(amount / 10000)
|
||||
break // pico-BTC
|
||||
default:
|
||||
amount = amount * 100000000 // BTC
|
||||
}
|
||||
|
||||
return amount
|
||||
}
|
||||
```
|
||||
|
||||
### Lightning.Pub's Role
|
||||
|
||||
Lightning.Pub handles the actual payment:
|
||||
|
||||
1. Receives Kind 21002 debit request from user's wallet
|
||||
2. Looks up the account by `pointer` field (e.g., "atm")
|
||||
3. Forwards request to GetLiveDebitRequests subscribers
|
||||
4. Waits for approval via RespondToDebit
|
||||
5. Pays the invoice from the account's balance
|
||||
6. Sends Kind 21002 response to user's wallet
|
||||
|
||||
**Important:** The ATM account must have sufficient balance to pay invoices.
|
||||
|
||||
## Infrastructure Requirements
|
||||
|
||||
### Relay Configuration
|
||||
|
||||
The relay must be accessible from:
|
||||
|
||||
1. **Lightning.Pub** (for publishing responses)
|
||||
2. **User's browser** (for subscribing to responses)
|
||||
|
||||
For Docker setups:
|
||||
|
||||
- Lightning.Pub uses internal Docker DNS: `ws://strfry:7777`
|
||||
- Browser uses host mapping: `ws://localhost:7777`
|
||||
- Both resolve to the same relay
|
||||
|
||||
**Lightning.Pub docker-compose config:**
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- NOSTR_RELAYS=ws://strfry:7777 # Docker internal
|
||||
```
|
||||
|
||||
**NDebit must encode browser-accessible relay:**
|
||||
|
||||
```
|
||||
ws://localhost:7777 # NOT ws://strfry:7777
|
||||
```
|
||||
|
||||
### Funding the ATM
|
||||
|
||||
The ATM user needs a balance to pay invoices:
|
||||
|
||||
```bash
|
||||
# Create invoice for ATM user
|
||||
curl -X POST "http://localhost:1776/api/app/user/add/invoice" \
|
||||
-H "Authorization: Bearer $APP_TOKEN" \
|
||||
-d '{"receiver_identifier": "atm", "payer_identifier": "external",
|
||||
"invoice_req": {"amountSats": 50000, "memo": "Fund ATM"}}'
|
||||
|
||||
# Pay from external node
|
||||
lncli payinvoice <invoice>
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### 1. Wrong Page for NDebit UI
|
||||
|
||||
**Problem:** Putting ndebit in the Send page
|
||||
**Solution:** NDebit is a RECEIVE action - put it in the Receive page
|
||||
|
||||
### 2. Relay Mismatch
|
||||
|
||||
**Problem:** NDebit encodes Docker-internal relay (`ws://strfry:7777`)
|
||||
**Solution:** Rewrite relay to browser-accessible URL (`ws://localhost:7777`)
|
||||
|
||||
### 3. Pubkey Mismatch
|
||||
|
||||
**Problem:** Hardcoded Lightning.Pub pubkey doesn't match after container recreation
|
||||
**Solution:** Dynamically fetch pubkey from ndebit or API
|
||||
|
||||
### 4. Zero Balance
|
||||
|
||||
**Problem:** Debit request fails with "Error in single invoice payment"
|
||||
**Solution:** Fund the ATM user before testing
|
||||
|
||||
### 5. LND Not Synced
|
||||
|
||||
**Problem:** All RPC calls timeout
|
||||
**Solution:** Mine blocks to sync LND: `bitcoin-cli -generate 10`
|
||||
|
||||
### 6. Amount Not in Bech32
|
||||
|
||||
**Problem:** Trying to encode amount in the ndebit bech32 string
|
||||
**Solution:** Use query parameter: `?amount=1000`
|
||||
|
||||
### 7. Invalid Pointer in NDebit
|
||||
|
||||
**Problem:** Using custom session IDs or arbitrary strings as the ndebit pointer
|
||||
|
||||
```
|
||||
wallet >> user atm:ml2b5abxdppn58sty5 not found wallet
|
||||
DebitManager >> ERROR application user not found
|
||||
```
|
||||
|
||||
**Cause:** Lightning.Pub uses the `pointer` field to look up which account should pay. It must be a valid Lightning.Pub user identifier (e.g., `atm`), not a custom session string.
|
||||
|
||||
**Solution:** Use the ATM's Lightning.Pub account identifier as the pointer:
|
||||
|
||||
```typescript
|
||||
const pointer = 'atm' // NOT 'atm:sessionId123'
|
||||
```
|
||||
|
||||
Track sessions locally using amount-based matching instead.
|
||||
|
||||
### 8. Debit Requests Not Received
|
||||
|
||||
**Problem:** GetLiveDebitRequests subscription sent but no debit requests arrive
|
||||
|
||||
**Possible Causes:**
|
||||
|
||||
1. Wrong pubkey in ndebit (must be Lightning.Pub's pubkey)
|
||||
2. Subscription filter doesn't match (check `authors` and `#p` tags)
|
||||
3. Using SimplePool instead of direct Relay connection (see `packages/lightning/TROUBLESHOOTING.md`)
|
||||
4. Race condition - published before subscription was ready
|
||||
|
||||
**Solution:** Add verbose logging to trace the flow:
|
||||
|
||||
```typescript
|
||||
nostrClient.subscribe([...], {
|
||||
onEvent: (event) => {
|
||||
console.log('[Debit] Event received:', event.kind, event.id)
|
||||
// ...
|
||||
},
|
||||
onEose: () => {
|
||||
console.log('[Debit] EOSE received - subscription active')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 9. Missing Amount in Debit Request
|
||||
|
||||
**Problem:** `message.debit.amount_sats` is undefined
|
||||
|
||||
**Cause:** Lightning.Pub doesn't always include the amount in the forwarded debit request
|
||||
|
||||
**Solution:** Decode the amount from the BOLT11 invoice:
|
||||
|
||||
```typescript
|
||||
const amountSats = message.debit.amount_sats || decodeAmountFromBolt11(invoice)
|
||||
```
|
||||
|
||||
### 10. Double Debit (Same QR Used Twice)
|
||||
|
||||
**Problem:** User can claim the same ndebit QR multiple times
|
||||
|
||||
**Solution:** Implement session-based single-use protection:
|
||||
|
||||
1. Register each ndebit generation as a session with expected amount
|
||||
2. Match incoming requests against active sessions
|
||||
3. Mark session as `paid` **before** sending approval (atomic)
|
||||
4. Reject requests with no matching active session
|
||||
|
||||
See "Single-Use Protection" section above for implementation details.
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- [ ] Lightning.Pub health check returns OK
|
||||
- [ ] LND is synced (`synced_to_chain: true`)
|
||||
- [ ] ATM user exists and has balance (`fund-atm` command)
|
||||
- [ ] Relay is accessible from browser (`ws://localhost:7777`)
|
||||
|
||||
### NDebit QR Generation
|
||||
|
||||
- [ ] QR contains `clink:` prefix
|
||||
- [ ] QR contains `?amount=` parameter
|
||||
- [ ] NDebit encodes **Lightning.Pub's pubkey** (not ATM's)
|
||||
- [ ] NDebit pointer is valid account identifier (`atm`)
|
||||
- [ ] Relay URL is browser-accessible (not Docker-internal)
|
||||
|
||||
### Debit Approval Service
|
||||
|
||||
- [ ] Service starts: `[Debit] Starting debit approval service`
|
||||
- [ ] Subscription active: `[Debit] EOSE received`
|
||||
- [ ] Events received: `[Debit] Event received: {kind: 21000, ...}`
|
||||
- [ ] Decryption works: `[Debit] Decrypted message: {...}`
|
||||
|
||||
### Single-Use Protection
|
||||
|
||||
- [ ] First claim succeeds: `[Debit] SUCCESS: Approved debit request`
|
||||
- [ ] Second claim fails: `[Debit] REJECTED: No matching active session`
|
||||
- [ ] Session marked as paid after approval
|
||||
- [ ] Same invoice rejected: `[Debit] REJECTED: Invoice already approved`
|
||||
|
||||
### End-to-End
|
||||
|
||||
- [ ] ATM displays ndebit QR
|
||||
- [ ] Wallet scans and claims
|
||||
- [ ] Debit approved and payment received
|
||||
- [ ] State machine transitions to success
|
||||
- [ ] Repeated scan is rejected
|
||||
|
||||
## Dependencies
|
||||
|
||||
```json
|
||||
{
|
||||
"@lamassu/clink": "workspace:*",
|
||||
"nostr-tools": "^2.x.x",
|
||||
"@noble/hashes": "^1.x.x",
|
||||
"qrcode": "^1.x.x"
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [CLINK Protocol Spec](https://github.com/shocknet/clink)
|
||||
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
|
||||
- [NIP-19 (bech32 encoding)](https://github.com/nostr-protocol/nips/blob/master/19.md)
|
||||
- [BIP-21 (URI scheme)](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki)
|
||||
|
|
@ -1,828 +0,0 @@
|
|||
---
|
||||
title: Nostr-Native ATM Architecture
|
||||
created: 2026-01-22
|
||||
updated: 2026-01-22
|
||||
tags:
|
||||
- architecture
|
||||
- nostr
|
||||
- lightning-pub
|
||||
- kyc-free
|
||||
- decentralized
|
||||
status: active
|
||||
priority: critical
|
||||
---
|
||||
|
||||
# Nostr-Native ATM Architecture
|
||||
|
||||
> [!abstract] Summary
|
||||
> A radical rethinking of ATM infrastructure where **Nostr becomes the backbone** for identity, communication, and payments. Replaces traditional server infrastructure with Lightning.Pub and a private Nostr relay, eliminating KYC vectors like phone numbers while enabling a truly decentralized, censorship-resistant system.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [[#Vision: Nostr as Infrastructure]]
|
||||
- [[#Lightning.Pub as Core Server]]
|
||||
- [[#Private Relay Architecture]]
|
||||
- [[#Machine Identity]]
|
||||
- [[#Replacing SMS with Nostr]]
|
||||
- [[#Event Schema]]
|
||||
|
||||
---
|
||||
|
||||
## Vision: Nostr as Infrastructure
|
||||
|
||||
### The Problem with Traditional ATM Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ TRADITIONAL LAMASSU ARCHITECTURE │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ATM ──HTTPS/WSS──► Server ──► PostgreSQL │
|
||||
│ │ │
|
||||
│ ├──► SMS Gateway (Twilio) │
|
||||
│ ├──► Email Service │
|
||||
│ ├──► KYC Provider │
|
||||
│ └──► Lightning Node │
|
||||
│ │
|
||||
│ Problems: │
|
||||
│ • Phone numbers = KYC vector │
|
||||
│ • Complex server infrastructure │
|
||||
│ • DNS, SSL, port forwarding required │
|
||||
│ • Single point of failure │
|
||||
│ • Centralized command/control │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### The Nostr-Native Solution
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ NOSTR-NATIVE ATM ARCHITECTURE │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ ATM 1 │ │ ATM 2 │ │ ATM N │ │
|
||||
│ │ npub_1 │ │ npub_2 │ │ npub_n │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────┼────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌────────────────────────┐ │
|
||||
│ │ Private Nostr Relay │◄─── NIP-42 Auth │
|
||||
│ │ (strfry / rnostr) │ Whitelist: ATMs + │
|
||||
│ └───────────┬────────────┘ Operators only │
|
||||
│ │ │
|
||||
│ ┌───────────┼───────────┐ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │Lightning │ │ Operator │ │ Public │ │
|
||||
│ │ Pub │ │Dashboard │ │ Relays │ │
|
||||
│ │(LND wrap)│ │ (Vue 3) │ │(fallback)│ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
│ Benefits: │
|
||||
│ • No phone numbers (Nostr DMs instead) │
|
||||
│ • Zero server config (no DNS/SSL/ports) │
|
||||
│ • Decentralized communication │
|
||||
│ • Cryptographic machine identity │
|
||||
│ • Censorship-resistant │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lightning.Pub as Core Server
|
||||
|
||||
> [!decision] Lightning.Pub Replaces lamassu-server
|
||||
> A Nostr-native account system that wraps LND and eliminates traditional server complexity.
|
||||
|
||||
### Why Lightning.Pub?
|
||||
|
||||
| Aspect | Traditional Server | Lightning.Pub |
|
||||
|--------|-------------------|---------------|
|
||||
| Network config | DNS, SSL, ports, firewall | Zero (uses Nostr relays) |
|
||||
| Deployment | Complex | One-line install |
|
||||
| Communication | HTTPS/WebSocket | Nostr events (NIP-44 encrypted) |
|
||||
| Account system | Custom implementation | Built-in sublayers |
|
||||
| CLINK support | Must implement | Native |
|
||||
| Lightning | Separate integration | Wraps LND directly |
|
||||
|
||||
### One-Line Deployment
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
wget -qO- https://deploy.lightning.pub | bash
|
||||
|
||||
# macOS
|
||||
curl -fsSL https://deploy.lightning.pub | bash
|
||||
|
||||
# Everything confined to ~/lightning_pub/
|
||||
# No sudo, no root, no system changes
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Lightning.Pub
|
||||
├── LND (Lightning Network Daemon)
|
||||
│ └── Neutrino (SPV Bitcoin)
|
||||
├── Account System
|
||||
│ ├── Application Pools (operator level)
|
||||
│ └── User Accounts (ATM wallets)
|
||||
├── CLINK Native
|
||||
│ ├── noffer (static payment codes)
|
||||
│ └── ndebit (authorized payments)
|
||||
├── Nostr Communication
|
||||
│ └── NIP-44 encrypted events
|
||||
└── Optional: LNURL Bridge (legacy support)
|
||||
```
|
||||
|
||||
### What Lightning.Pub Gives Us
|
||||
|
||||
1. **No Port Forwarding** - Nostr relays handle all communication
|
||||
2. **Multi-User Accounts** - Each ATM gets its own account
|
||||
3. **CLINK Native** - Static payment codes work out of the box
|
||||
4. **Liquidity Management** - Auto-quotes from LSPs (Zeus, Voltage, Flashsats)
|
||||
5. **Watchdog Security** - Monitors for drainage attacks
|
||||
6. **Production Tested** - Years of real-world deployment
|
||||
|
||||
### Configuration for ATM Fleet
|
||||
|
||||
```bash
|
||||
# ~/lightning_pub/.env
|
||||
|
||||
# Private relay for machine communication
|
||||
NOSTR_RELAYS="wss://relay.youratm.company wss://nos.lol"
|
||||
|
||||
# Disable bootstrap peering for full sovereignty
|
||||
DISABLE_LIQUIDITY_PROVIDER=true
|
||||
|
||||
# Custom LNURL domain (optional, for legacy wallets)
|
||||
SERVICE_URL=https://ln.youratm.company
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Private Relay Architecture
|
||||
|
||||
> [!decision] Run a Restricted Nostr Relay
|
||||
> NIP-42 authenticated relay that only accepts events from known machines and operators.
|
||||
|
||||
### Why a Private Relay?
|
||||
|
||||
| Concern | Public Relay | Private Relay |
|
||||
|---------|--------------|---------------|
|
||||
| Who can read | Anyone | Whitelisted npubs only |
|
||||
| Who can write | Anyone | Whitelisted npubs only |
|
||||
| Machine commands | Exposed | Encrypted, restricted |
|
||||
| Fleet data | Public | Private |
|
||||
| Censorship | Relay can censor | You control |
|
||||
|
||||
### Relay Options
|
||||
|
||||
| Relay | Language | NIP-42 | Performance | Notes |
|
||||
|-------|----------|--------|-------------|-------|
|
||||
| **strfry** | C++ | Yes | Excellent | Plugin system, negentropy sync |
|
||||
| **rnostr** | Rust | Yes | Excellent | LMDB storage, inspired by strfry |
|
||||
| **nostr-rs-relay** | Rust | Yes | Good | SQLite/PostgreSQL |
|
||||
|
||||
### NIP-42 Authentication
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ NIP-42 AUTH FLOW │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ATM connects to relay │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Relay sends AUTH challenge │
|
||||
│ ["AUTH", "<random-challenge>"] │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ATM signs challenge with its nsec │
|
||||
│ { │
|
||||
│ "kind": 22242, │
|
||||
│ "tags": [ │
|
||||
│ ["relay", "wss://relay.youratm.company"], │
|
||||
│ ["challenge", "<random-challenge>"] │
|
||||
│ ], │
|
||||
│ "content": "", │
|
||||
│ "sig": "<signature>" │
|
||||
│ } │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Relay verifies npub is in whitelist │
|
||||
│ │ │
|
||||
│ ├── Yes → Connection allowed │
|
||||
│ └── No → Connection rejected │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### strfry Configuration
|
||||
|
||||
```toml
|
||||
# strfry.conf
|
||||
|
||||
[relay]
|
||||
bind = "0.0.0.0"
|
||||
port = 7777
|
||||
realIpHeader = "X-Forwarded-For"
|
||||
|
||||
[relay.info]
|
||||
name = "ATM Fleet Relay"
|
||||
description = "Private relay for ATM communication"
|
||||
contact = "operator@youratm.company"
|
||||
|
||||
# Require NIP-42 authentication
|
||||
authRequired = true
|
||||
|
||||
[relay.writePolicy]
|
||||
plugin = "./plugins/whitelist.js"
|
||||
|
||||
[relay.negentropy]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
### Whitelist Plugin (noteguard style)
|
||||
|
||||
```javascript
|
||||
// plugins/whitelist.js
|
||||
const ALLOWED_PUBKEYS = new Set([
|
||||
'npub1_atm_001...', // ATM 1
|
||||
'npub1_atm_002...', // ATM 2
|
||||
'npub1_operator...', // Operator
|
||||
])
|
||||
|
||||
export function writePolicy(event, sourceInfo) {
|
||||
if (!sourceInfo.authedPubkey) {
|
||||
return { action: 'reject', message: 'auth-required: authenticate first' }
|
||||
}
|
||||
|
||||
if (!ALLOWED_PUBKEYS.has(sourceInfo.authedPubkey)) {
|
||||
return { action: 'reject', message: 'restricted: not authorized' }
|
||||
}
|
||||
|
||||
return { action: 'accept' }
|
||||
}
|
||||
```
|
||||
|
||||
### NixOS Module for Relay
|
||||
|
||||
```nix
|
||||
# relay.nix
|
||||
{ config, pkgs, ... }:
|
||||
{
|
||||
services.strfry = {
|
||||
enable = true;
|
||||
settings = {
|
||||
relay = {
|
||||
bind = "127.0.0.1";
|
||||
port = 7777;
|
||||
info = {
|
||||
name = "ATM Fleet Relay";
|
||||
description = "Private NIP-42 authenticated relay";
|
||||
};
|
||||
authRequired = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Nginx reverse proxy with SSL
|
||||
services.nginx.virtualHosts."relay.youratm.company" = {
|
||||
enableACME = true;
|
||||
forceSSL = true;
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:7777";
|
||||
proxyWebsockets = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Machine Identity
|
||||
|
||||
> [!decision] Each ATM Has a Nostr Keypair
|
||||
> Hardware-bound identity that replaces certificates and enables cryptographic authentication.
|
||||
|
||||
### Identity Model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ MACHINE IDENTITY │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Traditional: │
|
||||
│ • Client certificate (complex PKI) │
|
||||
│ • API keys (can be leaked) │
|
||||
│ • IP-based auth (unreliable) │
|
||||
│ │
|
||||
│ Nostr-Native: │
|
||||
│ • Machine has nsec (private key) │
|
||||
│ • npub is machine identity │
|
||||
│ • All events signed by machine │
|
||||
│ • Operator whitelist controls access │
|
||||
│ • No certificate authority needed │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Generation & Storage
|
||||
|
||||
```typescript
|
||||
// Machine first boot - generate identity
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { writeFileSync } from 'fs'
|
||||
|
||||
function initMachineIdentity() {
|
||||
const nsec = generateSecretKey()
|
||||
const npub = getPublicKey(nsec)
|
||||
|
||||
// Store in secure location (TPM, encrypted file, etc.)
|
||||
writeFileSync('/etc/lamassu/machine.nsec', nsec, { mode: 0o600 })
|
||||
|
||||
console.log(`Machine identity: ${npub}`)
|
||||
console.log('Add this npub to operator whitelist')
|
||||
|
||||
return { nsec, npub }
|
||||
}
|
||||
```
|
||||
|
||||
### Secure Key Storage Options
|
||||
|
||||
| Method | Security | Complexity | Best For |
|
||||
|--------|----------|------------|----------|
|
||||
| Encrypted file | Medium | Low | Development |
|
||||
| TPM 2.0 | High | Medium | Production |
|
||||
| Secure enclave | Highest | High | High-security |
|
||||
| HSM | Highest | Highest | Enterprise |
|
||||
|
||||
### Tauri Integration
|
||||
|
||||
```rust
|
||||
// src-tauri/src/identity.rs
|
||||
use nostr_sdk::prelude::*;
|
||||
use std::fs;
|
||||
|
||||
pub struct MachineIdentity {
|
||||
keys: Keys,
|
||||
}
|
||||
|
||||
impl MachineIdentity {
|
||||
pub fn load_or_create() -> Result<Self, Error> {
|
||||
let nsec_path = "/etc/lamassu/machine.nsec";
|
||||
|
||||
let keys = if fs::metadata(nsec_path).is_ok() {
|
||||
// Load existing
|
||||
let nsec = fs::read_to_string(nsec_path)?;
|
||||
Keys::parse(&nsec)?
|
||||
} else {
|
||||
// Generate new
|
||||
let keys = Keys::generate();
|
||||
fs::write(nsec_path, keys.secret_key()?.to_bech32()?)?;
|
||||
keys
|
||||
};
|
||||
|
||||
Ok(Self { keys })
|
||||
}
|
||||
|
||||
pub fn npub(&self) -> String {
|
||||
self.keys.public_key().to_bech32().unwrap()
|
||||
}
|
||||
|
||||
pub fn sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
|
||||
event.sign(&self.keys)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Replacing SMS with Nostr
|
||||
|
||||
> [!decision] Nostr DMs Replace Phone-Based Messaging
|
||||
> No phone numbers = no KYC vector. Users provide npub for receipts.
|
||||
|
||||
### What SMS Was Used For (Old Lamassu)
|
||||
|
||||
| Use Case | Old Method | New Method |
|
||||
|----------|------------|------------|
|
||||
| Transaction receipt | SMS to phone | NIP-17 DM to npub |
|
||||
| Verification code | SMS OTP | Not needed (no KYC) |
|
||||
| Operator alerts | SMS/Email | Nostr events to operator npub |
|
||||
| Customer notifications | SMS | Optional NIP-17 DM |
|
||||
|
||||
### NIP-17 Private Direct Messages
|
||||
|
||||
```typescript
|
||||
// Send encrypted receipt to user
|
||||
import { nip44, nip59 } from 'nostr-tools'
|
||||
|
||||
async function sendReceipt(
|
||||
userNpub: string,
|
||||
receipt: TransactionReceipt
|
||||
) {
|
||||
const content = JSON.stringify({
|
||||
type: 'transaction_receipt',
|
||||
txid: receipt.txid,
|
||||
amount: receipt.amountSats,
|
||||
timestamp: receipt.timestamp,
|
||||
atmId: receipt.atmNpub,
|
||||
})
|
||||
|
||||
// NIP-17: Encrypted gift-wrapped message
|
||||
const sealedEvent = await nip59.seal(
|
||||
machineKeys,
|
||||
userNpub,
|
||||
{
|
||||
kind: 14, // Direct message
|
||||
content,
|
||||
tags: [],
|
||||
}
|
||||
)
|
||||
|
||||
// Publish to relay
|
||||
await relay.publish(sealedEvent)
|
||||
}
|
||||
```
|
||||
|
||||
### User Flow (Optional Receipt)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ OPTIONAL RECEIPT FLOW │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. User completes transaction │
|
||||
│ │
|
||||
│ 2. ATM asks: "Want a receipt?" │
|
||||
│ [No Thanks] [Yes, via Nostr] │
|
||||
│ │
|
||||
│ 3. If yes, user provides npub: │
|
||||
│ • Scan NFC card with npub │
|
||||
│ • Scan QR code of npub │
|
||||
│ • Type npub manually │
|
||||
│ │
|
||||
│ 4. ATM sends NIP-17 encrypted DM │
|
||||
│ • Only user can decrypt │
|
||||
│ • Contains: amount, txid, timestamp │
|
||||
│ • No phone number collected! │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Operator Alerts via Nostr
|
||||
|
||||
```typescript
|
||||
// Machine publishes alert event
|
||||
async function sendOperatorAlert(
|
||||
alertType: 'low_cash' | 'error' | 'offline',
|
||||
details: object
|
||||
) {
|
||||
const event = {
|
||||
kind: 30078, // Replaceable application-specific
|
||||
pubkey: machineNpub,
|
||||
content: nip44.encrypt(
|
||||
machineNsec,
|
||||
operatorNpub,
|
||||
JSON.stringify({
|
||||
type: alertType,
|
||||
machineId: machineNpub,
|
||||
timestamp: Date.now(),
|
||||
details,
|
||||
})
|
||||
),
|
||||
tags: [
|
||||
['d', `alert:${machineNpub}`], // Replaceable identifier
|
||||
['p', operatorNpub],
|
||||
],
|
||||
}
|
||||
|
||||
await relay.publish(signEvent(event, machineNsec))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Schema
|
||||
|
||||
> [!tip] Custom Event Kinds for ATM Operations
|
||||
> Define application-specific events for machine status, transactions, and commands.
|
||||
|
||||
### Event Kinds
|
||||
|
||||
| Kind | Type | Description |
|
||||
|------|------|-------------|
|
||||
| 21001 | CLINK | Offer Request/Response |
|
||||
| 21002 | CLINK | Debit Request/Response |
|
||||
| 21003 | CLINK | Management Delegation |
|
||||
| 30078 | Replaceable | Machine Status |
|
||||
| 30079 | Replaceable | Cash Levels |
|
||||
| 14 | NIP-17 | Encrypted Receipt DM |
|
||||
| 22242 | Ephemeral | NIP-42 Auth |
|
||||
|
||||
### Machine Status Event (Kind 30078)
|
||||
|
||||
```typescript
|
||||
interface MachineStatusEvent {
|
||||
kind: 30078
|
||||
pubkey: string // Machine npub
|
||||
content: string // NIP-44 encrypted JSON
|
||||
tags: [
|
||||
['d', 'status'], // Replaceable identifier
|
||||
['p', string], // Operator npub
|
||||
]
|
||||
}
|
||||
|
||||
// Decrypted content:
|
||||
interface MachineStatus {
|
||||
online: boolean
|
||||
lastTransaction: number // timestamp
|
||||
cashLevels: {
|
||||
validator: number // bills in validator
|
||||
dispenser: CassetteLevel[]
|
||||
}
|
||||
errors: string[]
|
||||
version: string
|
||||
}
|
||||
```
|
||||
|
||||
### Transaction Record Event
|
||||
|
||||
```typescript
|
||||
interface TransactionEvent {
|
||||
kind: 30079
|
||||
pubkey: string // Machine npub
|
||||
content: string // NIP-44 encrypted
|
||||
tags: [
|
||||
['d', `tx:${txid}`],
|
||||
['p', string], // Operator npub
|
||||
]
|
||||
}
|
||||
|
||||
// Decrypted content:
|
||||
interface TransactionRecord {
|
||||
txid: string
|
||||
type: 'cash_in' | 'cash_out'
|
||||
amountFiat: number
|
||||
amountSats: number
|
||||
fee: number
|
||||
timestamp: number
|
||||
paymentMethod: 'lnurl_withdraw' | 'clink_offer' | 'invoice' | 'cashu'
|
||||
// No user identity stored!
|
||||
}
|
||||
```
|
||||
|
||||
### Operator Command Event
|
||||
|
||||
```typescript
|
||||
interface CommandEvent {
|
||||
kind: 21003 // CLINK manage
|
||||
pubkey: string // Operator npub
|
||||
content: string // NIP-44 encrypted
|
||||
tags: [
|
||||
['p', string], // Target machine npub
|
||||
]
|
||||
}
|
||||
|
||||
// Decrypted content:
|
||||
interface OperatorCommand {
|
||||
command: 'restart' | 'update' | 'disable' | 'enable' | 'set_limits'
|
||||
params?: object
|
||||
timestamp: number
|
||||
signature: string // Operator signs command
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Full Stack Architecture
|
||||
|
||||
### Component Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ NOSTR-NATIVE ATM STACK │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ATM MACHINE │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │
|
||||
│ │ │ Vue 3 UI │ │ XState v5 │ │ Rust HAL │ │ │
|
||||
│ │ │ (Tauri) │ │ (State) │ │ (Bill/Dispense) │ │ │
|
||||
│ │ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ ┌──────┴────────────────┴─────────────────────┴──────────┐ │ │
|
||||
│ │ │ Nostr Client │ │ │
|
||||
│ │ │ • Machine nsec/npub identity │ │ │
|
||||
│ │ │ • CLINK SDK for payments │ │ │
|
||||
│ │ │ • NIP-44 encryption │ │ │
|
||||
│ │ │ • Event publishing/subscription │ │ │
|
||||
│ │ └────────────────────────┬───────────────────────────────┘ │ │
|
||||
│ └───────────────────────────┼──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ PRIVATE NOSTR RELAY │ │
|
||||
│ │ • strfry / rnostr │ │
|
||||
│ │ • NIP-42 authentication required │ │
|
||||
│ │ • Whitelist: ATM npubs + Operator npubs │ │
|
||||
│ │ • Negentropy sync for offline reconciliation │ │
|
||||
│ └────────────────────────────┬──────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────┼────────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────────┐ │
|
||||
│ │ Lightning.Pub │ │ Operator │ │ Public Relays │ │
|
||||
│ │ │ │ Dashboard │ │ (Fallback) │ │
|
||||
│ │ • LND node │ │ │ │ │ │
|
||||
│ │ • Accounts │ │ • Vue 3 app │ │ • nos.lol │ │
|
||||
│ │ • CLINK native│ │ • Subscribe │ │ • relay.damus.io │ │
|
||||
│ │ • Liquidity │ │ to events │ │ • For CLINK with │ │
|
||||
│ └───────────────┘ │ • Send cmds │ │ external users │ │
|
||||
│ └───────────────┘ └───────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Data Flow: Cash-In Transaction
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant ATM
|
||||
participant Relay as Private Relay
|
||||
participant LPub as Lightning.Pub
|
||||
participant LN as Lightning Network
|
||||
|
||||
User->>ATM: Insert $50 cash
|
||||
ATM->>ATM: Validate bills (HAL)
|
||||
ATM->>Relay: Publish status event
|
||||
|
||||
ATM->>ATM: Generate CLINK offer (variable price)
|
||||
ATM->>ATM: Display QR code
|
||||
|
||||
User->>User: Scan with ShockWallet
|
||||
User->>Relay: CLINK offer request (Kind 21001)
|
||||
Relay->>ATM: Forward request
|
||||
|
||||
ATM->>LPub: Request invoice (amount calculated)
|
||||
LPub->>ATM: BOLT11 invoice
|
||||
ATM->>Relay: CLINK response with invoice
|
||||
Relay->>User: Forward response
|
||||
|
||||
User->>LN: Pay invoice
|
||||
LN->>LPub: Payment received
|
||||
LPub->>Relay: Payment confirmation event
|
||||
Relay->>ATM: Forward confirmation
|
||||
|
||||
ATM->>ATM: Transaction complete
|
||||
ATM->>Relay: Publish transaction record
|
||||
|
||||
opt User provided npub
|
||||
ATM->>Relay: Send NIP-17 receipt DM
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Add Nostr Layer
|
||||
|
||||
```
|
||||
Existing Lamassu ──► Add Nostr client
|
||||
Add private relay
|
||||
Keep existing server (parallel)
|
||||
```
|
||||
|
||||
### Phase 2: Lightning.Pub Integration
|
||||
|
||||
```
|
||||
Add Lightning.Pub ──► Route payments through LPub
|
||||
CLINK offers enabled
|
||||
Account system active
|
||||
```
|
||||
|
||||
### Phase 3: Full Migration
|
||||
|
||||
```
|
||||
Remove old server ──► Nostr-only communication
|
||||
NIP-17 receipts (no SMS)
|
||||
Private relay primary
|
||||
```
|
||||
|
||||
### Phase 4: Optional Enhancements
|
||||
|
||||
```
|
||||
Advanced features ──► Cashu ecash integration
|
||||
Fedimint support
|
||||
Multi-relay redundancy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Threat Model
|
||||
|
||||
| Threat | Mitigation |
|
||||
|--------|------------|
|
||||
| Relay compromise | NIP-44 encryption (relay can't read) |
|
||||
| Key theft | TPM/HSM storage, key rotation |
|
||||
| Replay attacks | Timestamps, nonces in events |
|
||||
| Rogue operator | Multi-sig commands (future) |
|
||||
| Network sniffing | WebSocket over TLS, NIP-44 |
|
||||
|
||||
### Key Rotation
|
||||
|
||||
```typescript
|
||||
// Periodic key rotation for machines
|
||||
async function rotateMachineKey(oldNsec: string) {
|
||||
const newKeys = generateKeys()
|
||||
|
||||
// Publish key rotation event (signed by old key)
|
||||
const rotationEvent = {
|
||||
kind: 30078,
|
||||
content: nip44.encrypt(oldNsec, operatorNpub, JSON.stringify({
|
||||
type: 'key_rotation',
|
||||
oldPubkey: getPublicKey(oldNsec),
|
||||
newPubkey: newKeys.npub,
|
||||
timestamp: Date.now(),
|
||||
})),
|
||||
tags: [
|
||||
['d', 'key_rotation'],
|
||||
['p', operatorNpub],
|
||||
],
|
||||
}
|
||||
|
||||
await relay.publish(signEvent(rotationEvent, oldNsec))
|
||||
|
||||
// Operator must update whitelist
|
||||
// Then switch to new key
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison: Old vs Nostr-Native
|
||||
|
||||
| Aspect | Old Lamassu | Nostr-Native |
|
||||
|--------|-------------|--------------|
|
||||
| Communication | HTTPS/WebSocket | Nostr events |
|
||||
| Authentication | Client certs | NIP-42 + npub whitelist |
|
||||
| Encryption | TLS | NIP-44 (content-level) |
|
||||
| Identity | PKI certificates | Nostr keypairs |
|
||||
| Receipts | SMS (phone = KYC) | NIP-17 DMs (npub) |
|
||||
| Alerts | Email/SMS | Nostr events |
|
||||
| Server config | DNS, SSL, ports | Zero config |
|
||||
| Deployment | Complex | One-line |
|
||||
| Censorship | Server can be seized | Relay-agnostic |
|
||||
| Privacy | Phone numbers leaked | Pseudonymous npubs |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Relay redundancy** - Should machines connect to multiple relays?
|
||||
2. **Offline operation** - How long can machine operate without relay?
|
||||
3. **Key escrow** - How to recover if machine key is lost?
|
||||
4. **Multi-operator** - Can multiple operators share a fleet?
|
||||
5. **Cashu over Nostr** - Use Nostr for ecash token delivery?
|
||||
|
||||
---
|
||||
|
||||
## Related Notes
|
||||
|
||||
- [[architecture-review]] - Overall KYC-free architecture
|
||||
- [[lnbits-integration]] - LNbits as alternative backend
|
||||
- [[hardware-recommendations]] - Hardware choices
|
||||
- [[machine-ui-modernization]] - Vue 3 UI migration
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
### Lightning.Pub
|
||||
- [Lightning.Pub GitHub](https://github.com/shocknet/Lightning.Pub)
|
||||
- [ShockWallet](https://github.com/shocknet/wallet2)
|
||||
- [CLINK Protocol](https://github.com/shocknet/CLINK)
|
||||
|
||||
### Nostr Relays
|
||||
- [strfry](https://github.com/hoytech/strfry)
|
||||
- [rnostr](https://github.com/rnostr/rnostr)
|
||||
- [nostr-rs-relay](https://sr.ht/~gheartsfield/nostr-rs-relay/)
|
||||
- [noteguard](https://github.com/damus-io/noteguard) - strfry plugin system
|
||||
|
||||
### NIPs
|
||||
- [NIP-42: Authentication](https://github.com/nostr-protocol/nips/blob/master/42.md)
|
||||
- [NIP-44: Versioned Encryption](https://github.com/paulmillr/nip44)
|
||||
- [NIP-17: Private Direct Messages](https://nips.nostr.com/17)
|
||||
- [NIP-59: Gift Wraps](https://github.com/nostr-protocol/nips/blob/master/59.md)
|
||||
Loading…
Add table
Add a link
Reference in a new issue