diff --git a/lamassu-next/apps/machine/electron/main.ts b/lamassu-next/apps/machine/electron/main.ts index e5df3e5..cfab582 100644 --- a/lamassu-next/apps/machine/electron/main.ts +++ b/lamassu-next/apps/machine/electron/main.ts @@ -8,6 +8,36 @@ import { app, BrowserWindow, ipcMain } from 'electron' import path from 'node:path' +import fs from 'node:fs' +import { fileURLToPath } from 'node:url' + +// ESM equivalent of __dirname +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Load .env file manually (Electron main process doesn't have Vite's env loading) +function loadEnvFile() { + const envPath = path.join(__dirname, '..', '.env') + try { + if (fs.existsSync(envPath)) { + const envContent = fs.readFileSync(envPath, 'utf-8') + for (const line of envContent.split('\n')) { + const trimmed = line.trim() + if (trimmed && !trimmed.startsWith('#')) { + const [key, ...valueParts] = trimmed.split('=') + if (key && valueParts.length > 0) { + process.env[key] = valueParts.join('=') + } + } + } + console.log('[Electron] Loaded .env file from:', envPath) + } + } catch (e) { + console.warn('[Electron] Failed to load .env file:', e) + } +} + +loadEnvFile() // Determine if we're in development const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged diff --git a/lamassu-next/apps/machine/electron/tsconfig.json b/lamassu-next/apps/machine/electron/tsconfig.json index fca762c..a89ff1f 100644 --- a/lamassu-next/apps/machine/electron/tsconfig.json +++ b/lamassu-next/apps/machine/electron/tsconfig.json @@ -1,13 +1,14 @@ { "compilerOptions": { "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "node", + "module": "ES2022", + "moduleResolution": "bundler", "esModuleInterop": true, "strict": true, "skipLibCheck": true, "outDir": "../dist-electron", "rootDir": "." }, - "include": ["./**/*.ts"] + "include": ["main.ts"], + "exclude": ["preload.ts"] } diff --git a/lamassu-next/apps/machine/electron/tsconfig.preload.json b/lamassu-next/apps/machine/electron/tsconfig.preload.json new file mode 100644 index 0000000..bab2f97 --- /dev/null +++ b/lamassu-next/apps/machine/electron/tsconfig.preload.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "../dist-electron", + "rootDir": "." + }, + "include": ["preload.ts"] +} diff --git a/lamassu-next/apps/machine/package.json b/lamassu-next/apps/machine/package.json index 11dbd10..23cc7fe 100644 --- a/lamassu-next/apps/machine/package.json +++ b/lamassu-next/apps/machine/package.json @@ -13,8 +13,8 @@ "scripts": { "dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"", "dev:vite": "vite", - "electron:dev": "tsc -p electron/tsconfig.json && electron dist-electron/main.js", - "build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json", + "electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js", + "build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json", "build:electron": "pnpm build && electron-builder", "preview": "vite preview", "typecheck": "vue-tsc --noEmit", diff --git a/lamassu-next/apps/machine/src/services/lightning.ts b/lamassu-next/apps/machine/src/services/lightning.ts index d6f05ac..e9cdeb0 100644 --- a/lamassu-next/apps/machine/src/services/lightning.ts +++ b/lamassu-next/apps/machine/src/services/lightning.ts @@ -16,7 +16,11 @@ import { NostrClient, generateIdentity, loadIdentityFromHex, + encryptContent, + decryptContent, + createSignedEvent, type MachineIdentity, + type Event as NostrEvent, } from '@lamassu/nostr-client' import { LightningPubClient } from '@lamassu/lightning' import { @@ -102,6 +106,406 @@ async function loadLightningConfig(): Promise { // Config is loaded async now - will be set in initializeLightningServices let CONFIG: LightningConfig +// ============================================================================ +// Cash-in Session Management (for ndebit single-use protection) +// ============================================================================ + +/** Active cash-in session */ +interface ActiveSession { + sessionId: string + satsAmount: number + createdAt: number + status: 'active' | 'paid' | 'expired' +} + +/** Map of sessionId -> session data */ +const activeSessions = new Map() + +/** Set of approved invoice hashes (to prevent double-approval) */ +const approvedInvoices = new Set() + +/** Set of processed event IDs (to prevent replay) */ +const processedEventIds = new Set() + +/** Session timeout in ms (5 minutes) */ +const SESSION_TIMEOUT_MS = 5 * 60 * 1000 + +/** + * Register a new active session for cash-in + */ +function registerActiveSession(sessionId: string, satsAmount: number): void { + console.log('[Session] Registering session:', sessionId, 'for', satsAmount, 'sats') + + activeSessions.set(sessionId, { + sessionId, + satsAmount, + createdAt: Date.now(), + status: 'active', + }) + + // Auto-expire after timeout + setTimeout(() => { + const session = activeSessions.get(sessionId) + if (session && session.status === 'active') { + console.log('[Session] Expiring session:', sessionId) + session.status = 'expired' + // Clean up after another minute + setTimeout(() => activeSessions.delete(sessionId), 60000) + } + }, SESSION_TIMEOUT_MS) +} + +/** + * Find an active session by amount + * Returns the session if found and valid, null otherwise + * + * Note: Since Lightning.Pub requires the pointer to be a valid account identifier, + * we can't embed session IDs in the ndebit pointer. Instead, we match by amount. + * This means two concurrent sessions with the same amount would conflict. + * For production, consider using invoice description or webhooks for session tracking. + */ +function findActiveSessionByAmount(amountSats: number): ActiveSession | null { + for (const session of activeSessions.values()) { + if (session.status !== 'active') continue + + // Validate amount matches (with small tolerance for rounding) + const tolerance = Math.max(1, Math.floor(session.satsAmount * 0.001)) // 0.1% or 1 sat + if (Math.abs(amountSats - session.satsAmount) <= tolerance) { + return session + } + } + + console.log('[Session] No active session found for amount:', amountSats) + return null +} + +/** + * @deprecated Use findActiveSessionByAmount instead + * Kept for backwards compatibility with tests + */ +function validateDebitSession(_pointer: string, amountSats: number): ActiveSession | null { + return findActiveSessionByAmount(amountSats) +} + +/** + * Mark a session as paid (prevents replay) + */ +function markSessionPaid(sessionId: string): void { + const session = activeSessions.get(sessionId) + if (session) { + console.log('[Session] Marking session as paid:', sessionId) + session.status = 'paid' + } +} + +/** + * Get session by ID + */ +function getSession(sessionId: string): ActiveSession | undefined { + return activeSessions.get(sessionId) +} + +/** Export for testing */ +export { validateDebitSession, findActiveSessionByAmount, markSessionPaid, getSession } + +// ============================================================================ +// Debit Approval Service +// ============================================================================ + +/** Debit request from Lightning.Pub */ +interface DebitRequest { + requestId: string + request_id: string + npub: string + debit: { + type: string + invoice?: string + amount_sats?: number + } +} + +/** Callback for when a debit payment is approved and sent */ +type DebitPaymentCallback = (sessionId: string, invoice: string, preimage?: string) => void + +/** + * Start the debit approval subscription + * + * Listens for GetLiveDebitRequests from Lightning.Pub and auto-approves + * debit requests that match active sessions. + * + * @param nostrClient - Nostr client for subscriptions + * @param identity - ATM's identity for signing/encryption + * @param onPaymentApproved - Callback when a payment is approved + * @returns Cleanup function to stop the subscription + */ +function startDebitApprovalService( + nostrClient: NostrClient, + identity: MachineIdentity, + onPaymentApproved?: DebitPaymentCallback +): () => void { + console.log('[Debit] Starting debit approval service') + console.log('[Debit] ATM pubkey:', identity.publicKey) + console.log('[Debit] Lightning.Pub pubkey:', CONFIG.lightningPubPubkey) + + let subscriptionId: string | null = null + + // Send GetLiveDebitRequests subscription + const sendSubscription = async () => { + const subscribeRequest = { + rpcName: 'GetLiveDebitRequests', + authIdentifier: identity.publicKey, + body: {}, + } + + const content = encryptContent(identity, CONFIG.lightningPubPubkey, subscribeRequest) + + const event = createSignedEvent(identity, { + kind: 21000, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', CONFIG.lightningPubPubkey]], + content, + }) + + await nostrClient.publish(event) + console.log('[Debit] Sent GetLiveDebitRequests subscription') + } + + // Handle incoming debit requests + const handleDebitRequest = async (message: DebitRequest, eventId: string) => { + // Log full message structure for debugging + console.log('[Debit] Full message structure:', JSON.stringify(message, null, 2)) + + console.log('[Debit] Received debit request:', { + eventId: eventId.slice(0, 16) + '...', + requestId: message.request_id, + npub: message.npub?.slice(0, 16) + '...', + debitType: message.debit?.type, + amountSats: message.debit?.amount_sats, + }) + + // Check if we've already processed this event (replay protection) + if (processedEventIds.has(eventId)) { + console.log('[Debit] REJECTED: Event already processed:', eventId.slice(0, 16)) + return + } + + if (!message.debit?.invoice) { + console.log('[Debit] No invoice in debit request') + return + } + + const invoice = message.debit.invoice + + // Check if we've already approved this invoice (double-spend protection) + if (approvedInvoices.has(invoice)) { + console.log('[Debit] REJECTED: Invoice already approved') + return + } + + // Extract amount - try message.debit.amount_sats first, then decode from invoice + let amountSats = message.debit.amount_sats + if (!amountSats) { + // Try to decode amount from BOLT11 invoice + // BOLT11 format: lnbc... + // Multipliers: m=milli (0.001), u=micro (0.000001), n=nano (0.000000001), p=pico + const invoiceLower = invoice.toLowerCase() + const match = invoiceLower.match(/^ln(bc|tb|bcrt)(\d+)([munp])?/) + if (match) { + const [, , amountStr, multiplier] = match + let amount = parseInt(amountStr!, 10) + // Convert to satoshis based on multiplier (amounts are in BTC) + // 1 BTC = 100,000,000 sats + switch (multiplier) { + case 'm': // milli-BTC = 100,000 sats + amount = amount * 100000 + break + case 'u': // micro-BTC = 100 sats + amount = amount * 100 + break + case 'n': // nano-BTC = 0.1 sats (multiply by 0.1) + amount = Math.floor(amount / 10) + break + case 'p': // pico-BTC = 0.0001 sats + amount = Math.floor(amount / 10000) + break + default: // no multiplier = BTC + amount = amount * 100000000 + } + amountSats = amount + console.log('[Debit] Decoded amount from invoice:', amountSats, 'sats') + } + } + + if (!amountSats) { + console.log('[Debit] No amount in debit request and could not decode from invoice') + return + } + + // Find matching active session by amount + // IMPORTANT: We mark the session as paid BEFORE approving to prevent race conditions + let matchingSession: ActiveSession | null = null + for (const session of activeSessions.values()) { + if (session.status === 'active') { + const tolerance = Math.max(1, Math.floor(session.satsAmount * 0.001)) + if (Math.abs(amountSats - session.satsAmount) <= tolerance) { + // Atomically mark as paid to prevent race condition + session.status = 'paid' + matchingSession = session + break + } + } + } + + if (!matchingSession) { + console.log('[Debit] REJECTED: No matching active session for amount:', amountSats) + console.log( + '[Debit] Active sessions:', + Array.from(activeSessions.values()).map((s) => ({ + id: s.sessionId.slice(0, 8), + amount: s.satsAmount, + status: s.status, + })) + ) + return + } + + // Mark event and invoice as processed BEFORE sending approval + processedEventIds.add(eventId) + approvedInvoices.add(invoice) + + console.log('[Debit] Found matching session:', matchingSession.sessionId) + console.log('[Debit] Approving debit request...') + + // 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 content = encryptContent(identity, CONFIG.lightningPubPubkey, approveRequest) + + const approveEvent = createSignedEvent(identity, { + kind: 21000, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', CONFIG.lightningPubPubkey]], + content, + }) + + await nostrClient.publish(approveEvent) + console.log('[Debit] SUCCESS: Approved debit request for session:', matchingSession.sessionId) + + // Notify callback with a placeholder preimage + // For ndebit, the "approval" IS the payment action - Lightning.Pub pays immediately + // The actual preimage comes later via GetLiveUserOperations, but we don't need to wait + if (onPaymentApproved) { + onPaymentApproved(matchingSession.sessionId, invoice, 'ndebit-approved') + } + } + + // Subscribe to messages from Lightning.Pub + const eventHandler = (event: NostrEvent) => { + console.log('[Debit] Event received:', { + kind: event.kind, + from: event.pubkey.slice(0, 16) + '...', + id: event.id.slice(0, 16) + '...', + }) + + if (event.kind !== 21000) { + console.log('[Debit] Ignoring non-21000 event') + return + } + if (event.pubkey !== CONFIG.lightningPubPubkey) { + console.log('[Debit] Ignoring event from unknown pubkey') + return + } + + try { + console.log('[Debit] Decrypting event content...') + const decrypted = decryptContent(identity, CONFIG.lightningPubPubkey, event.content) + const message = JSON.parse(decrypted) + console.log('[Debit] Decrypted message:', { + requestId: message.requestId, + rpcName: message.rpcName, + hasDebit: !!message.debit, + }) + + // Check if this is a live debit request + if (message.requestId === 'GetLiveDebitRequests' && message.debit) { + // Pass the event ID for replay protection + handleDebitRequest(message as DebitRequest, event.id) + } else if (message.rpcName) { + console.log('[Debit] RPC response:', message.rpcName, ':', message.status || 'received') + } else if (message.requestId) { + console.log('[Debit] Subscription status:', message.status) + } + } catch (err) { + // Log decryption failures to debug + console.log( + '[Debit] Failed to decrypt/parse event:', + err instanceof Error ? err.message : String(err) + ) + } + } + + // Start subscription + const startSubscription = async () => { + console.log('[Debit] Setting up Nostr subscription...') + console.log('[Debit] Filter:', { + kinds: [21000], + authors: [CONFIG.lightningPubPubkey.slice(0, 16) + '...'], + '#p': [identity.publicKey.slice(0, 16) + '...'], + }) + + // Subscribe to Kind 21000 events from Lightning.Pub tagged to us + try { + subscriptionId = nostrClient.subscribe( + [ + { + kinds: [21000], + authors: [CONFIG.lightningPubPubkey], + '#p': [identity.publicKey], + since: Math.floor(Date.now() / 1000) - 5, + }, + ], + { + onEvent: eventHandler, + onEose: () => { + console.log('[Debit] EOSE received - subscription is active and caught up') + }, + } + ) + console.log('[Debit] Subscription created:', subscriptionId) + } catch (subErr) { + console.error('[Debit] Failed to create subscription:', subErr) + throw subErr + } + + // Send the subscription request + await sendSubscription() + } + + // Start the subscription + startSubscription().catch((err) => { + console.error('[Debit] Failed to start subscription:', err) + }) + + // Return cleanup function + return () => { + if (subscriptionId) { + nostrClient.unsubscribe(subscriptionId) + } + console.log('[Debit] Stopped debit approval service') + } +} + interface LightningServices { nostrClient: NostrClient lightningPub: LightningPubClient @@ -112,6 +516,10 @@ interface LightningServices { onOfferRequest: (callback: OfferRequestCallback) => void /** Set callback for when payments are received (from CLINK or LNURL-withdraw) */ onPaymentReceived: (callback: PaymentReceivedCallback) => void + /** Set callback for when ndebit payments are approved */ + onDebitPaymentApproved: (callback: DebitPaymentCallback) => void + /** Stop the debit approval service */ + stopDebitApproval: () => void } /** Callback for incoming offer requests */ @@ -184,6 +592,24 @@ export async function initializeLightningServices(): Promise // Callbacks for events let offerRequestCallback: OfferRequestCallback | null = null let paymentReceivedCallback: PaymentReceivedCallback | null = null + let debitPaymentCallback: DebitPaymentCallback | null = null + + // Start the debit approval service + const stopDebitApproval = startDebitApprovalService( + nostrClient, + identity, + (sessionId, invoice, preimage) => { + console.log('[Lightning] Debit payment approved for session:', sessionId) + // Notify payment received callback (for state machine PAYMENT_RECEIVED event) + if (paymentReceivedCallback && preimage) { + paymentReceivedCallback(preimage) + } + // Also notify debit-specific callback + if (debitPaymentCallback) { + debitPaymentCallback(sessionId, invoice, preimage) + } + } + ) // Set up CLINK offer request handler // When someone scans our noffer and requests an invoice, we respond @@ -253,6 +679,10 @@ export async function initializeLightningServices(): Promise onPaymentReceived: (callback: PaymentReceivedCallback) => { paymentReceivedCallback = callback }, + onDebitPaymentApproved: (callback: DebitPaymentCallback) => { + debitPaymentCallback = callback + }, + stopDebitApproval, } } @@ -269,26 +699,44 @@ function createATMServices( /** * Generate an ndebit URI for cash-in * - * The ndebit encodes the ATM's pubkey and relay, with amount as query param. + * The ndebit encodes Lightning.Pub's pubkey and relay, with amount as query param. * Format: clink:ndebit1?amount= + * + * Single-use protection: + * - The pointer MUST be a valid Lightning.Pub account identifier (e.g., "atm") + * - Lightning.Pub uses the pointer to find which account should pay + * - Session tracking is done locally by registering amount-based sessions + * - When GetLiveDebitRequests notifies us, we match by amount + * - After payment approval, the session is marked complete to prevent replay */ generateNdebit: async (context: ATMContext): Promise => { console.log('[ATM Service] Generating ndebit for', context.satsAmount, 'sats') + console.log('[ATM Service] Session ID:', context.cashInSessionId) - // Create ndebit with Lightning.Pub's pubkey (not ATM's pubkey!) - // The wallet sends Kind 21002 to the pubkey in the ndebit, - // so it must be Lightning.Pub's pubkey for it to receive and pay. - // The pointer identifies which Lightning.Pub user account pays (e.g., "atm") + if (!context.cashInSessionId) { + throw new Error('No cash-in session ID - state machine error') + } + + // Create ndebit with Lightning.Pub's pubkey + // The wallet sends Kind 21002 to the pubkey in the ndebit + // The pointer MUST be a valid Lightning.Pub user identifier + // (Lightning.Pub looks up the wallet by this identifier) + const pointer = 'atm' // ATM's account identifier in Lightning.Pub const ndebit = encodeNdebit({ pubkey: CONFIG.lightningPubPubkey, relay: CONFIG.relayUrl, - pointer: 'atm', // Lightning.Pub user identifier for the ATM account + pointer, }) + // Register this session as active (for debit approval validation) + // We match incoming debit requests by amount + registerActiveSession(context.cashInSessionId, context.satsAmount) + // Format as full URI with amount const uri = formatNdebitUri(ndebit, context.satsAmount) console.log('[ATM Service] Generated ndebit URI:', uri.slice(0, 60) + '...') + console.log('[ATM Service] Pointer:', pointer, '(session:', context.cashInSessionId, ')') return uri }, diff --git a/lamassu-next/devenv.nix b/lamassu-next/devenv.nix index c33df36..e4e0c72 100644 --- a/lamassu-next/devenv.nix +++ b/lamassu-next/devenv.nix @@ -198,6 +198,23 @@ docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu -generate "$BLOCKS" ''; + # Start auto-miner (mines 1 block every 30 seconds) + auto-mine.exec = '' + echo "Starting auto-miner (1 block every 30 seconds)..." + docker compose -f docker/docker-compose.dev.yml --profile mining up -d miner + echo "" + echo "Auto-miner started. This keeps LND in sync." + echo "Use 'auto-mine-stop' to stop it." + ''; + + # Stop auto-miner + auto-mine-stop.exec = '' + echo "Stopping auto-miner..." + docker stop lamassu-miner 2>/dev/null || true + docker rm lamassu-miner 2>/dev/null || true + echo "Auto-miner stopped." + ''; + # Connect to LND CLI lncli.exec = '' docker exec -it lamassu-lnd lncli --network=regtest "$@" @@ -537,6 +554,8 @@ echo " lncli LND CLI (Lightning.Pub's node)" echo " lncli-alice LND CLI (Alice's node for testing payments)" echo " mine-blocks Mine regtest blocks (default: 1)" + echo " auto-mine Start auto-miner (1 block/30s, keeps LND synced)" + echo " auto-mine-stop Stop auto-miner" echo " setup-channel Setup channel between Alice and LND" echo " alice-pay Pay invoice from Alice's node" echo " relay-test Test Nostr relay connection" diff --git a/lamassu-next/docker/docker-compose.dev.yml b/lamassu-next/docker/docker-compose.dev.yml index e442098..6a963a2 100644 --- a/lamassu-next/docker/docker-compose.dev.yml +++ b/lamassu-next/docker/docker-compose.dev.yml @@ -170,6 +170,34 @@ services: start_period: 30s restart: unless-stopped + # Automatic block miner for regtest (keeps LND synced) + # Mines 1 block every 30 seconds + miner: + image: alpine:latest + container_name: lamassu-miner + depends_on: + bitcoind: + condition: service_healthy + entrypoint: /bin/sh + command: + - -c + - | + apk add --no-cache curl jq + echo "Starting auto-miner (1 block every 30 seconds)..." + while true; do + # Create wallet if not exists + curl -s --user lamassu:lamassu --data-binary '{"jsonrpc":"1.0","method":"createwallet","params":["miner"]}' http://bitcoind:18443/ > /dev/null 2>&1 + # Mine a block + ADDR=$$(curl -s --user lamassu:lamassu --data-binary '{"jsonrpc":"1.0","method":"getnewaddress","params":[]}' http://bitcoind:18443/ | jq -r '.result // empty') + if [ -n "$$ADDR" ]; then + curl -s --user lamassu:lamassu --data-binary "{\"jsonrpc\":\"1.0\",\"method\":\"generatetoaddress\",\"params\":[1,\"$$ADDR\"]}" http://bitcoind:18443/ > /dev/null + fi + sleep 30 + done + restart: unless-stopped + profiles: + - mining # Only starts with: docker compose --profile mining up -d + # PostgreSQL for optional server-side state postgres: image: postgres:16-alpine diff --git a/lamassu-next/docs/ndebit-cash-in-flow.md b/lamassu-next/docs/ndebit-cash-in-flow.md index c9b643f..d5c990d 100644 --- a/lamassu-next/docs/ndebit-cash-in-flow.md +++ b/lamassu-next/docs/ndebit-cash-in-flow.md @@ -299,63 +299,349 @@ Key UI elements: ## 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 -The ATM service needs to: +```typescript +// apps/machine/src/services/lightning.ts -1. **Get the ndebit from Lightning.Pub:** +generateNdebit: async (context: ATMContext): Promise => { + // 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 -```javascript -const user = await getAtmUser() // API call to Lightning.Pub -const ndebit = user.info.ndebit -``` - -2. **Rewrite relay for browser access** (if needed): - -```javascript -import { decodeNdebit, encodeNdebit } from '@lamassu/clink' - -function rewriteNdebitRelay(ndebitString, browserRelay) { - const decoded = decodeNdebit(ndebitString) - if (!decoded) return ndebitString - - return encodeNdebit({ - pubkey: decoded.pubkey, - relay: browserRelay, // e.g., 'ws://localhost:7777' - pointer: decoded.pointer, + 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 } ``` -3. **Format the URI with amount:** +**Critical:** The ndebit encodes **Lightning.Pub's pubkey**, not the ATM's pubkey. This is because: -```javascript -// Using clink: scheme (protocol-agnostic, supports Cashu/Fedimint in future) -function formatNdebitUri(ndebit, amountSats) { - const rewritten = rewriteNdebitRelay(ndebit, 'ws://localhost:7777') - return `clink:${rewritten}?amount=${amountSats}` +- 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() + +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 + ) } ``` -4. **Generate QR code:** +2. **Session Validation**: When a debit request arrives, we find a matching session: -```javascript -import QRCode from 'qrcode' +```typescript +function findActiveSessionByAmount(amountSats: number): ActiveSession | null { + for (const session of activeSessions.values()) { + if (session.status !== 'active') continue -const uri = formatNdebitUri(ndebit, 1000) -const qrDataUrl = await QRCode.toDataURL(uri, { width: 300 }) + // 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 +} ``` -### Handling Debit Requests (Lightning.Pub) +3. **Atomic Status Update**: Before approving, we mark the session as paid: -Lightning.Pub handles Kind 21002 debit requests automatically. It will: +```typescript +// In debit request handler: +const matchingSession = findActiveSessionByAmount(amountSats) +if (!matchingSession) { + console.log('[Debit] REJECTED: No matching active session') + return +} -1. Receive the debit request with the invoice -2. Validate the request (check pointer, amount limits, etc.) -3. Pay the invoice from the ATM user's balance -4. Send response back via relay +// Mark as paid BEFORE sending approval (prevents race conditions) +matchingSession.status = 'paid' -**Important:** The ATM user must have sufficient balance to pay invoices. +// Now approve... +``` + +#### Additional Protections + +```typescript +// Track processed events (prevents replay of same Nostr event) +const processedEventIds = new Set() + +// Track approved invoices (prevents same invoice being approved twice) +const approvedInvoices = new Set() + +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... + // 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 @@ -432,16 +718,113 @@ lncli payinvoice **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 -1. [ ] Lightning.Pub health check returns OK -2. [ ] LND is synced (`synced_to_chain: true`) -3. [ ] ATM user exists and has balance -4. [ ] Relay is accessible from browser -5. [ ] Wallet source configured with correct relay -6. [ ] NDebit QR contains `clink:` prefix -7. [ ] NDebit QR contains `?amount=` parameter -8. [ ] NDebit relay is browser-accessible +### 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 diff --git a/lamassu-next/packages/state-machine/src/machine.ts b/lamassu-next/packages/state-machine/src/machine.ts index 15a6951..68fac20 100644 --- a/lamassu-next/packages/state-machine/src/machine.ts +++ b/lamassu-next/packages/state-machine/src/machine.ts @@ -131,10 +131,12 @@ export function createATMMachine(services: Partial = {}) { actions: { resetContext: assign(() => ({ ...initialContext, + cashInSessionId: null, })), setStartTime: assign({ startedAt: () => Date.now(), txid: () => generateTxId(), + cashInSessionId: () => generateSessionId(), }), addBill: assign({ billsInserted: ({ context, event }) => { @@ -627,6 +629,16 @@ function generateTxId(): string { return `tx_${timestamp}_${random}` } +/** + * Generate a unique session ID for cash-in ndebit single-use protection + * This is used in the ndebit pointer field to link debit requests to specific transactions + */ +function generateSessionId(): string { + const timestamp = Date.now().toString(36) + const random = Math.random().toString(36).substring(2, 12) + return `${timestamp}${random}` +} + /** * Default ATM machine (no services - for type checking) */ diff --git a/lamassu-next/packages/state-machine/src/types.ts b/lamassu-next/packages/state-machine/src/types.ts index bead2a5..a61f5f6 100644 --- a/lamassu-next/packages/state-machine/src/types.ts +++ b/lamassu-next/packages/state-machine/src/types.ts @@ -81,6 +81,10 @@ export interface ATMContext { txid: string | null /** Transaction start time */ startedAt: number | null + + // Cash-in session (for ndebit single-use protection) + /** Unique session ID for this cash-in transaction (used in ndebit pointer) */ + cashInSessionId: string | null } /** ATM events */ @@ -144,6 +148,7 @@ export const initialContext: ATMContext = { retryCount: 0, txid: null, startedAt: null, + cashInSessionId: null, } /** Service inputs for actors */