diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index 1e85a76..f710c59 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -6,6 +6,10 @@ import { Button } from '@/components/ui/button' const atmStore = useAtmStore() +function formatSats(sats: number): string { + return sats.toLocaleString() +} + onMounted(async () => { // Initialize the ATM state machine with Lightning.Pub await atmStore.initializeWithLightning() @@ -28,8 +32,14 @@ function toggleLiveServices() { > - +
+ + {{ formatSats(atmStore.balanceSats) }} sats + { adminToken: 'lamassu-dev-admin-token', atmPrivateKey: '', 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 @@ -93,6 +94,8 @@ async function loadLightningConfig(): Promise { adminToken: runtimeConfig.adminToken || defaults.adminToken, atmPrivateKey: runtimeConfig.atmPrivateKey || defaults.atmPrivateKey, appId: runtimeConfig.appId || defaults.appId, + appToken: runtimeConfig.appToken || defaults.appToken, + linkingToken: runtimeConfig.linkingToken || defaults.linkingToken, } } catch (e) { console.warn('[Lightning] Failed to get runtime config from Electron:', e) @@ -108,12 +111,122 @@ async function loadLightningConfig(): Promise { 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, + 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 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 { + 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) // ============================================================================ @@ -676,14 +789,33 @@ export async function initializeLightningServices(): Promise console.log('[Lightning] Connected to relay:', CONFIG.relayUrl) // 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({ accountPubkey: CONFIG.lightningPubPubkey, relays: [CONFIG.relayUrl], + appId: CONFIG.appId, }) lightningPub.initialize(nostrClient, identity) 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 const clink = new CLINKClient({ nostrClient, @@ -868,57 +1000,50 @@ function createATMServices( /** * Generate an LNURL-withdraw link for cash-in * - * 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. + * Uses Nostr RPC (NIP-44 encrypted) to create the withdraw link, + * keeping ATM-to-Lightning.Pub communication fully encrypted. + * + * 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: - * 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 - * 3. Wallet calls LNURL callback to get invoice params - * 4. Wallet generates invoice and calls withdraw callback + * 3. Wallet calls LNURL callback (HTTP) to get invoice params + * 4. Wallet generates invoice and calls withdraw callback (HTTP) * 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) + console.log('[ATM Service] Using Nostr RPC (NIP-44 encrypted)') 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, - }), + // Call the withdraw extension via Nostr RPC (encrypted) + const response = await lightningPub.createWithdrawLink({ + 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) { - 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}`) - } + console.log('[ATM Service] Withdraw link created via RPC:', response) - const data = await response.json() - console.log('[ATM Service] Withdraw link created:', data) - - if (!data.link?.lnurl) { - throw new Error('Extension did not return LNURL in response') + if (!response.link?.lnurl) { + throw new Error('RPC did not return LNURL in response') } // Register session for tracking completion 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 - startLnurlCompletionPolling(data.link.unique_hash, (preimage) => { + // Start polling for completion (still uses HTTP - no RPC alternative yet) + startLnurlCompletionPolling(response.link.unique_hash, (preimage) => { console.log('[ATM Service] LNURL-withdraw claimed! Preimage:', preimage.slice(0, 16)) if (onPaymentCallback) { onPaymentCallback(preimage) @@ -926,8 +1051,11 @@ function createATMServices( }) } - console.log('[ATM Service] LNURL-withdraw generated:', data.link.lnurl.slice(0, 40) + '...') - return data.link.lnurl + console.log( + '[ATM Service] LNURL-withdraw generated:', + response.link.lnurl.slice(0, 40) + '...' + ) + return response.link.lnurl } catch (error) { console.error('[ATM Service] Failed to generate LNURL-withdraw:', error) throw error @@ -975,6 +1103,23 @@ function createATMServices( 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 => { + 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 */ diff --git a/apps/machine/src/stores/atm.ts b/apps/machine/src/stores/atm.ts index f45e967..ac4e606 100644 --- a/apps/machine/src/stores/atm.ts +++ b/apps/machine/src/stores/atm.ts @@ -44,6 +44,11 @@ const mockServices: ATMServices = { 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) => { console.log('[Mock] Sending Nostr receipt to', context.userNpub) }, @@ -104,6 +109,8 @@ export const useAtmStore = defineStore('atm', () => { const isRequestingDebit = ref(false) const paymentError = ref(null) const lnurlWithdrawUri = ref(null) + const balanceSats = ref(null) + let stopBalanceWatch: (() => void) | null = null // Store reference to ATM services for direct calls let atmServicesRef: ATMServices | null = null @@ -177,6 +184,22 @@ export const useAtmStore = defineStore('atm', () => { lightningPub.value = services.lightningPub 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 services.onPaymentReceived((preimage) => { 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 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) => { send({ type: 'BILL_INSERTED', denomination }) }, @@ -539,6 +587,7 @@ export const useAtmStore = defineStore('atm', () => { debugMode, useLiveServices, connectionStatus, + balanceSats, halServices, isPayingInvoice, isRequestingDebit,