From 5d56ebb642aea185dda91b6c8fe78689f29917e3 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Sat, 24 Jan 2026 14:04:25 -0500 Subject: [PATCH] Implement LNURL-withdraw for cash-in flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace CLINK offer with LNURL-withdraw for the "buy bitcoin" flow. LNURL-withdraw is the correct protocol for customers to receive sats: - ATM displays LNURL-withdraw QR code - Customer scans with their Lightning wallet - Wallet automatically creates invoice and sends it to ATM - ATM pays the invoice, sending sats to customer This provides a much better UX than manual invoice entry, especially for a kiosk where users can't paste text. Changes: - Add LnurlWithdrawServer class that implements LUD-03 protocol - Add LNURL HTTP server (port 3333) for wallet callbacks - Update state machine: generatingOffer → generatingLnurlWithdraw - Add lnurlWithdraw context field and generateLnurlWithdraw service - Update CashInView to display LNURL-withdraw QR - Add @scure/base dependency for bech32 encoding Co-Authored-By: Claude Opus 4.5 --- lamassu-next/apps/machine/package.json | 1 + .../apps/machine/src/services/lightning.ts | 82 ++++- .../machine/src/services/lnurl-withdraw.ts | 310 ++++++++++++++++++ lamassu-next/apps/machine/src/stores/atm.ts | 6 + .../apps/machine/src/views/CashInView.vue | 19 +- .../packages/state-machine/src/machine.ts | 16 +- .../packages/state-machine/src/types.ts | 9 +- lamassu-next/pnpm-lock.yaml | 3 + 8 files changed, 427 insertions(+), 19 deletions(-) create mode 100644 lamassu-next/apps/machine/src/services/lnurl-withdraw.ts diff --git a/lamassu-next/apps/machine/package.json b/lamassu-next/apps/machine/package.json index 134d3a5..ed981f7 100644 --- a/lamassu-next/apps/machine/package.json +++ b/lamassu-next/apps/machine/package.json @@ -18,6 +18,7 @@ "@lamassu/lightning": "workspace:*", "@lamassu/nostr-client": "workspace:*", "@lamassu/state-machine": "workspace:*", + "@scure/base": "^1.2.0", "@vueuse/core": "^14.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/lamassu-next/apps/machine/src/services/lightning.ts b/lamassu-next/apps/machine/src/services/lightning.ts index 55ccdae..eb2899d 100644 --- a/lamassu-next/apps/machine/src/services/lightning.ts +++ b/lamassu-next/apps/machine/src/services/lightning.ts @@ -2,6 +2,7 @@ * Lightning Services * * Connects to Lightning.Pub and provides real ATMServices implementation. + * Includes LNURL-withdraw server for cash-in flow. */ import { NostrClient, generateIdentity, type MachineIdentity } from '@lamassu/nostr-client' @@ -9,6 +10,7 @@ import { LightningPubClient } from '@lamassu/lightning' import { CLINKClient, createOfferSuccess, createOfferError, CLINKErrorCode } from '@lamassu/clink' import type { OfferRequest } from '@lamassu/clink' import type { ATMServices, ATMContext } from '@lamassu/state-machine' +import { LnurlWithdrawServer } from './lnurl-withdraw' // Development infrastructure configuration // In production, these would come from environment or secure config @@ -21,17 +23,23 @@ const DEV_CONFIG = { adminToken: 'lamassu-dev-admin-token', // Lightning.Pub HTTP API lightningPubApiUrl: 'http://localhost:1776', + // LNURL-withdraw server configuration + lnurlPort: 3333, + // External host - this needs to be reachable by the customer's wallet + // For local development, use localhost. In production, use the ATM's public IP/domain + lnurlExternalHost: 'localhost', } interface LightningServices { nostrClient: NostrClient lightningPub: LightningPubClient clink: CLINKClient + lnurlServer: LnurlWithdrawServer identity: MachineIdentity atmServices: ATMServices /** Set callback for when offer requests are received */ onOfferRequest: (callback: OfferRequestCallback) => void - /** Set callback for when payments are received */ + /** Set callback for when payments are received (from CLINK or LNURL-withdraw) */ onPaymentReceived: (callback: PaymentReceivedCallback) => void } @@ -130,13 +138,48 @@ export async function initializeLightningServices(): Promise clink.startListening() console.log('[Lightning] CLINK client initialized with offer handler') + // Create pay invoice function for LNURL-withdraw + const payInvoice = async (invoice: string): Promise<{ preimage: string }> => { + console.log('[Lightning] Paying invoice:', invoice.slice(0, 32) + '...') + try { + const result = await lightningPub.payInvoice(invoice) + if (!result.success) { + throw new Error(result.error || 'Payment failed') + } + console.log( + '[Lightning] Payment successful, preimage:', + result.preimage?.slice(0, 16) + '...' + ) + return { preimage: result.preimage! } + } catch (error) { + console.error('[Lightning] Failed to pay invoice:', error) + throw error + } + } + + // Create LNURL-withdraw server + const lnurlServer = new LnurlWithdrawServer({ + port: DEV_CONFIG.lnurlPort, + externalHost: DEV_CONFIG.lnurlExternalHost, + payInvoice, + }) + + // Start the LNURL server + await lnurlServer.start() + console.log('[Lightning] LNURL-withdraw server started on port', DEV_CONFIG.lnurlPort) + // Create ATM services - const atmServices = createATMServices(lightningPub, clink, identity) + const atmServices = createATMServices(lightningPub, clink, lnurlServer, identity, (preimage) => { + if (paymentReceivedCallback) { + paymentReceivedCallback(preimage) + } + }) return { nostrClient, lightningPub, clink, + lnurlServer, identity, atmServices, onOfferRequest: (callback: OfferRequestCallback) => { @@ -154,13 +197,15 @@ export async function initializeLightningServices(): Promise function createATMServices( lightningPub: LightningPubClient, clink: CLINKClient, - _identity: MachineIdentity + lnurlServer: LnurlWithdrawServer, + _identity: MachineIdentity, + onPaymentSuccess: (preimage: string) => void ): ATMServices { return { /** * Generate a CLINK offer (noffer) for receiving payment * - * For cash-in: customer scans QR to receive sats + * For cash-out display (not currently used for main flow) */ generateClinkOffer: async (context: ATMContext): Promise => { console.log('[ATM Service] Generating CLINK offer for', context.satsAmount, 'sats') @@ -178,6 +223,35 @@ function createATMServices( return noffer }, + /** + * Generate an LNURL-withdraw link for cash-in + * + * Customer scans QR with their wallet, wallet automatically creates invoice + * and sends it to us, we pay it. + */ + generateLnurlWithdraw: async (context: ATMContext): Promise => { + console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats') + + const lnurl = lnurlServer.createWithdrawal( + context.satsAmount, + `Lamassu ATM - Buy ${context.satsAmount} sats`, + (preimage) => { + console.log( + '[ATM Service] LNURL-withdraw payment sent! Preimage:', + preimage.slice(0, 16) + '...' + ) + onPaymentSuccess(preimage) + }, + (error) => { + console.error('[ATM Service] LNURL-withdraw payment failed:', error) + // Note: error handling is done via the state machine's PAYMENT_FAILED event + } + ) + + console.log('[ATM Service] Generated LNURL-withdraw:', lnurl.slice(0, 32) + '...') + return lnurl + }, + /** * Generate a Lightning invoice for payment * diff --git a/lamassu-next/apps/machine/src/services/lnurl-withdraw.ts b/lamassu-next/apps/machine/src/services/lnurl-withdraw.ts new file mode 100644 index 0000000..122d8cc --- /dev/null +++ b/lamassu-next/apps/machine/src/services/lnurl-withdraw.ts @@ -0,0 +1,310 @@ +/** + * LNURL-Withdraw Server + * + * Implements LUD-03 (LNURL-withdraw) protocol for cash-in flow. + * Customer scans a QR code with their Lightning wallet, which automatically + * creates an invoice and sends it to us for payment. + * + * Flow: + * 1. ATM generates withdrawal with unique k1 token + * 2. Customer scans LNURL-withdraw QR + * 3. Wallet fetches parameters from our callback URL + * 4. Wallet creates invoice and submits to callback + * 5. We validate k1, pay the invoice, notify the ATM + * + * @see https://github.com/lnurl/luds/blob/luds/03.md + */ + +import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http' +import { bech32 } from '@scure/base' + +/** Pending withdrawal request */ +interface PendingWithdrawal { + k1: string + amountMsats: number + description: string + createdAt: number + /** Callback to pay the invoice when received */ + payInvoice: (invoice: string) => Promise<{ preimage: string }> + /** Callback when payment succeeds */ + onSuccess: (preimage: string) => void + /** Callback when payment fails */ + onError: (error: string) => void +} + +/** LNURL-withdraw server configuration */ +export interface LnurlWithdrawConfig { + /** Port to listen on */ + port: number + /** External host/IP that wallets will connect to */ + externalHost: string + /** Payment function */ + payInvoice: (invoice: string) => Promise<{ preimage: string }> +} + +/** + * LNURL-withdraw server + */ +export class LnurlWithdrawServer { + private server: Server | null = null + private pendingWithdrawals = new Map() + private config: LnurlWithdrawConfig + + constructor(config: LnurlWithdrawConfig) { + this.config = config + } + + /** + * Start the HTTP server + */ + async start(): Promise { + if (this.server) { + return // Already running + } + + return new Promise((resolve, reject) => { + this.server = createServer((req, res) => this.handleRequest(req, res)) + + this.server.on('error', (err) => { + console.error('[LNURL] Server error:', err) + reject(err) + }) + + this.server.listen(this.config.port, () => { + console.log(`[LNURL] Server listening on port ${this.config.port}`) + resolve() + }) + }) + } + + /** + * Stop the HTTP server + */ + async stop(): Promise { + return new Promise((resolve) => { + if (this.server) { + this.server.close(() => { + this.server = null + console.log('[LNURL] Server stopped') + resolve() + }) + } else { + resolve() + } + }) + } + + /** + * Create a new withdrawal request + * + * @param amountSats Amount in satoshis + * @param description Description for the invoice + * @param onSuccess Callback when payment succeeds + * @param onError Callback when payment fails + * @returns LNURL-withdraw string (bech32-encoded) + */ + createWithdrawal( + amountSats: number, + description: string, + onSuccess: (preimage: string) => void, + onError: (error: string) => void + ): string { + // Generate random k1 token + const k1 = this.generateK1() + + // Store the pending withdrawal + const withdrawal: PendingWithdrawal = { + k1, + amountMsats: amountSats * 1000, + description, + createdAt: Date.now(), + payInvoice: this.config.payInvoice, + onSuccess, + onError, + } + + this.pendingWithdrawals.set(k1, withdrawal) + console.log(`[LNURL] Created withdrawal: ${k1.slice(0, 16)}... for ${amountSats} sats`) + + // Build the callback URL + const callbackUrl = `http://${this.config.externalHost}:${this.config.port}/lnurl?k1=${k1}` + + // Encode as LNURL (bech32 with "lnurl" prefix) + const lnurl = this.encodeLnurl(callbackUrl) + console.log(`[LNURL] Generated LNURL: ${lnurl.slice(0, 32)}...`) + + return lnurl + } + + /** + * Cancel a pending withdrawal + */ + cancelWithdrawal(k1: string): void { + if (this.pendingWithdrawals.has(k1)) { + this.pendingWithdrawals.delete(k1) + console.log(`[LNURL] Cancelled withdrawal: ${k1.slice(0, 16)}...`) + } + } + + /** + * Handle incoming HTTP requests + */ + private handleRequest(req: IncomingMessage, res: ServerResponse): void { + // Enable CORS for wallet compatibility + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS') + res.setHeader('Content-Type', 'application/json') + + if (req.method === 'OPTIONS') { + res.writeHead(200) + res.end() + return + } + + const url = new URL(req.url || '/', `http://${req.headers.host}`) + const path = url.pathname + const k1 = url.searchParams.get('k1') + const pr = url.searchParams.get('pr') // Payment request (invoice) + + console.log( + `[LNURL] Request: ${path}?k1=${k1?.slice(0, 16)}...${pr ? `&pr=${pr.slice(0, 20)}...` : ''}` + ) + + if (path !== '/lnurl') { + this.sendError(res, 404, 'Not found') + return + } + + if (!k1) { + this.sendError(res, 400, 'Missing k1 parameter') + return + } + + const withdrawal = this.pendingWithdrawals.get(k1) + if (!withdrawal) { + this.sendError(res, 400, 'Unknown or expired k1') + return + } + + if (pr) { + // Step 2: Wallet is submitting an invoice + this.handleInvoiceSubmission(res, withdrawal, pr) + } else { + // Step 1: Wallet is requesting withdrawal parameters + this.handleWithdrawalRequest(res, withdrawal) + } + } + + /** + * Handle initial LNURL-withdraw request (wallet fetching parameters) + */ + private handleWithdrawalRequest(res: ServerResponse, withdrawal: PendingWithdrawal): void { + const callbackUrl = `http://${this.config.externalHost}:${this.config.port}/lnurl?k1=${withdrawal.k1}` + + const response = { + tag: 'withdrawRequest', + callback: callbackUrl, + k1: withdrawal.k1, + defaultDescription: withdrawal.description, + minWithdrawable: withdrawal.amountMsats, + maxWithdrawable: withdrawal.amountMsats, + } + + console.log(`[LNURL] Sending withdrawal parameters:`, { + ...response, + k1: response.k1.slice(0, 16) + '...', + }) + + res.writeHead(200) + res.end(JSON.stringify(response)) + } + + /** + * Handle invoice submission from wallet + */ + private async handleInvoiceSubmission( + res: ServerResponse, + withdrawal: PendingWithdrawal, + invoice: string + ): Promise { + console.log(`[LNURL] Received invoice: ${invoice.slice(0, 32)}...`) + + try { + // Validate invoice starts with expected prefix + const lowerInvoice = invoice.toLowerCase() + if ( + !lowerInvoice.startsWith('lnbc') && + !lowerInvoice.startsWith('lntb') && + !lowerInvoice.startsWith('lnbcrt') + ) { + this.sendError(res, 400, 'Invalid invoice format') + return + } + + // TODO: Decode and verify invoice amount matches withdrawal amount + // For now, trust the wallet created the correct invoice + + console.log(`[LNURL] Paying invoice...`) + const result = await withdrawal.payInvoice(invoice) + + // Payment successful + console.log(`[LNURL] Payment successful! Preimage: ${result.preimage.slice(0, 16)}...`) + + // Remove from pending + this.pendingWithdrawals.delete(withdrawal.k1) + + // Notify the ATM + withdrawal.onSuccess(result.preimage) + + // Send success response to wallet + res.writeHead(200) + res.end(JSON.stringify({ status: 'OK' })) + } catch (error) { + console.error(`[LNURL] Payment failed:`, error) + + // Notify the ATM + withdrawal.onError(error instanceof Error ? error.message : 'Payment failed') + + // Send error to wallet + this.sendError(res, 500, error instanceof Error ? error.message : 'Payment failed') + } + } + + /** + * Send JSON error response + */ + private sendError(res: ServerResponse, status: number, message: string): void { + res.writeHead(status) + res.end(JSON.stringify({ status: 'ERROR', reason: message })) + } + + /** + * Generate a random k1 token (32 bytes hex) + */ + private generateK1(): string { + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + } + + /** + * Encode a URL as LNURL (bech32 with "lnurl" prefix) + */ + private encodeLnurl(url: string): string { + const bytes = new TextEncoder().encode(url) + const words = bech32.toWords(bytes) + return bech32.encode('lnurl', words, 1023).toUpperCase() + } +} + +/** + * Decode an LNURL back to a URL (for testing/debugging) + */ +export function decodeLnurl(lnurl: string): string { + // Cast to the expected bech32 format type + const decoded = bech32.decode(lnurl.toLowerCase() as `${string}1${string}`, 1023) + const bytes = bech32.fromWords(decoded.words) + return new TextDecoder().decode(new Uint8Array(bytes)) +} diff --git a/lamassu-next/apps/machine/src/stores/atm.ts b/lamassu-next/apps/machine/src/stores/atm.ts index 4137b10..79607e6 100644 --- a/lamassu-next/apps/machine/src/stores/atm.ts +++ b/lamassu-next/apps/machine/src/stores/atm.ts @@ -18,6 +18,12 @@ const mockServices: ATMServices = { return `noffer1mock${Date.now().toString(36)}` }, + generateLnurlWithdraw: async (context) => { + console.log('[Mock] Generating LNURL-withdraw for', context.satsAmount, 'sats') + // This is a mock LNURL - in reality it would be a bech32 encoded URL + return `LNURL1MOCK${Date.now().toString(36).toUpperCase()}` + }, + generateInvoice: async (amountMsat) => { console.log('[Mock] Generating invoice for', amountMsat, 'msats') return `lnbc${amountMsat}n1mock${Date.now().toString(36)}` diff --git a/lamassu-next/apps/machine/src/views/CashInView.vue b/lamassu-next/apps/machine/src/views/CashInView.vue index af54b36..e13a16d 100644 --- a/lamassu-next/apps/machine/src/views/CashInView.vue +++ b/lamassu-next/apps/machine/src/views/CashInView.vue @@ -125,18 +125,18 @@ function formatSats(sats: number): string { - - + + -

Generating CLINK offer...

+

Preparing your withdrawal...

- + - Scan to Receive + Scan to Receive Sats {{ formatSats(context.satsAmount) }} sats @@ -144,9 +144,12 @@ function formatSats(sats: number): string { - +

- Open your Lightning wallet and scan the code + Open your Lightning wallet and scan to receive your sats +

+

+ Your wallet will automatically create an invoice and we'll pay it instantly

@@ -157,7 +160,7 @@ function formatSats(sats: number): string { size="sm" @click="atmStore.paymentReceived('mock-preimage-' + Date.now())" > - Dev: Simulate Payment + Dev: Simulate Payment Sent diff --git a/lamassu-next/packages/state-machine/src/machine.ts b/lamassu-next/packages/state-machine/src/machine.ts index e482c28..04b16e4 100644 --- a/lamassu-next/packages/state-machine/src/machine.ts +++ b/lamassu-next/packages/state-machine/src/machine.ts @@ -24,6 +24,12 @@ export function createATMMachine(services: Partial = {}) { } return services.generateClinkOffer(input) }), + generateLnurlWithdraw: fromPromise(async ({ input }: { input: ATMContext }) => { + if (!services.generateLnurlWithdraw) { + throw new Error('generateLnurlWithdraw service not provided') + } + return services.generateLnurlWithdraw(input) + }), generateInvoice: fromPromise(async ({ input }: { input: number }) => { if (!services.generateInvoice) { throw new Error('generateInvoice service not provided') @@ -217,21 +223,21 @@ export function createATMMachine(services: Partial = {}) { }, FINISH_INSERTING: { guard: 'hasInsertedBills', - target: 'generatingOffer', + target: 'generatingLnurlWithdraw', }, CANCEL: '#atm.idle', TIMEOUT: '#atm.idle', }, }, - generatingOffer: { + generatingLnurlWithdraw: { invoke: { - src: 'generateClinkOffer', + src: 'generateLnurlWithdraw', input: ({ context }) => context, onDone: { target: 'displayingQR', actions: assign({ - clinkOffer: ({ event }) => event.output, - paymentMethod: () => 'clink_offer' as const, + lnurlWithdraw: ({ event }) => event.output, + paymentMethod: () => 'lnurl_withdraw' as const, }), }, onError: { diff --git a/lamassu-next/packages/state-machine/src/types.ts b/lamassu-next/packages/state-machine/src/types.ts index 0c1dbfc..46b340a 100644 --- a/lamassu-next/packages/state-machine/src/types.ts +++ b/lamassu-next/packages/state-machine/src/types.ts @@ -25,8 +25,10 @@ export interface ATMContext { // Payment /** BOLT11 invoice for payment */ invoice: string | null - /** CLINK offer string (noffer) */ + /** CLINK offer string (noffer) - for cash-out */ clinkOffer: string | null + /** LNURL-withdraw string - for cash-in (customer receives sats) */ + lnurlWithdraw: string | null /** Current payment status */ paymentStatus: PaymentStatus /** Payment preimage (proof of payment) */ @@ -94,6 +96,7 @@ export const initialContext: ATMContext = { feePercent: 0.02, invoice: null, clinkOffer: null, + lnurlWithdraw: null, paymentStatus: null, preimage: null, paymentMethod: null, @@ -109,8 +112,10 @@ export const initialContext: ATMContext = { /** Service inputs for actors */ export interface ATMServices { - /** Generate a CLINK offer */ + /** Generate a CLINK offer (for cash-out) */ generateClinkOffer: (context: ATMContext) => Promise + /** Generate an LNURL-withdraw link (for cash-in - customer receives sats) */ + generateLnurlWithdraw: (context: ATMContext) => Promise /** Generate a Lightning invoice */ generateInvoice: (amountMsat: number) => Promise /** Send receipt via Nostr */ diff --git a/lamassu-next/pnpm-lock.yaml b/lamassu-next/pnpm-lock.yaml index c153c23..9f0ebde 100644 --- a/lamassu-next/pnpm-lock.yaml +++ b/lamassu-next/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@lamassu/state-machine': specifier: workspace:* version: link:../../packages/state-machine + '@scure/base': + specifier: ^1.2.0 + version: 1.2.6 '@vueuse/core': specifier: ^14.1.0 version: 14.1.0(vue@3.5.27(typescript@5.9.3))