From 0c8a1304134587a6c08fa50cf2d7b28c3c0470a8 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Sat, 14 Feb 2026 15:28:46 -0500 Subject: [PATCH] feat(machine): add LNURL-withdraw as alternative payment method in cash-in Add LNURL-withdraw (LUD-03) as an alternative to ndebit for the cash-in flow. Users can now choose between: - ndebit (CLINK) - for ShockWallet and compatible apps - LNURL-withdraw - for any LNURL-compatible wallet (Zeus, Phoenix, etc.) Changes: - CashInView.vue: Add tab UI to switch between ndebit and LNURL QR codes - atm.ts: Add generateLnurlWithdraw store action - lightning.ts: Implement LNURL-withdraw service calling extension API - Add @scure/base dependency for LNURL encoding Co-Authored-By: Claude Opus 4.5 --- lamassu-next/apps/machine/package.json | 2 + .../apps/machine/src/services/lightning.ts | 175 +++++++++++++++--- lamassu-next/apps/machine/src/stores/atm.ts | 30 +++ .../apps/machine/src/views/CashInView.vue | 90 +++++++-- 4 files changed, 260 insertions(+), 37 deletions(-) diff --git a/lamassu-next/apps/machine/package.json b/lamassu-next/apps/machine/package.json index 23cc7fe..c121252 100644 --- a/lamassu-next/apps/machine/package.json +++ b/lamassu-next/apps/machine/package.json @@ -30,6 +30,7 @@ "@vueuse/core": "^14.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "express": "^5.2.1", "lucide-vue-next": "^0.563.0", "pinia": "^2.2.0", "qrcode.vue": "^3.6.0", @@ -40,6 +41,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/express": "^5.0.6", "@types/node": "^22.0.0", "@vitejs/plugin-vue": "^5.2.0", "concurrently": "^9.0.0", diff --git a/lamassu-next/apps/machine/src/services/lightning.ts b/lamassu-next/apps/machine/src/services/lightning.ts index 811bccf..1602372 100644 --- a/lamassu-next/apps/machine/src/services/lightning.ts +++ b/lamassu-next/apps/machine/src/services/lightning.ts @@ -60,8 +60,10 @@ interface LightningConfig { relayUrl: string lightningPubPubkey: string lightningPubApiUrl: string + extensionApiUrl: string adminToken: string atmPrivateKey: string + appId: string } /** @@ -73,8 +75,10 @@ async function loadLightningConfig(): Promise { relayUrl: 'ws://localhost:7777', lightningPubPubkey: '', lightningPubApiUrl: 'http://localhost:1776', + extensionApiUrl: 'http://localhost:1777', adminToken: 'lamassu-dev-admin-token', atmPrivateKey: '', + appId: '152fd75c134226824e5183cd9c02a35b4972e995f39e7c0e4ec215ae8c1fae1d', // ATM app ID } // In Electron, get runtime config from main process @@ -85,8 +89,10 @@ async function loadLightningConfig(): Promise { relayUrl: runtimeConfig.relayUrl || defaults.relayUrl, lightningPubPubkey: runtimeConfig.lightningPubPubkey || defaults.lightningPubPubkey, lightningPubApiUrl: runtimeConfig.lightningPubApiUrl || defaults.lightningPubApiUrl, + extensionApiUrl: runtimeConfig.extensionApiUrl || defaults.extensionApiUrl, adminToken: runtimeConfig.adminToken || defaults.adminToken, atmPrivateKey: runtimeConfig.atmPrivateKey || defaults.atmPrivateKey, + appId: runtimeConfig.appId || defaults.appId, } } catch (e) { console.warn('[Lightning] Failed to get runtime config from Electron:', e) @@ -98,8 +104,10 @@ async function loadLightningConfig(): Promise { relayUrl: import.meta.env.VITE_RELAY_URL || defaults.relayUrl, lightningPubPubkey: import.meta.env.VITE_LIGHTNING_PUB_PUBKEY || defaults.lightningPubPubkey, lightningPubApiUrl: import.meta.env.VITE_LIGHTNING_PUB_API_URL || defaults.lightningPubApiUrl, + extensionApiUrl: import.meta.env.VITE_EXTENSION_API_URL || defaults.extensionApiUrl, adminToken: import.meta.env.VITE_ADMIN_TOKEN || defaults.adminToken, atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey, + appId: import.meta.env.VITE_APP_ID || defaults.appId, } } @@ -208,6 +216,96 @@ function getSession(sessionId: string): ActiveSession | undefined { /** Export for testing */ export { validateDebitSession, findActiveSessionByAmount, markSessionPaid, getSession } +// ============================================================================ +// LNURL-Withdraw Session Management +// ============================================================================ + +/** Active LNURL-withdraw session */ +interface LnurlSession { + sessionId: string + uniqueHash: string + satsAmount: number + status: 'active' | 'claimed' | 'expired' + createdAt: number + cleanup?: () => void +} + +/** Map of uniqueHash -> LNURL session data */ +const lnurlSessions = new Map() + +/** + * Register a new LNURL-withdraw session + */ +function registerLnurlSession(sessionId: string, uniqueHash: string, satsAmount: number): void { + console.log('[LNURL Session] Registering:', uniqueHash, 'for', satsAmount, 'sats') + + lnurlSessions.set(uniqueHash, { + sessionId, + uniqueHash, + satsAmount, + status: 'active', + createdAt: Date.now(), + }) + + // Auto-expire after timeout + setTimeout(() => { + const session = lnurlSessions.get(uniqueHash) + if (session && session.status === 'active') { + console.log('[LNURL Session] Expiring:', uniqueHash) + session.status = 'expired' + if (session.cleanup) session.cleanup() + // Clean up after another minute + setTimeout(() => lnurlSessions.delete(uniqueHash), 60000) + } + }, SESSION_TIMEOUT_MS) +} + +/** + * Start polling for LNURL-withdraw completion + */ +function startLnurlCompletionPolling( + uniqueHash: string, + onComplete: (preimage: string) => void +): void { + console.log('[LNURL Poll] Starting polling for:', uniqueHash) + + const pollInterval = setInterval(async () => { + try { + const response = await fetch(`${CONFIG.extensionApiUrl}/api/v1/lnurl/${uniqueHash}`) + + if (!response.ok) { + console.warn('[LNURL Poll] Status check failed:', response.status) + return + } + + const data = await response.json() + + // Check if the link has been used + if (data.link?.used > 0 || data.used > 0) { + console.log('[LNURL Poll] Withdrawal claimed!', uniqueHash) + clearInterval(pollInterval) + + const session = lnurlSessions.get(uniqueHash) + if (session) { + session.status = 'claimed' + lnurlSessions.delete(uniqueHash) + } + + // Use a placeholder preimage since LNURL-withdraw doesn't provide one directly + onComplete(`lnurl-withdraw-${uniqueHash}`) + } + } catch (e) { + console.warn('[LNURL Poll] Error checking status:', e) + } + }, 2000) // Poll every 2 seconds + + // Store cleanup function + const session = lnurlSessions.get(uniqueHash) + if (session) { + session.cleanup = () => clearInterval(pollInterval) + } +} + // ============================================================================ // Debit Approval Service // ============================================================================ @@ -762,37 +860,70 @@ function createATMServices( /** * Generate an LNURL-withdraw link for cash-in * - * In production (Tauri), this would use either: - * 1. A local LNURL-withdraw server that calls PayInvoice - * 2. Laser scanner where customer shows invoice QR + * Calls the Lightning.Pub withdraw extension to create a single-use + * LNURL-withdraw link for the exact amount. When a wallet scans this, + * they can claim the sats directly. * - * In browser dev mode, we return a mock LNURL for display. - * The user can paste a real invoice to test e2e payment flow. + * Flow: + * 1. ATM creates withdraw link via extension API + * 2. User scans LNURL QR with any Lightning wallet + * 3. Wallet calls LNURL callback to get invoice params + * 4. Wallet generates invoice and calls withdraw callback + * 5. Extension pays invoice from ATM's Lightning.Pub account */ generateLnurlWithdraw: async (context: ATMContext): Promise => { console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats') + console.log('[ATM Service] Extension API URL:', CONFIG.extensionApiUrl) - if (isBrowser) { - // Browser dev mode: return a mock LNURL for display - // User can paste a real invoice in dev mode to test e2e flow - console.log( - '[ATM Service] Browser mode - mock LNURL for display, use invoice input for real payments' - ) + try { + // Call the withdraw extension to create a link + const response = await fetch(`${CONFIG.extensionApiUrl}/api/v1/withdraw/create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer app_${CONFIG.appId}`, + }, + body: JSON.stringify({ + title: `ATM Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`, + min_withdrawable: context.satsAmount, + max_withdrawable: context.satsAmount, + uses: 1, + wait_time: 0, + }), + }) - // Create a mock LNURL that encodes to a placeholder URL - const mockLnurl = `LNURL1DP68GURN8GHJ7MRWW4EXCTNXD9SHG6NPVCHX7EFWD4JXZENFV9NX2ARGV4NXGWPJX5MRWCMRXVURSWFEVYCNZVE5XUCNQDE4XE3RJDPE8PSNJV3JXQCKXCTXXCEXXVPNVVEXJWFKVC6NXEPSVF3RSCNXV33KXVMRVFSNVWFKXESN2D3E8YUXGVNPVD3RWD3CV5UNVCE4V5URYWPNVYMN2E3SXFJRSWP3VGMRJCTYVYENXVFSV5URQVNXVDJXXVE4XCUNVVEEVY6RWD3E893NXCN9XAJNQVFEX4JNZCE5X5CRVV3NV5CNXCF3XSEK2CF5X43KWVPSXQCRQVPSXQCRQVPSXQCRQVPSXQCRQVPSXQCRQVPSXQCRQVPSXQCRQVPSXQCRQVPSXQCRQVPSXQCRQVPSXQCRQVPS2D3333` + if (!response.ok) { + const errorText = await response.text() + console.error('[ATM Service] Extension API error:', response.status, errorText) + throw new Error(`Failed to create LNURL-withdraw: ${response.status} ${errorText}`) + } - // Small delay to simulate async operation - await new Promise((resolve) => setTimeout(resolve, 200)) + const data = await response.json() + console.log('[ATM Service] Withdraw link created:', data) - return mockLnurl + if (!data.link?.lnurl) { + throw new Error('Extension did not return LNURL in response') + } + + // Register session for tracking completion + if (context.cashInSessionId) { + registerLnurlSession(context.cashInSessionId, data.link.unique_hash, context.satsAmount) + + // Start polling for completion + startLnurlCompletionPolling(data.link.unique_hash, (preimage) => { + console.log('[ATM Service] LNURL-withdraw claimed! Preimage:', preimage.slice(0, 16)) + if (onPaymentCallback) { + onPaymentCallback(preimage) + } + }) + } + + console.log('[ATM Service] LNURL-withdraw generated:', data.link.lnurl.slice(0, 40) + '...') + return data.link.lnurl + } catch (error) { + console.error('[ATM Service] Failed to generate LNURL-withdraw:', error) + throw error } - - // Production mode (Tauri): Would use local LNURL server or scanner - // For now, throw an error indicating this needs implementation - throw new Error( - 'LNURL-withdraw not implemented for production mode yet. Use laser scanner flow.' - ) }, /** diff --git a/lamassu-next/apps/machine/src/stores/atm.ts b/lamassu-next/apps/machine/src/stores/atm.ts index 6d472a2..f45e967 100644 --- a/lamassu-next/apps/machine/src/stores/atm.ts +++ b/lamassu-next/apps/machine/src/stores/atm.ts @@ -103,6 +103,10 @@ export const useAtmStore = defineStore('atm', () => { const isPayingInvoice = ref(false) const isRequestingDebit = ref(false) const paymentError = ref(null) + const lnurlWithdrawUri = ref(null) + + // Store reference to ATM services for direct calls + let atmServicesRef: ATMServices | null = null // Computed const currentState = computed(() => { @@ -141,6 +145,7 @@ export const useAtmStore = defineStore('atm', () => { // Actions function initialize(services: ATMServices = mockServices) { + atmServicesRef = services const machine = createATMMachine(services) actor.value = createActor(machine) @@ -335,6 +340,29 @@ export const useAtmStore = defineStore('atm', () => { } } + /** + * Generate an LNURL-withdraw link for the current cash-in amount + * This allows any Lightning wallet to claim the sats + */ + async function generateLnurlWithdraw(): Promise { + if (!atmServicesRef) { + throw new Error('ATM services not initialized') + } + + const ctx = context.value + if (!ctx?.satsAmount) { + throw new Error('No amount available - insert cash first') + } + + console.log('[ATM] Generating LNURL-withdraw for', ctx.satsAmount, 'sats') + + const lnurl = await atmServicesRef.generateLnurlWithdraw(ctx) + lnurlWithdrawUri.value = lnurl + + console.log('[ATM] LNURL-withdraw generated:', lnurl.slice(0, 40) + '...') + return lnurl + } + /** * Initialize with real HAL hardware + Lightning services * @@ -539,6 +567,8 @@ export const useAtmStore = defineStore('atm', () => { paymentReceived, payInvoice, requestDebit, + generateLnurlWithdraw, + lnurlWithdrawUri, skipReceipt, cashTaken, addDenomination, diff --git a/lamassu-next/apps/machine/src/views/CashInView.vue b/lamassu-next/apps/machine/src/views/CashInView.vue index 0fbdd9f..b787e2b 100644 --- a/lamassu-next/apps/machine/src/views/CashInView.vue +++ b/lamassu-next/apps/machine/src/views/CashInView.vue @@ -22,6 +22,26 @@ const invoiceInput = ref('') // Copy state for ndebit URI const copied = ref(false) +// Tab state for QR display (ndebit vs LNURL-withdraw) +const activeTab = ref<'ndebit' | 'lnurl'>('ndebit') +const lnurlWithdraw = ref(null) +const isGeneratingLnurl = ref(false) + +// Switch to LNURL tab and generate LNURL-withdraw if needed +async function switchToLnurl() { + activeTab.value = 'lnurl' + if (!lnurlWithdraw.value && !isGeneratingLnurl.value) { + isGeneratingLnurl.value = true + try { + lnurlWithdraw.value = await atmStore.generateLnurlWithdraw() + } catch (err) { + console.error('[CashIn] Failed to generate LNURL-withdraw:', err) + } finally { + isGeneratingLnurl.value = false + } + } +} + // Copy ndebit URI to clipboard async function copyNdebit() { const uri = atmStore.context?.ndebitUri @@ -203,7 +223,7 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque - + Scan to Receive Sats @@ -212,35 +232,66 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque {{ formatSats(context.satsAmount) }} sats + +
+ + +
- -
+ +

Scan with Shock Wallet or compatible CLINK wallet

+ +
+
+ + +
+

+ Or copy and paste into your wallet +

+
- -
-
- - + +
+ + +
+

Failed to generate LNURL

-

- Or copy and paste into your wallet +

+ Scan with any Lightning wallet (Phoenix, Zeus, Wallet of Satoshi, etc.)

- +

How it works:

-
    -
  1. 1. Scan the QR code with your Lightning wallet
  2. +
      +
    1. 1. Scan the QR code with your CLINK-compatible wallet
    2. 2. Your wallet creates an invoice for {{ formatSats(context?.satsAmount || 0) }} sats @@ -248,6 +299,15 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
    3. 3. Confirm the claim in your wallet
    4. 4. Receive your sats instantly!
    +
      +
    1. 1. Scan the QR code with any Lightning wallet
    2. +
    3. + 2. Your wallet shows a withdrawal for + {{ formatSats(context?.satsAmount || 0) }} sats +
    4. +
    5. 3. Confirm the withdrawal in your wallet
    6. +
    7. 4. Sats arrive in your wallet!
    8. +