feat(machine): LNURL-withdraw support and real-time balance display

- Add LNURL-withdraw as payment option in cash-in flow
- Generate withdraw links via Nostr RPC (NIP-44 encrypted)
- Poll withdraw link status to detect wallet claims
- Add npub linking during Lightning.Pub initialization
- Display live balance badge in ATM UI header
- Subscribe to balance updates via LiveUserOperation events

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-16 16:58:05 -05:00
commit b410917e52
3 changed files with 245 additions and 41 deletions

View file

@ -6,6 +6,10 @@ import { Button } from '@/components/ui/button'
const atmStore = useAtmStore() const atmStore = useAtmStore()
function formatSats(sats: number): string {
return sats.toLocaleString()
}
onMounted(async () => { onMounted(async () => {
// Initialize the ATM state machine with Lightning.Pub // Initialize the ATM state machine with Lightning.Pub
await atmStore.initializeWithLightning() await atmStore.initializeWithLightning()
@ -28,8 +32,14 @@ function toggleLiveServices() {
> >
<router-view /> <router-view />
<!-- Connection status (top right) --> <!-- Connection status + balance (top right) -->
<div class="fixed right-4 top-4 z-50 flex items-center gap-2"> <div class="fixed right-4 top-4 z-50 flex items-center gap-2">
<Badge
v-if="atmStore.balanceSats !== null"
class="bg-bitcoin/20 font-mono text-bitcoin border border-bitcoin/30"
>
{{ formatSats(atmStore.balanceSats) }} sats
</Badge>
<Badge <Badge
v-if="atmStore.connectionStatus === 'connected'" v-if="atmStore.connectionStatus === 'connected'"
class="bg-success text-success-foreground" class="bg-success text-success-foreground"

View file

@ -37,9 +37,6 @@ import type { ATMServices, ATMContext } from '@lamassu/state-machine'
// Import Electron types // Import Electron types
import type {} from '@/types/electron' import type {} from '@/types/electron'
// Check if we're in a browser environment
const isBrowser = typeof window !== 'undefined'
// Check if we're running in Electron (electronAPI is exposed via preload) // Check if we're running in Electron (electronAPI is exposed via preload)
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
@ -64,6 +61,8 @@ interface LightningConfig {
adminToken: string adminToken: string
atmPrivateKey: string atmPrivateKey: string
appId: string appId: string
appToken: string // JWT token for app API calls (user management, linking)
linkingToken: string
} }
/** /**
@ -79,6 +78,8 @@ async function loadLightningConfig(): Promise<LightningConfig> {
adminToken: 'lamassu-dev-admin-token', adminToken: 'lamassu-dev-admin-token',
atmPrivateKey: '', atmPrivateKey: '',
appId: '152fd75c134226824e5183cd9c02a35b4972e995f39e7c0e4ec215ae8c1fae1d', // ATM app ID appId: '152fd75c134226824e5183cd9c02a35b4972e995f39e7c0e4ec215ae8c1fae1d', // ATM app ID
appToken: '', // JWT token from app creation
linkingToken: '', // Token to link Nostr pubkey to app user balance
} }
// In Electron, get runtime config from main process // In Electron, get runtime config from main process
@ -93,6 +94,8 @@ async function loadLightningConfig(): Promise<LightningConfig> {
adminToken: runtimeConfig.adminToken || defaults.adminToken, adminToken: runtimeConfig.adminToken || defaults.adminToken,
atmPrivateKey: runtimeConfig.atmPrivateKey || defaults.atmPrivateKey, atmPrivateKey: runtimeConfig.atmPrivateKey || defaults.atmPrivateKey,
appId: runtimeConfig.appId || defaults.appId, appId: runtimeConfig.appId || defaults.appId,
appToken: runtimeConfig.appToken || defaults.appToken,
linkingToken: runtimeConfig.linkingToken || defaults.linkingToken,
} }
} catch (e) { } catch (e) {
console.warn('[Lightning] Failed to get runtime config from Electron:', e) console.warn('[Lightning] Failed to get runtime config from Electron:', e)
@ -108,12 +111,122 @@ async function loadLightningConfig(): Promise<LightningConfig> {
adminToken: import.meta.env.VITE_ADMIN_TOKEN || defaults.adminToken, adminToken: import.meta.env.VITE_ADMIN_TOKEN || defaults.adminToken,
atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey, atmPrivateKey: import.meta.env.VITE_ATM_PRIVATE_KEY || defaults.atmPrivateKey,
appId: import.meta.env.VITE_APP_ID || defaults.appId, appId: import.meta.env.VITE_APP_ID || defaults.appId,
appToken: import.meta.env.VITE_APP_TOKEN || defaults.appToken,
linkingToken: import.meta.env.VITE_LINKING_TOKEN || defaults.linkingToken,
} }
} }
// Config is loaded async now - will be set in initializeLightningServices // Config is loaded async now - will be set in initializeLightningServices
let CONFIG: LightningConfig let CONFIG: LightningConfig
// ATM user identifier - consistent across restarts
const ATM_USER_IDENTIFIER = 'atm-primary-user'
/**
* Link Nostr pubkey to App User
*
* This is critical for LNURL-withdraw to work correctly. The Extension API
* uses App Users (created via HTTP API), but Nostr RPC creates separate
* Nostr Users. By linking our npub to the App User BEFORE any operations,
* all subsequent Nostr RPC calls will find and use the linked App User.
*
* Flow:
* 1. Create App User via admin API (if not exists)
* 2. Request a fresh linking token via admin API
* 3. Immediately link via Nostr RPC (before 2-min token expiry)
*/
async function linkNostrPubkeyToAppUser(
config: LightningConfig,
identity: MachineIdentity
): Promise<void> {
console.log('[Linking] Starting npub linking process...')
// Step 1: Create App User if needed
console.log('[Linking] Ensuring App User exists:', ATM_USER_IDENTIFIER)
const authToken = config.appToken || `app_${config.appId}`
console.log(
'[Linking] Using auth token:',
authToken.startsWith('eyJ') ? 'JWT token' : 'app_id fallback'
)
const createUserResponse = await fetch(`${config.lightningPubApiUrl}/api/app/user/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
identifier: ATM_USER_IDENTIFIER,
fail_if_exists: false,
balance: 0,
}),
})
if (!createUserResponse.ok) {
const errorText = await createUserResponse.text()
// User might already exist - that's OK
if (!errorText.includes('already exists')) {
console.log('[Linking] Create user response:', createUserResponse.status, errorText)
}
} else {
console.log('[Linking] App User created/exists')
}
// Step 2: Request fresh linking token
console.log('[Linking] Requesting fresh linking token...')
const linkingTokenResponse = await fetch(`${config.lightningPubApiUrl}/api/app/user/npub/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
user_identifier: ATM_USER_IDENTIFIER,
}),
})
if (!linkingTokenResponse.ok) {
const errorText = await linkingTokenResponse.text()
// May fail if already linked - check for that
if (errorText.includes('already has an npub')) {
console.log('[Linking] User already has npub linked')
return
}
throw new Error(`Failed to get linking token: ${linkingTokenResponse.status} ${errorText}`)
}
const linkingData = await linkingTokenResponse.json()
const token = linkingData.token
if (!token) {
throw new Error('No token in linking response')
}
console.log('[Linking] Got fresh token, linking immediately...')
// Step 3: Link via Nostr RPC immediately
// Create a temporary LightningPub client just for linking
const tempClient = new NostrClient({
relays: [{ url: config.relayUrl }],
identity,
})
await tempClient.connect()
const tempLP = new LightningPubClient({
accountPubkey: config.lightningPubPubkey,
relays: [config.relayUrl],
appId: config.appId,
})
tempLP.initialize(tempClient, identity)
try {
await tempLP.linkNpub(token)
console.log('[Linking] Successfully linked npub to App User')
} finally {
tempLP.disconnect()
tempClient.disconnect()
}
}
// ============================================================================ // ============================================================================
// Cash-in Session Management (for ndebit single-use protection) // Cash-in Session Management (for ndebit single-use protection)
// ============================================================================ // ============================================================================
@ -676,14 +789,33 @@ export async function initializeLightningServices(): Promise<LightningServices>
console.log('[Lightning] Connected to relay:', CONFIG.relayUrl) console.log('[Lightning] Connected to relay:', CONFIG.relayUrl)
// Create Lightning.Pub client // Create Lightning.Pub client
// Pass appId to ensure Nostr-authenticated user is created under the same app
// as HTTP API users (for Extension API compatibility like LNURL-withdraw)
const lightningPub = new LightningPubClient({ const lightningPub = new LightningPubClient({
accountPubkey: CONFIG.lightningPubPubkey, accountPubkey: CONFIG.lightningPubPubkey,
relays: [CONFIG.relayUrl], relays: [CONFIG.relayUrl],
appId: CONFIG.appId,
}) })
lightningPub.initialize(nostrClient, identity) lightningPub.initialize(nostrClient, identity)
console.log('[Lightning] Lightning.Pub client initialized') console.log('[Lightning] Lightning.Pub client initialized')
// Link Nostr pubkey to app user - CRITICAL for LNURL-withdraw
// The Extension API uses App Users, but Nostr RPC creates separate Nostr Users.
// We fetch a fresh linking token from the admin API and link immediately,
// before the 2-minute token expiry. This ensures:
// 1. Subsequent Nostr RPC calls find and use the linked App User
// 2. Funding and LNURL-withdraw use the same user (same balance)
if (CONFIG.appId && CONFIG.adminToken) {
try {
await linkNostrPubkeyToAppUser(CONFIG, identity)
} catch (e) {
console.log('[Lightning] Note: linking skipped or already linked:', e)
}
} else {
console.log('[Lightning] Skipping npub linking (no appId or adminToken configured)')
}
// Create CLINK client // Create CLINK client
const clink = new CLINKClient({ const clink = new CLINKClient({
nostrClient, nostrClient,
@ -868,57 +1000,50 @@ function createATMServices(
/** /**
* Generate an LNURL-withdraw link for cash-in * Generate an LNURL-withdraw link for cash-in
* *
* Calls the Lightning.Pub withdraw extension to create a single-use * Uses Nostr RPC (NIP-44 encrypted) to create the withdraw link,
* LNURL-withdraw link for the exact amount. When a wallet scans this, * keeping ATM-to-Lightning.Pub communication fully encrypted.
* they can claim the sats directly. *
* Note: The LNURL protocol itself still uses HTTP (wallets call HTTP
* endpoints to claim). For fully encrypted cash-in including the
* wallet interaction, use CLINK (ndebit) instead.
* *
* Flow: * Flow:
* 1. ATM creates withdraw link via extension API * 1. ATM creates withdraw link via Nostr RPC (withdraw.createLink)
* 2. User scans LNURL QR with any Lightning wallet * 2. User scans LNURL QR with any Lightning wallet
* 3. Wallet calls LNURL callback to get invoice params * 3. Wallet calls LNURL callback (HTTP) to get invoice params
* 4. Wallet generates invoice and calls withdraw callback * 4. Wallet generates invoice and calls withdraw callback (HTTP)
* 5. Extension pays invoice from ATM's Lightning.Pub account * 5. Extension pays invoice from ATM's Lightning.Pub account
*/ */
generateLnurlWithdraw: async (context: ATMContext): Promise<string> => { generateLnurlWithdraw: async (context: ATMContext): Promise<string> => {
console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats') console.log('[ATM Service] Generating LNURL-withdraw for', context.satsAmount, 'sats')
console.log('[ATM Service] Extension API URL:', CONFIG.extensionApiUrl) console.log('[ATM Service] Using Nostr RPC (NIP-44 encrypted)')
try { try {
// Call the withdraw extension to create a link // Call the withdraw extension via Nostr RPC (encrypted)
const response = await fetch(`${CONFIG.extensionApiUrl}/api/v1/withdraw/create`, { const response = await lightningPub.createWithdrawLink({
method: 'POST', title: `ATM Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
headers: { min_withdrawable: context.satsAmount,
'Content-Type': 'application/json', max_withdrawable: context.satsAmount,
Authorization: `Bearer app_${CONFIG.appId}`, uses: 1,
}, wait_time: 0,
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,
}),
}) })
if (!response.ok) { console.log('[ATM Service] Withdraw link created via RPC:', response)
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}`)
}
const data = await response.json() if (!response.link?.lnurl) {
console.log('[ATM Service] Withdraw link created:', data) throw new Error('RPC did not return LNURL in response')
if (!data.link?.lnurl) {
throw new Error('Extension did not return LNURL in response')
} }
// Register session for tracking completion // Register session for tracking completion
if (context.cashInSessionId) { if (context.cashInSessionId) {
registerLnurlSession(context.cashInSessionId, data.link.unique_hash, context.satsAmount) registerLnurlSession(
context.cashInSessionId,
response.link.unique_hash,
context.satsAmount
)
// Start polling for completion // Start polling for completion (still uses HTTP - no RPC alternative yet)
startLnurlCompletionPolling(data.link.unique_hash, (preimage) => { startLnurlCompletionPolling(response.link.unique_hash, (preimage) => {
console.log('[ATM Service] LNURL-withdraw claimed! Preimage:', preimage.slice(0, 16)) console.log('[ATM Service] LNURL-withdraw claimed! Preimage:', preimage.slice(0, 16))
if (onPaymentCallback) { if (onPaymentCallback) {
onPaymentCallback(preimage) onPaymentCallback(preimage)
@ -926,8 +1051,11 @@ function createATMServices(
}) })
} }
console.log('[ATM Service] LNURL-withdraw generated:', data.link.lnurl.slice(0, 40) + '...') console.log(
return data.link.lnurl '[ATM Service] LNURL-withdraw generated:',
response.link.lnurl.slice(0, 40) + '...'
)
return response.link.lnurl
} catch (error) { } catch (error) {
console.error('[ATM Service] Failed to generate LNURL-withdraw:', error) console.error('[ATM Service] Failed to generate LNURL-withdraw:', error)
throw error throw error
@ -975,6 +1103,23 @@ function createATMServices(
return 1000 // sats per USD (~$100k BTC) return 1000 // sats per USD (~$100k BTC)
}, },
/**
* Get ATM's available balance in sats
* Used to limit cash-in transactions to what the ATM can pay out
*/
getAvailableBalance: async (): Promise<number> => {
console.log('[ATM Service] Fetching available balance from Lightning.Pub')
try {
const { balanceSats } = await lightningPub.getBalance()
console.log('[ATM Service] Available balance:', balanceSats, 'sats')
return balanceSats
} catch (error) {
console.error('[ATM Service] Failed to get balance:', error)
// Return 0 to prevent any cash-in if we can't verify balance
return 0
}
},
/** /**
* Send receipt via Nostr DM * Send receipt via Nostr DM
*/ */

View file

@ -44,6 +44,11 @@ const mockServices: ATMServices = {
return 1000 // 1000 sats per USD (~$100k BTC) return 1000 // 1000 sats per USD (~$100k BTC)
}, },
getAvailableBalance: async () => {
console.log('[Mock] Fetching available balance')
return 500000 // 500k sats available in mock mode
},
sendNostrReceipt: async (context) => { sendNostrReceipt: async (context) => {
console.log('[Mock] Sending Nostr receipt to', context.userNpub) console.log('[Mock] Sending Nostr receipt to', context.userNpub)
}, },
@ -104,6 +109,8 @@ export const useAtmStore = defineStore('atm', () => {
const isRequestingDebit = ref(false) const isRequestingDebit = ref(false)
const paymentError = ref<string | null>(null) const paymentError = ref<string | null>(null)
const lnurlWithdrawUri = ref<string | null>(null) const lnurlWithdrawUri = ref<string | null>(null)
const balanceSats = ref<number | null>(null)
let stopBalanceWatch: (() => void) | null = null
// Store reference to ATM services for direct calls // Store reference to ATM services for direct calls
let atmServicesRef: ATMServices | null = null let atmServicesRef: ATMServices | null = null
@ -177,6 +184,22 @@ export const useAtmStore = defineStore('atm', () => {
lightningPub.value = services.lightningPub lightningPub.value = services.lightningPub
clinkClient.value = services.clink clinkClient.value = services.clink
// Fetch initial balance and subscribe to live updates
try {
const { balanceSats: initial } = await services.lightningPub.getBalance()
balanceSats.value = initial
console.log('[ATM] Initial balance:', initial, 'sats')
} catch (e) {
console.warn('[ATM] Failed to fetch initial balance:', e)
}
// Stop any previous subscription
stopBalanceWatch?.()
stopBalanceWatch = services.lightningPub.watchBalance((newBalance) => {
balanceSats.value = newBalance
console.log('[ATM] Balance updated:', newBalance, 'sats')
})
// Wire up payment received callback // Wire up payment received callback
services.onPaymentReceived((preimage) => { services.onPaymentReceived((preimage) => {
console.log('[ATM] Payment received via CLINK, preimage:', preimage.slice(0, 16) + '...') console.log('[ATM] Payment received via CLINK, preimage:', preimage.slice(0, 16) + '...')
@ -417,6 +440,31 @@ export const useAtmStore = defineStore('atm', () => {
// Wire validator events to state machine // Wire validator events to state machine
hal.connectValidator({ hal.connectValidator({
shouldAcceptBill: (denomination) => {
// Check if accepting this bill would exceed available balance
const ctx = context.value
if (!ctx || ctx.exchangeRate === 0) {
console.warn('[ATM] Cannot check balance: no exchange rate')
return true // Allow if we don't have rate yet (shouldn't happen)
}
// Calculate what the new sats amount would be
const newFiatCents = ctx.fiatAmount + denomination * 100
const newFiatUnits = newFiatCents / 100
const grossSats = Math.floor(newFiatUnits * ctx.exchangeRate)
const fee = Math.floor(grossSats * ctx.feePercent)
const newSatsAmount = grossSats - fee
// Check against available balance
if (newSatsAmount > ctx.availableBalance) {
console.log(
`[ATM] Rejecting $${denomination} bill: would need ${newSatsAmount} sats but only ${ctx.availableBalance} available`
)
return false
}
return true
},
onBillInserted: (denomination) => { onBillInserted: (denomination) => {
send({ type: 'BILL_INSERTED', denomination }) send({ type: 'BILL_INSERTED', denomination })
}, },
@ -539,6 +587,7 @@ export const useAtmStore = defineStore('atm', () => {
debugMode, debugMode,
useLiveServices, useLiveServices,
connectionStatus, connectionStatus,
balanceSats,
halServices, halServices,
isPayingInvoice, isPayingInvoice,
isRequestingDebit, isRequestingDebit,