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 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-14 15:28:46 -05:00
commit 0c8a130413
4 changed files with 260 additions and 37 deletions

View file

@ -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",

View file

@ -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<LightningConfig> {
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<LightningConfig> {
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<LightningConfig> {
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<string, LnurlSession>()
/**
* 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<string> => {
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.'
)
},
/**

View file

@ -103,6 +103,10 @@ export const useAtmStore = defineStore('atm', () => {
const isPayingInvoice = ref(false)
const isRequestingDebit = ref(false)
const paymentError = ref<string | null>(null)
const lnurlWithdrawUri = ref<string | null>(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<string> {
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,

View file

@ -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<string | null>(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
</CardContent>
</Card>
<!-- Display NDebit QR -->
<!-- Display QR (ndebit or LNURL-withdraw) -->
<Card v-else-if="nestedState === 'displayingQR'" class="w-[480px] border-0 bg-white/5">
<CardHeader class="text-center">
<CardTitle>Scan to Receive Sats</CardTitle>
@ -212,35 +232,66 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
{{ formatSats(context.satsAmount) }} sats
</Badge>
</CardDescription>
<!-- Tab buttons for switching between ndebit and LNURL -->
<div class="flex gap-2 mt-3 justify-center">
<Button
:variant="activeTab === 'ndebit' ? 'default' : 'outline'"
size="sm"
@click="activeTab = 'ndebit'"
>
CLINK (Shock Wallet)
</Button>
<Button
:variant="activeTab === 'lnurl' ? 'default' : 'outline'"
size="sm"
@click="switchToLnurl"
>
LNURL (Any Wallet)
</Button>
</div>
</CardHeader>
<CardContent class="space-y-6">
<!-- NDebit QR Code (Primary) -->
<div class="flex flex-col items-center">
<!-- NDebit QR Code (shown when ndebit tab is active) -->
<div v-if="activeTab === 'ndebit'" class="flex flex-col items-center">
<QRCode v-if="context?.ndebitUri" :value="context.ndebitUri" :size="220" />
<p class="mt-4 text-center text-sm text-muted-foreground">
Scan with Shock Wallet or compatible CLINK wallet
</p>
<!-- Copyable NDebit URI -->
<div v-if="context?.ndebitUri" class="mt-4 w-full space-y-2">
<div class="flex gap-2">
<Input :model-value="context.ndebitUri" readonly class="flex-1 font-mono text-xs" />
<Button variant="outline" size="sm" @click="copyNdebit">
{{ copied ? '✓ Copied' : 'Copy' }}
</Button>
</div>
<p class="text-center text-xs text-muted-foreground">
Or copy and paste into your wallet
</p>
</div>
</div>
<!-- Copyable NDebit URI -->
<div v-if="context?.ndebitUri" class="space-y-2">
<div class="flex gap-2">
<Input :model-value="context.ndebitUri" readonly class="flex-1 font-mono text-xs" />
<Button variant="outline" size="sm" @click="copyNdebit">
{{ copied ? '✓ Copied' : 'Copy' }}
</Button>
<!-- LNURL-withdraw QR Code (shown when lnurl tab is active) -->
<div v-else-if="activeTab === 'lnurl'" class="flex flex-col items-center">
<Skeleton v-if="isGeneratingLnurl" class="h-[220px] w-[220px]" />
<QRCode v-else-if="lnurlWithdraw" :value="lnurlWithdraw" :size="220" />
<div
v-else
class="flex h-[220px] w-[220px] items-center justify-center rounded-lg bg-white/10"
>
<p class="text-sm text-muted-foreground">Failed to generate LNURL</p>
</div>
<p class="text-center text-xs text-muted-foreground">
Or copy and paste into your wallet
<p class="mt-4 text-center text-sm text-muted-foreground">
Scan with any Lightning wallet (Phoenix, Zeus, Wallet of Satoshi, etc.)
</p>
</div>
<!-- Instructions -->
<!-- Instructions (different based on active tab) -->
<Alert class="border-green-500/50 bg-green-500/10">
<AlertDescription>
<p class="mb-2 text-sm font-medium text-green-400">How it works:</p>
<ol class="space-y-1 text-xs text-muted-foreground">
<li>1. Scan the QR code with your Lightning wallet</li>
<ol v-if="activeTab === 'ndebit'" class="space-y-1 text-xs text-muted-foreground">
<li>1. Scan the QR code with your CLINK-compatible wallet</li>
<li>
2. Your wallet creates an invoice for
{{ formatSats(context?.satsAmount || 0) }} sats
@ -248,6 +299,15 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
<li>3. Confirm the claim in your wallet</li>
<li>4. Receive your sats instantly!</li>
</ol>
<ol v-else class="space-y-1 text-xs text-muted-foreground">
<li>1. Scan the QR code with any Lightning wallet</li>
<li>
2. Your wallet shows a withdrawal for
{{ formatSats(context?.satsAmount || 0) }} sats
</li>
<li>3. Confirm the withdrawal in your wallet</li>
<li>4. Sats arrive in your wallet!</li>
</ol>
</AlertDescription>
</Alert>