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:
parent
c199431b8e
commit
b410917e52
3 changed files with 245 additions and 41 deletions
|
|
@ -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() {
|
|||
>
|
||||
<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">
|
||||
<Badge
|
||||
v-if="atmStore.balanceSats !== null"
|
||||
class="bg-bitcoin/20 font-mono text-bitcoin border border-bitcoin/30"
|
||||
>
|
||||
{{ formatSats(atmStore.balanceSats) }} sats
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="atmStore.connectionStatus === 'connected'"
|
||||
class="bg-success text-success-foreground"
|
||||
|
|
|
|||
|
|
@ -37,9 +37,6 @@ import type { ATMServices, ATMContext } from '@lamassu/state-machine'
|
|||
// Import Electron types
|
||||
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)
|
||||
const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined
|
||||
|
||||
|
|
@ -64,6 +61,8 @@ interface LightningConfig {
|
|||
adminToken: string
|
||||
atmPrivateKey: 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',
|
||||
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<LightningConfig> {
|
|||
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<LightningConfig> {
|
|||
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<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)
|
||||
// ============================================================================
|
||||
|
|
@ -676,14 +789,33 @@ export async function initializeLightningServices(): Promise<LightningServices>
|
|||
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<string> => {
|
||||
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({
|
||||
// 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<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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue