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,