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()
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer app_${CONFIG.appId}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: `ATM Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
|
title: `ATM Cash-In ${context.cashInSessionId?.slice(0, 8) || 'session'}`,
|
||||||
min_withdrawable: context.satsAmount,
|
min_withdrawable: context.satsAmount,
|
||||||
max_withdrawable: context.satsAmount,
|
max_withdrawable: context.satsAmount,
|
||||||
uses: 1,
|
uses: 1,
|
||||||
wait_time: 0,
|
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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue