diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..1ae1e5f --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,1617 @@ +--- +title: Implementation Plan - Nostr-Native Lightning ATM +created: 2026-01-22 +updated: 2026-01-22 +tags: + - implementation + - roadmap + - development + - nostr + - lightning +status: active +priority: critical +--- + +# Implementation Plan: Nostr-Native Lightning ATM + +> [!abstract] Summary +> Concrete implementation plan for building the Nostr-native Lightning ATM from scratch. Organized in phases with clear dependencies, deliverables, and testable milestones. + +## Quick Links + +- [[#Phase 0: Foundation]] +- [[#Phase 1: Core Infrastructure]] +- [[#Phase 2: Machine Shell]] +- [[#Phase 3: Hardware Abstraction]] +- [[#Phase 4: Payment Flows]] +- [[#Phase 5: Operator Tools]] +- [[#Monorepo Structure]] + +--- + +## Guiding Principles + +> [!important] Build Philosophy +> 1. **Vertical slices** - Each phase delivers working functionality +> 2. **Test on real hardware early** - Don't wait until "everything is ready" +> 3. **Mock what you must** - But prefer real components when possible +> 4. **Ship incrementally** - Working ATM with 1 bill validator > perfect ATM with 10 + +--- + +## Monorepo Structure + +``` +lamassu-next/ +├── .github/ +│ └── workflows/ # CI/CD +├── apps/ +│ ├── machine/ # Tauri + Vue 3 ATM application +│ │ ├── src/ # Vue 3 frontend +│ │ ├── src-tauri/ # Rust backend +│ │ └── package.json +│ ├── dashboard/ # Vue 3 operator dashboard +│ │ ├── src/ +│ │ └── package.json +│ └── relay/ # Private Nostr relay config +│ └── strfry.conf +├── packages/ +│ ├── hal/ # Rust Hardware Abstraction Layer +│ │ ├── src/ +│ │ │ ├── lib.rs +│ │ │ ├── validators/ # Bill validators +│ │ │ ├── dispensers/ # Bill dispensers +│ │ │ ├── printer/ # Receipt printer +│ │ │ └── nfc/ # NFC reader +│ │ ├── Cargo.toml +│ │ └── index.node # napi-rs output +│ ├── nostr-client/ # Shared Nostr client +│ │ ├── src/ +│ │ │ ├── identity.ts +│ │ │ ├── relay.ts +│ │ │ ├── events.ts +│ │ │ └── encryption.ts +│ │ └── package.json +│ ├── clink/ # CLINK SDK wrapper +│ │ ├── src/ +│ │ └── package.json +│ ├── state-machine/ # XState v5 machine logic +│ │ ├── src/ +│ │ │ ├── machines/ +│ │ │ │ ├── atm.ts +│ │ │ │ ├── cashIn.ts +│ │ │ │ └── cashOut.ts +│ │ │ └── index.ts +│ │ └── package.json +│ └── ui-shared/ # Shared Vue components +│ ├── src/ +│ └── package.json +├── devenv.nix # Development environment +├── devenv.yaml +├── flake.nix # Nix flake for builds +├── flake.lock +├── pnpm-workspace.yaml +├── package.json +└── turbo.json # Turborepo config +``` + +--- + +## Phase 0: Foundation + +> [!goal] Deliverable +> Reproducible development environment with all tooling ready. + +### 0.1 Initialize Monorepo + +```bash +# Create new repo (or restructure existing) +mkdir lamassu-next && cd lamassu-next +pnpm init +pnpm add -Dw turbo typescript @types/node +``` + +**turbo.json:** +```json +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "test": { + "dependsOn": ["build"] + }, + "lint": {} + } +} +``` + +**pnpm-workspace.yaml:** +```yaml +packages: + - 'apps/*' + - 'packages/*' +``` + +### 0.2 Development Environment (devenv.nix) + +```nix +# devenv.nix +{ pkgs, lib, ... }: + +{ + # Languages + languages.javascript = { + enable = true; + package = pkgs.nodejs_22; + pnpm = { + enable = true; + install.enable = true; + }; + }; + + languages.typescript.enable = true; + + languages.rust = { + enable = true; + channel = "stable"; + components = [ "rustc" "cargo" "clippy" "rustfmt" "rust-analyzer" ]; + }; + + # Services + services.postgres = { + enable = true; + initialDatabases = [{ name = "lamassu_dev"; }]; + listen_addresses = "127.0.0.1"; + }; + + # Packages + packages = with pkgs; [ + # Build tools + pkg-config + openssl + + # Tauri dependencies + gtk3 + webkitgtk + libappindicator-gtk3 + + # Hardware + libusb1 + libnfc + + # Nostr tools + nak # Nostr army knife CLI + + # Development + just # Task runner + jq + ]; + + # Pre-commit hooks + pre-commit.hooks = { + prettier.enable = true; + eslint.enable = true; + rustfmt.enable = true; + clippy.enable = true; + }; + + # Environment variables + env = { + RUST_BACKTRACE = "1"; + DATABASE_URL = "postgresql://localhost/lamassu_dev"; + }; + + # Scripts + scripts = { + dev.exec = "pnpm turbo dev"; + build.exec = "pnpm turbo build"; + test.exec = "pnpm turbo test"; + }; + + enterShell = '' + echo "🚀 Lamassu development environment" + echo " Node.js: $(node --version)" + echo " Rust: $(rustc --version)" + echo "" + echo "Commands: dev, build, test" + ''; +} +``` + +### 0.3 Lightning.Pub Local Setup + +```bash +# Option A: Docker for development +docker run -d \ + --name lightning-pub-dev \ + -p 9735:9735 \ + -v ~/lightning_pub_dev:/root/lightning_pub \ + ghcr.io/shocknet/lightning-pub:latest + +# Option B: Native install (Linux/macOS) +wget -qO- https://deploy.lightning.pub | bash +``` + +### 0.4 Private Relay Setup + +```bash +# Using strfry in Docker for dev +docker run -d \ + --name strfry-dev \ + -p 7777:7777 \ + -v ./apps/relay/strfry.conf:/etc/strfry.conf \ + ghcr.io/hoytech/strfry:latest +``` + +**apps/relay/strfry.conf:** +``` +relay { + bind = "0.0.0.0" + port = 7777 + + info { + name = "Lamassu Dev Relay" + description = "Development relay for ATM testing" + } + + # Require auth in production + # authRequired = true +} +``` + +### 0.5 Milestone Checklist + +- [ ] `pnpm install` works +- [ ] `devenv shell` enters environment +- [ ] Lightning.Pub running, can create wallet +- [ ] strfry relay running, can publish events +- [ ] `nak` CLI can interact with relay + +--- + +## Phase 1: Core Infrastructure + +> [!goal] Deliverable +> Nostr client library and machine identity system. + +### 1.1 Nostr Client Package + +**packages/nostr-client/src/index.ts:** +```typescript +export * from './identity' +export * from './relay' +export * from './events' +export * from './encryption' +``` + +**packages/nostr-client/src/identity.ts:** +```typescript +import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools' +import { readFileSync, writeFileSync, existsSync } from 'fs' + +export interface MachineIdentity { + nsec: Uint8Array + npub: string + npubBech32: string +} + +export function loadOrCreateIdentity(path: string): MachineIdentity { + if (existsSync(path)) { + const nsecBech32 = readFileSync(path, 'utf-8').trim() + const { data: nsec } = nip19.decode(nsecBech32) + const npub = getPublicKey(nsec as Uint8Array) + return { + nsec: nsec as Uint8Array, + npub, + npubBech32: nip19.npubEncode(npub), + } + } + + const nsec = generateSecretKey() + const npub = getPublicKey(nsec) + const nsecBech32 = nip19.nsecEncode(nsec) + + writeFileSync(path, nsecBech32, { mode: 0o600 }) + + return { + nsec, + npub, + npubBech32: nip19.npubEncode(npub), + } +} +``` + +**packages/nostr-client/src/relay.ts:** +```typescript +import { Relay, finalizeEvent, type Event } from 'nostr-tools' +import type { MachineIdentity } from './identity' + +export class ATMRelayClient { + private relay: Relay | null = null + private identity: MachineIdentity + + constructor( + private relayUrl: string, + identity: MachineIdentity + ) { + this.identity = identity + } + + async connect(): Promise { + this.relay = await Relay.connect(this.relayUrl) + console.log(`Connected to ${this.relayUrl}`) + + // NIP-42 auth will be handled automatically if required + } + + async publish(event: Partial): Promise { + if (!this.relay) throw new Error('Not connected') + + const signed = finalizeEvent( + { + kind: event.kind!, + content: event.content || '', + tags: event.tags || [], + created_at: Math.floor(Date.now() / 1000), + }, + this.identity.nsec + ) + + await this.relay.publish(signed) + } + + subscribe( + filters: object[], + onEvent: (event: Event) => void + ): () => void { + if (!this.relay) throw new Error('Not connected') + + const sub = this.relay.subscribe(filters, { + onevent: onEvent, + }) + + return () => sub.close() + } + + async disconnect(): Promise { + this.relay?.close() + this.relay = null + } +} +``` + +**packages/nostr-client/src/events.ts:** +```typescript +// ATM-specific event kinds +export const EVENT_KINDS = { + // CLINK + CLINK_OFFER: 21001, + CLINK_DEBIT: 21002, + CLINK_MANAGE: 21003, + + // Machine status (replaceable) + MACHINE_STATUS: 30078, + TRANSACTION_RECORD: 30079, + + // Encrypted DMs + PRIVATE_DM: 14, + + // Auth + NIP42_AUTH: 22242, +} as const + +export interface MachineStatusContent { + online: boolean + version: string + lastTransaction?: number + cashLevels: { + validator: number + dispenserCassettes: Array<{ + denomination: number + count: number + }> + } + errors: string[] +} + +export interface TransactionContent { + txid: string + type: 'cash_in' | 'cash_out' + amountFiat: number + amountSats: number + currency: string + fee: number + timestamp: number + paymentMethod: 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu' +} +``` + +### 1.2 CLINK Integration + +**packages/clink/src/index.ts:** +```typescript +import { nip44 } from 'nostr-tools' +import type { MachineIdentity } from '@lamassu/nostr-client' + +export interface CLINKOffer { + pubkey: string + relays: string[] + priceType: 'fixed' | 'variable' | 'spontaneous' + amount?: number // For fixed price + description?: string +} + +export function createOffer( + identity: MachineIdentity, + relays: string[], + priceType: CLINKOffer['priceType'], + amount?: number +): string { + // Encode as noffer1... bech32 string + const data = { + pubkey: identity.npub, + relays, + priceType, + amount, + } + + // TLV encoding for noffer + return encodeNoffer(data) +} + +export async function handleOfferRequest( + request: any, + identity: MachineIdentity, + generateInvoice: (amountMsat: number) => Promise +): Promise { + const amountMsat = request.amount || calculateAmount(request) + const invoice = await generateInvoice(amountMsat) + + // Create encrypted response + const response = nip44.encrypt( + identity.nsec, + request.senderPubkey, + JSON.stringify({ invoice }) + ) + + return response +} + +function encodeNoffer(data: object): string { + // TODO: Implement proper TLV encoding per CLINK spec + const json = JSON.stringify(data) + const bytes = new TextEncoder().encode(json) + // bech32 encode with 'noffer' prefix + return `noffer1${Buffer.from(bytes).toString('hex')}` +} + +function calculateAmount(request: any): number { + // Calculate based on fiat amount and exchange rate + return request.fiatAmount * request.satsPerUnit +} +``` + +### 1.3 Milestone Checklist + +- [ ] `@lamassu/nostr-client` package builds +- [ ] Can generate machine identity +- [ ] Can connect to strfry relay +- [ ] Can publish and receive events +- [ ] `@lamassu/clink` package builds +- [ ] Can create noffer string +- [ ] Integration test: publish event, receive on another client + +--- + +## Phase 2: Machine Shell (Tauri + Vue 3) + +> [!goal] Deliverable +> ATM application shell that can display UI and communicate via Nostr. + +### 2.1 Initialize Tauri App + +```bash +cd apps/machine +pnpm create tauri-app . --template vue-ts +pnpm add @lamassu/nostr-client @lamassu/clink @lamassu/state-machine +pnpm add @vueuse/core pinia xstate @xstate/vue +``` + +### 2.2 Vue 3 App Structure + +``` +apps/machine/src/ +├── App.vue +├── main.ts +├── assets/ +├── components/ +│ ├── ui/ # shadcn-vue components +│ │ ├── Button.vue +│ │ ├── Card.vue +│ │ └── ... +│ ├── CashInScreen.vue +│ ├── CashOutScreen.vue +│ ├── IdleScreen.vue +│ ├── QRDisplay.vue +│ └── NumPad.vue +├── composables/ +│ ├── useNostr.ts +│ ├── useHardware.ts +│ └── useMachine.ts +├── stores/ +│ ├── transaction.ts +│ └── machine.ts +└── machines/ + └── atm.ts # XState machine +``` + +### 2.3 XState Machine + +**apps/machine/src/machines/atm.ts:** +```typescript +import { setup, assign } from 'xstate' + +interface ATMContext { + // Transaction + fiatAmount: number + satsAmount: number + currency: string + + // Payment + invoice: string | null + clinkOffer: string | null + paymentStatus: 'pending' | 'paid' | 'failed' | null + + // Hardware + billsInserted: number[] + cashDispensed: boolean + + // User + userNpub: string | null + + // Errors + error: string | null +} + +type ATMEvent = + | { type: 'SELECT_CASH_IN' } + | { type: 'SELECT_CASH_OUT' } + | { type: 'CANCEL' } + | { type: 'BILL_INSERTED'; denomination: number } + | { type: 'FINISH_INSERTING' } + | { type: 'PAYMENT_RECEIVED' } + | { type: 'PAYMENT_FAILED'; error: string } + | { type: 'CASH_DISPENSED' } + | { type: 'USER_SCANNED_NPUB'; npub: string } + | { type: 'TIMEOUT' } + +export const atmMachine = setup({ + types: { + context: {} as ATMContext, + events: {} as ATMEvent, + }, + actions: { + resetContext: assign({ + fiatAmount: 0, + satsAmount: 0, + invoice: null, + clinkOffer: null, + paymentStatus: null, + billsInserted: [], + cashDispensed: false, + userNpub: null, + error: null, + }), + addBill: assign({ + billsInserted: ({ context, event }) => { + if (event.type !== 'BILL_INSERTED') return context.billsInserted + return [...context.billsInserted, event.denomination] + }, + fiatAmount: ({ context, event }) => { + if (event.type !== 'BILL_INSERTED') return context.fiatAmount + return context.fiatAmount + event.denomination + }, + }), + calculateSats: assign({ + satsAmount: ({ context }) => { + // TODO: Get exchange rate + const rate = 100_000 // sats per dollar (example) + const fee = 0.02 // 2% + return Math.floor(context.fiatAmount * rate * (1 - fee)) + }, + }), + setUserNpub: assign({ + userNpub: ({ event }) => { + if (event.type !== 'USER_SCANNED_NPUB') return null + return event.npub + }, + }), + }, + guards: { + hasInsertedBills: ({ context }) => context.billsInserted.length > 0, + hasSufficientAmount: ({ context }) => context.fiatAmount >= 1, + }, +}).createMachine({ + id: 'atm', + initial: 'idle', + context: { + fiatAmount: 0, + satsAmount: 0, + currency: 'USD', + invoice: null, + clinkOffer: null, + paymentStatus: null, + billsInserted: [], + cashDispensed: false, + userNpub: null, + error: null, + }, + states: { + idle: { + entry: 'resetContext', + on: { + SELECT_CASH_IN: 'cashIn', + SELECT_CASH_OUT: 'cashOut', + }, + }, + + // === CASH IN (Buy Bitcoin) === + cashIn: { + initial: 'insertingBills', + states: { + insertingBills: { + on: { + BILL_INSERTED: { + actions: ['addBill'], + }, + FINISH_INSERTING: { + guard: 'hasInsertedBills', + target: 'calculatingAmount', + }, + CANCEL: '#atm.idle', + }, + }, + calculatingAmount: { + entry: 'calculateSats', + always: 'generatingOffer', + }, + generatingOffer: { + invoke: { + src: 'generateClinkOffer', + onDone: { + target: 'displayingQR', + actions: assign({ + clinkOffer: ({ event }) => event.output, + }), + }, + onError: { + target: 'error', + actions: assign({ + error: ({ event }) => event.error.message, + }), + }, + }, + }, + displayingQR: { + on: { + PAYMENT_RECEIVED: 'askForReceipt', + TIMEOUT: '#atm.idle', + CANCEL: '#atm.idle', + }, + }, + askForReceipt: { + on: { + USER_SCANNED_NPUB: { + actions: 'setUserNpub', + target: 'sendingReceipt', + }, + CANCEL: 'complete', // Skip receipt + }, + }, + sendingReceipt: { + invoke: { + src: 'sendNostrReceipt', + onDone: 'complete', + onError: 'complete', // Don't fail transaction for receipt + }, + }, + complete: { + after: { + 3000: '#atm.idle', + }, + }, + error: { + on: { + CANCEL: '#atm.idle', + }, + }, + }, + }, + + // === CASH OUT (Sell Bitcoin) === + cashOut: { + initial: 'selectingAmount', + states: { + selectingAmount: { + on: { + SELECT_AMOUNT: { + target: 'generatingInvoice', + actions: assign({ + fiatAmount: ({ event }) => event.amount, + }), + }, + CANCEL: '#atm.idle', + }, + }, + generatingInvoice: { + invoke: { + src: 'generateInvoice', + onDone: { + target: 'awaitingPayment', + actions: assign({ + invoice: ({ event }) => event.output, + }), + }, + onError: { + target: 'error', + }, + }, + }, + awaitingPayment: { + on: { + PAYMENT_RECEIVED: 'dispensingCash', + TIMEOUT: '#atm.idle', + CANCEL: '#atm.idle', + }, + }, + dispensingCash: { + invoke: { + src: 'dispenseCash', + onDone: 'complete', + onError: 'error', + }, + }, + complete: { + after: { + 3000: '#atm.idle', + }, + }, + error: { + on: { + CANCEL: '#atm.idle', + }, + }, + }, + }, + }, +}) +``` + +### 2.4 Nostr Composable + +**apps/machine/src/composables/useNostr.ts:** +```typescript +import { ref, onMounted, onUnmounted } from 'vue' +import { ATMRelayClient, loadOrCreateIdentity } from '@lamassu/nostr-client' +import { invoke } from '@tauri-apps/api/core' + +const RELAY_URL = import.meta.env.VITE_RELAY_URL || 'ws://localhost:7777' +const IDENTITY_PATH = '/etc/lamassu/machine.nsec' + +export function useNostr() { + const connected = ref(false) + const identity = ref | null>(null) + let client: ATMRelayClient | null = null + + onMounted(async () => { + // Load identity (via Tauri for secure storage) + const nsecPath = await invoke('get_identity_path') + identity.value = loadOrCreateIdentity(nsecPath) + + // Connect to relay + client = new ATMRelayClient(RELAY_URL, identity.value) + await client.connect() + connected.value = true + + // Publish online status + await publishStatus({ online: true }) + }) + + onUnmounted(async () => { + await publishStatus({ online: false }) + await client?.disconnect() + }) + + async function publishStatus(status: object) { + if (!client || !identity.value) return + + await client.publish({ + kind: 30078, + content: JSON.stringify(status), + tags: [['d', 'status']], + }) + } + + async function publishTransaction(tx: object) { + if (!client || !identity.value) return + + await client.publish({ + kind: 30079, + content: JSON.stringify(tx), + tags: [['d', `tx:${tx.txid}`]], + }) + } + + function subscribeToCommands(onCommand: (cmd: object) => void) { + if (!client || !identity.value) return () => {} + + return client.subscribe( + [{ kinds: [21003], '#p': [identity.value.npub] }], + (event) => { + // Decrypt and handle command + const cmd = JSON.parse(event.content) // TODO: decrypt + onCommand(cmd) + } + ) + } + + return { + connected, + identity, + publishStatus, + publishTransaction, + subscribeToCommands, + } +} +``` + +### 2.5 Tauri Commands + +**apps/machine/src-tauri/src/lib.rs:** +```rust +use std::path::PathBuf; + +#[tauri::command] +fn get_identity_path() -> String { + // In production, use secure location + if cfg!(debug_assertions) { + dirs::home_dir() + .unwrap() + .join(".lamassu/machine.nsec") + .to_string_lossy() + .to_string() + } else { + "/etc/lamassu/machine.nsec".to_string() + } +} + +#[tauri::command] +async fn get_exchange_rate(currency: String) -> Result { + // TODO: Fetch from price feed + Ok(100_000.0) // sats per dollar +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .invoke_handler(tauri::generate_handler![ + get_identity_path, + get_exchange_rate, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} +``` + +### 2.6 Milestone Checklist + +- [ ] Tauri app builds and runs +- [ ] Vue 3 UI displays idle screen +- [ ] Can navigate between screens +- [ ] XState machine handles state transitions +- [ ] Connects to Nostr relay +- [ ] Publishes status events +- [ ] Can display QR codes + +--- + +## Phase 3: Hardware Abstraction Layer + +> [!goal] Deliverable +> Rust HAL with napi-rs bindings for bill validator and dispenser. + +### 3.1 HAL Crate Structure + +``` +packages/hal/ +├── Cargo.toml +├── src/ +│ ├── lib.rs +│ ├── error.rs +│ ├── validators/ +│ │ ├── mod.rs +│ │ ├── traits.rs # BillValidator trait +│ │ ├── nv200.rs # ITL NV200 (eSSP) +│ │ └── mock.rs # Mock for testing +│ ├── dispensers/ +│ │ ├── mod.rs +│ │ ├── traits.rs # BillDispenser trait +│ │ ├── puloon.rs # Puloon LCDM +│ │ └── mock.rs +│ ├── printer/ +│ │ ├── mod.rs +│ │ └── escpos.rs # ESC/POS thermal printer +│ └── nfc/ +│ ├── mod.rs +│ └── acr122u.rs # ACR122U reader +└── index.node # napi-rs output +``` + +### 3.2 Bill Validator Trait + +**packages/hal/src/validators/traits.rs:** +```rust +use async_trait::async_trait; +use thiserror::Error; + +#[derive(Debug, Clone)] +pub struct BillEvent { + pub denomination: u32, + pub currency: String, + pub timestamp: u64, +} + +#[derive(Debug, Error)] +pub enum ValidatorError { + #[error("Connection failed: {0}")] + ConnectionFailed(String), + #[error("Communication error: {0}")] + CommunicationError(String), + #[error("Bill rejected: {0}")] + BillRejected(String), + #[error("Stacker full")] + StackerFull, + #[error("Hardware error: {0}")] + HardwareError(String), +} + +#[async_trait] +pub trait BillValidator: Send + Sync { + /// Connect to the validator + async fn connect(&mut self) -> Result<(), ValidatorError>; + + /// Disconnect from the validator + async fn disconnect(&mut self) -> Result<(), ValidatorError>; + + /// Enable bill acceptance + async fn enable(&mut self) -> Result<(), ValidatorError>; + + /// Disable bill acceptance + async fn disable(&mut self) -> Result<(), ValidatorError>; + + /// Accept the currently held bill into stacker + async fn accept(&mut self) -> Result<(), ValidatorError>; + + /// Reject the currently held bill + async fn reject(&mut self) -> Result<(), ValidatorError>; + + /// Get current bill count in stacker + async fn get_bill_count(&self) -> Result; + + /// Subscribe to bill events + fn subscribe(&self) -> tokio::sync::broadcast::Receiver; +} +``` + +### 3.3 NV200 Implementation (eSSP) + +**packages/hal/src/validators/nv200.rs:** +```rust +use super::traits::*; +use async_trait::async_trait; +use tokio::sync::broadcast; +use tokio_serial::{SerialPortBuilderExt, SerialStream}; + +pub struct NV200Validator { + port_path: String, + serial: Option, + event_tx: broadcast::Sender, + enabled: bool, +} + +impl NV200Validator { + pub fn new(port_path: &str) -> Self { + let (event_tx, _) = broadcast::channel(16); + Self { + port_path: port_path.to_string(), + serial: None, + event_tx, + enabled: false, + } + } + + async fn send_command(&mut self, cmd: &[u8]) -> Result, ValidatorError> { + let serial = self.serial.as_mut() + .ok_or(ValidatorError::ConnectionFailed("Not connected".into()))?; + + // eSSP protocol: STX, SEQ, LEN, DATA..., CRC + let packet = self.build_essp_packet(cmd); + + // Write command + use tokio::io::AsyncWriteExt; + serial.write_all(&packet).await + .map_err(|e| ValidatorError::CommunicationError(e.to_string()))?; + + // Read response + use tokio::io::AsyncReadExt; + let mut buf = vec![0u8; 256]; + let n = serial.read(&mut buf).await + .map_err(|e| ValidatorError::CommunicationError(e.to_string()))?; + + Ok(buf[..n].to_vec()) + } + + fn build_essp_packet(&self, data: &[u8]) -> Vec { + // eSSP packet structure + let mut packet = vec![0x7F]; // STX + packet.push(0x00); // Sequence (simplified) + packet.push(data.len() as u8); + packet.extend_from_slice(data); + // Add CRC16 + let crc = self.calculate_crc(&packet[1..]); + packet.extend_from_slice(&crc.to_le_bytes()); + packet + } + + fn calculate_crc(&self, data: &[u8]) -> u16 { + // CRC-16-CCITT + let mut crc: u16 = 0xFFFF; + for byte in data { + crc ^= (*byte as u16) << 8; + for _ in 0..8 { + if crc & 0x8000 != 0 { + crc = (crc << 1) ^ 0x8005; + } else { + crc <<= 1; + } + } + } + crc + } +} + +#[async_trait] +impl BillValidator for NV200Validator { + async fn connect(&mut self) -> Result<(), ValidatorError> { + let serial = tokio_serial::new(&self.port_path, 9600) + .open_native_async() + .map_err(|e| ValidatorError::ConnectionFailed(e.to_string()))?; + + self.serial = Some(serial); + + // Sync and reset + self.send_command(&[0x11]).await?; // Sync + self.send_command(&[0x01]).await?; // Reset + + // Set channel enables (all denominations) + self.send_command(&[0x02, 0xFF, 0xFF]).await?; + + Ok(()) + } + + async fn disconnect(&mut self) -> Result<(), ValidatorError> { + self.serial = None; + Ok(()) + } + + async fn enable(&mut self) -> Result<(), ValidatorError> { + self.send_command(&[0x0A]).await?; // Enable + self.enabled = true; + Ok(()) + } + + async fn disable(&mut self) -> Result<(), ValidatorError> { + self.send_command(&[0x09]).await?; // Disable + self.enabled = false; + Ok(()) + } + + async fn accept(&mut self) -> Result<(), ValidatorError> { + // NV200 auto-stacks, but we need to confirm + self.send_command(&[0x5C]).await?; // Payout + Ok(()) + } + + async fn reject(&mut self) -> Result<(), ValidatorError> { + self.send_command(&[0x08]).await?; // Reject + Ok(()) + } + + async fn get_bill_count(&self) -> Result { + // Query stacker level + Ok(0) // TODO: Implement + } + + fn subscribe(&self) -> broadcast::Receiver { + self.event_tx.subscribe() + } +} +``` + +### 3.4 napi-rs Bindings + +**packages/hal/src/lib.rs:** +```rust +use napi::bindgen_prelude::*; +use napi_derive::napi; + +mod error; +mod validators; +mod dispensers; +mod printer; +mod nfc; + +use validators::{traits::BillValidator, nv200::NV200Validator, mock::MockValidator}; + +#[napi] +pub struct BillValidatorWrapper { + inner: Box, +} + +#[napi] +impl BillValidatorWrapper { + #[napi(constructor)] + pub fn new(driver: String, port: Option) -> Result { + let validator: Box = match driver.as_str() { + "nv200" => { + let port = port.ok_or_else(|| Error::from_reason("Port required for NV200"))?; + Box::new(NV200Validator::new(&port)) + } + "mock" => Box::new(MockValidator::new()), + _ => return Err(Error::from_reason(format!("Unknown driver: {}", driver))), + }; + + Ok(Self { inner: validator }) + } + + #[napi] + pub async fn connect(&mut self) -> Result<()> { + self.inner.connect().await + .map_err(|e| Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn enable(&mut self) -> Result<()> { + self.inner.enable().await + .map_err(|e| Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn disable(&mut self) -> Result<()> { + self.inner.disable().await + .map_err(|e| Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn accept(&mut self) -> Result<()> { + self.inner.accept().await + .map_err(|e| Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn reject(&mut self) -> Result<()> { + self.inner.reject().await + .map_err(|e| Error::from_reason(e.to_string())) + } +} +``` + +### 3.5 TypeScript Usage + +```typescript +import { BillValidatorWrapper } from '@lamassu/hal' + +// Development with mock +const validator = new BillValidatorWrapper('mock') + +// Production with NV200 +const validator = new BillValidatorWrapper('nv200', '/dev/ttyUSB0') + +await validator.connect() +await validator.enable() + +// Listen for bills (via event emitter pattern) +validator.on('bill', (event) => { + console.log(`Bill inserted: ${event.denomination} ${event.currency}`) +}) +``` + +### 3.6 Milestone Checklist + +- [ ] HAL crate compiles +- [ ] Mock validator works +- [ ] napi-rs bindings build +- [ ] Can import in TypeScript +- [ ] Mock bills trigger events +- [ ] NV200 driver tested with real hardware (optional) +- [ ] Puloon dispenser driver implemented + +--- + +## Phase 4: Payment Flows + +> [!goal] Deliverable +> Complete cash-in and cash-out flows working end-to-end. + +### 4.1 Lightning.Pub Integration + +**packages/lightning/src/index.ts:** +```typescript +import { nip44 } from 'nostr-tools' +import type { MachineIdentity } from '@lamassu/nostr-client' + +export class LightningPubClient { + constructor( + private nprofile: string, // Lightning.Pub connection string + private identity: MachineIdentity + ) {} + + async createInvoice(amountMsat: number, memo: string): Promise { + // Send CLINK request for invoice + // ... + return 'lnbc...' + } + + async payInvoice(bolt11: string): Promise<{ preimage: string }> { + // Use CLINK debit to pay + // ... + return { preimage: '...' } + } + + async getBalance(): Promise { + // Query balance via Nostr + // ... + return 0 + } + + subscribeToPayments(onPayment: (payment: any) => void): () => void { + // Subscribe to payment events + // ... + return () => {} + } +} +``` + +### 4.2 Cash-In Flow Integration + +```typescript +// In XState machine actors +const generateClinkOffer = fromPromise(async ({ input }) => { + const { identity, amountSats, relays } = input + + // Create CLINK offer + const offer = createOffer(identity, relays, 'fixed', amountSats) + + // Start listening for offer requests + // When request comes in, generate invoice from Lightning.Pub + // Send invoice back via CLINK response + + return offer +}) + +const sendNostrReceipt = fromPromise(async ({ input }) => { + const { identity, userNpub, transaction, relay } = input + + if (!userNpub) return // User didn't want receipt + + // Create NIP-17 encrypted DM + const receipt = { + type: 'transaction_receipt', + txid: transaction.txid, + amount: transaction.amountSats, + timestamp: Date.now(), + } + + // Encrypt with NIP-44 + const encrypted = nip44.encrypt( + identity.nsec, + userNpub, + JSON.stringify(receipt) + ) + + // Publish as gift-wrapped DM (NIP-59) + await relay.publish({ + kind: 14, + content: encrypted, + tags: [['p', userNpub]], + }) +}) +``` + +### 4.3 Cash-Out Flow Integration + +```typescript +const generateInvoice = fromPromise(async ({ input }) => { + const { lightningPub, amountSats, memo } = input + + const invoice = await lightningPub.createInvoice( + amountSats * 1000, // msat + memo || 'ATM cash withdrawal' + ) + + return invoice +}) + +const dispenseCash = fromPromise(async ({ input }) => { + const { dispenser, amountFiat, denominations } = input + + // Calculate bills to dispense + const bills = calculateBillsToDispense(amountFiat, denominations) + + // Dispense each denomination + for (const [denomination, count] of Object.entries(bills)) { + await dispenser.dispense(parseInt(denomination), count) + } + + return { success: true } +}) +``` + +### 4.4 Cashu Integration (Offline Mode) + +**packages/cashu/src/index.ts:** +```typescript +import { CashuMint, CashuWallet, getEncodedToken } from '@cashu/cashu-ts' + +export class ATMCashuWallet { + private wallet: CashuWallet + + constructor(mintUrl: string) { + const mint = new CashuMint(mintUrl) + this.wallet = new CashuWallet(mint) + } + + async preloadTokens(amountSats: number): Promise { + // Mint tokens to have ready for offline operation + const { proofs } = await this.wallet.mintTokens(amountSats) + // Store proofs locally + } + + async createTokenForUser(amountSats: number): Promise { + // Create cashu token for user + const proofs = await this.getProofsForAmount(amountSats) + return getEncodedToken({ + token: [{ mint: this.wallet.mint.mintUrl, proofs }] + }) + } + + private async getProofsForAmount(amount: number) { + // Select proofs from local storage + // ... + } +} +``` + +### 4.5 Milestone Checklist + +- [ ] Lightning.Pub client connects +- [ ] Can create invoices +- [ ] Can receive payments +- [ ] Cash-in flow works end-to-end (mock hardware) +- [ ] Cash-out flow works end-to-end (mock hardware) +- [ ] CLINK offer request/response working +- [ ] NIP-17 receipts delivered +- [ ] Cashu offline mode works + +--- + +## Phase 5: Operator Tools + +> [!goal] Deliverable +> Dashboard for monitoring and managing ATM fleet. + +### 5.1 Dashboard App + +``` +apps/dashboard/ +├── src/ +│ ├── App.vue +│ ├── main.ts +│ ├── views/ +│ │ ├── Dashboard.vue +│ │ ├── Machines.vue +│ │ ├── Transactions.vue +│ │ └── Settings.vue +│ ├── components/ +│ │ ├── MachineCard.vue +│ │ ├── TransactionTable.vue +│ │ └── AlertBanner.vue +│ └── composables/ +│ └── useFleet.ts +└── package.json +``` + +### 5.2 Fleet Composable + +**apps/dashboard/src/composables/useFleet.ts:** +```typescript +import { ref, computed, onMounted, onUnmounted } from 'vue' +import { ATMRelayClient, loadOrCreateIdentity } from '@lamassu/nostr-client' + +interface MachineStatus { + npub: string + online: boolean + lastSeen: number + cashLevels: object + errors: string[] +} + +export function useFleet() { + const machines = ref>(new Map()) + const transactions = ref([]) + let client: ATMRelayClient | null = null + let unsubscribe: (() => void) | null = null + + const onlineMachines = computed(() => + [...machines.value.values()].filter(m => m.online) + ) + + const totalCashOnHand = computed(() => { + // Sum cash across all machines + return 0 + }) + + onMounted(async () => { + const identity = loadOrCreateIdentity('~/.lamassu/operator.nsec') + client = new ATMRelayClient( + import.meta.env.VITE_RELAY_URL, + identity + ) + await client.connect() + + // Subscribe to all machine status events + unsubscribe = client.subscribe( + [{ kinds: [30078, 30079] }], + handleEvent + ) + }) + + onUnmounted(() => { + unsubscribe?.() + client?.disconnect() + }) + + function handleEvent(event: any) { + if (event.kind === 30078) { + // Machine status update + const status = JSON.parse(event.content) + machines.value.set(event.pubkey, { + npub: event.pubkey, + ...status, + lastSeen: event.created_at, + }) + } else if (event.kind === 30079) { + // Transaction record + const tx = JSON.parse(event.content) + transactions.value.unshift(tx) + } + } + + async function sendCommand(machineNpub: string, command: object) { + if (!client) return + + await client.publish({ + kind: 21003, // CLINK manage + content: JSON.stringify(command), + tags: [['p', machineNpub]], + }) + } + + return { + machines, + transactions, + onlineMachines, + totalCashOnHand, + sendCommand, + } +} +``` + +### 5.3 Milestone Checklist + +- [ ] Dashboard builds and runs +- [ ] Connects to relay +- [ ] Shows machine status in real-time +- [ ] Displays transaction history +- [ ] Can send commands to machines +- [ ] Alerts for low cash / errors + +--- + +## Development Hardware + +### Recommended Dev Kit + +| Component | Model | Purpose | Est. Cost | +|-----------|-------|---------|-----------| +| SBC | Raspberry Pi 5 (8GB) | Development machine | $80 | +| Display | Waveshare 7" touch | UI development | $70 | +| Bill Validator | ITL NV200 (used) | Real hardware testing | $300-500 | +| Printer | Generic ESC/POS | Receipt testing | $50 | +| NFC Reader | ACR122U | BOLT card testing | $35 | +| **Total** | | | **~$535-735** | + +### Mock-First Development + +For most development, mocks are sufficient: + +```typescript +// Start with mocks +const validator = new BillValidatorWrapper('mock') +const dispenser = new BillDispenserWrapper('mock') + +// CLI to simulate bills +// pnpm mock:bill 20 <- Simulates $20 bill inserted +// pnpm mock:bill 50 <- Simulates $50 bill inserted +``` + +--- + +## Testing Strategy + +### Unit Tests + +```bash +# Run all unit tests +pnpm test + +# Run specific package +pnpm --filter @lamassu/nostr-client test +``` + +### Integration Tests + +```bash +# Start test infrastructure +docker compose -f docker-compose.test.yml up -d + +# Run integration tests +pnpm test:integration +``` + +### E2E Tests (Playwright) + +```bash +# Run E2E tests against mock hardware +pnpm test:e2e + +# Run E2E tests against real hardware (CI skip) +REAL_HARDWARE=true pnpm test:e2e +``` + +--- + +## Next Steps + +1. **Initialize the monorepo** (Phase 0.1-0.2) +2. **Get devenv.nix working** (Phase 0.2) +3. **Set up Lightning.Pub locally** (Phase 0.3) +4. **Build nostr-client package** (Phase 1.1) +5. **First milestone: Publish event to relay** + +--- + +## Related Notes + +- [[nostr-native-architecture]] - Architecture overview +- [[architecture-review]] - KYC-free vision +- [[hardware-recommendations]] - Hardware choices +- [[modernization-plan]] - Original tech decisions