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:
parent
940afe3a65
commit
0c8a130413
4 changed files with 260 additions and 37 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue