feat: implement ndebit debit approval with single-use protection
Implement the complete ATM-side ndebit (cash-in) flow with single-use protection to prevent double-spending from the same QR code. Key features: - Debit approval service subscribes to GetLiveDebitRequests from Lightning.Pub - Session-based validation: each ndebit QR is valid for one use only - Amount decoded from BOLT11 invoice when not provided in message - Atomic session marking prevents race conditions - Triple protection: event ID tracking, invoice tracking, session status Changes: - apps/machine/src/services/lightning.ts: Add debit approval service with session management, BOLT11 amount decoding, and RespondToDebit approval - packages/state-machine: Add cashInSessionId to context and generation - docs/ndebit-cash-in-flow.md: Comprehensive documentation of the flow, architecture diagrams, and troubleshooting guide Infrastructure improvements: - Auto-miner service for regtest (keeps LND synced during testing) - Electron .env file loading for runtime configuration - Preload script compilation for Electron IPC Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e185947259
commit
595ac3b863
10 changed files with 991 additions and 52 deletions
|
|
@ -8,6 +8,36 @@
|
||||||
|
|
||||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
// ESM equivalent of __dirname
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
// Load .env file manually (Electron main process doesn't have Vite's env loading)
|
||||||
|
function loadEnvFile() {
|
||||||
|
const envPath = path.join(__dirname, '..', '.env')
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf-8')
|
||||||
|
for (const line of envContent.split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (trimmed && !trimmed.startsWith('#')) {
|
||||||
|
const [key, ...valueParts] = trimmed.split('=')
|
||||||
|
if (key && valueParts.length > 0) {
|
||||||
|
process.env[key] = valueParts.join('=')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('[Electron] Loaded .env file from:', envPath)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Electron] Failed to load .env file:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEnvFile()
|
||||||
|
|
||||||
// Determine if we're in development
|
// Determine if we're in development
|
||||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "CommonJS",
|
"module": "ES2022",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"outDir": "../dist-electron",
|
"outDir": "../dist-electron",
|
||||||
"rootDir": "."
|
"rootDir": "."
|
||||||
},
|
},
|
||||||
"include": ["./**/*.ts"]
|
"include": ["main.ts"],
|
||||||
|
"exclude": ["preload.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
lamassu-next/apps/machine/electron/tsconfig.preload.json
Normal file
13
lamassu-next/apps/machine/electron/tsconfig.preload.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "../dist-electron",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["preload.ts"]
|
||||||
|
}
|
||||||
|
|
@ -13,8 +13,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"",
|
"dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"",
|
||||||
"dev:vite": "vite",
|
"dev:vite": "vite",
|
||||||
"electron:dev": "tsc -p electron/tsconfig.json && electron dist-electron/main.js",
|
"electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js",
|
||||||
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json",
|
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json",
|
||||||
"build:electron": "pnpm build && electron-builder",
|
"build:electron": "pnpm build && electron-builder",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,11 @@ import {
|
||||||
NostrClient,
|
NostrClient,
|
||||||
generateIdentity,
|
generateIdentity,
|
||||||
loadIdentityFromHex,
|
loadIdentityFromHex,
|
||||||
|
encryptContent,
|
||||||
|
decryptContent,
|
||||||
|
createSignedEvent,
|
||||||
type MachineIdentity,
|
type MachineIdentity,
|
||||||
|
type Event as NostrEvent,
|
||||||
} from '@lamassu/nostr-client'
|
} from '@lamassu/nostr-client'
|
||||||
import { LightningPubClient } from '@lamassu/lightning'
|
import { LightningPubClient } from '@lamassu/lightning'
|
||||||
import {
|
import {
|
||||||
|
|
@ -102,6 +106,406 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
||||||
// 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
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Cash-in Session Management (for ndebit single-use protection)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Active cash-in session */
|
||||||
|
interface ActiveSession {
|
||||||
|
sessionId: string
|
||||||
|
satsAmount: number
|
||||||
|
createdAt: number
|
||||||
|
status: 'active' | 'paid' | 'expired'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map of sessionId -> session data */
|
||||||
|
const activeSessions = new Map<string, ActiveSession>()
|
||||||
|
|
||||||
|
/** Set of approved invoice hashes (to prevent double-approval) */
|
||||||
|
const approvedInvoices = new Set<string>()
|
||||||
|
|
||||||
|
/** Set of processed event IDs (to prevent replay) */
|
||||||
|
const processedEventIds = new Set<string>()
|
||||||
|
|
||||||
|
/** Session timeout in ms (5 minutes) */
|
||||||
|
const SESSION_TIMEOUT_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new active session for cash-in
|
||||||
|
*/
|
||||||
|
function registerActiveSession(sessionId: string, satsAmount: number): void {
|
||||||
|
console.log('[Session] Registering session:', sessionId, 'for', satsAmount, 'sats')
|
||||||
|
|
||||||
|
activeSessions.set(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
satsAmount,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-expire after timeout
|
||||||
|
setTimeout(() => {
|
||||||
|
const session = activeSessions.get(sessionId)
|
||||||
|
if (session && session.status === 'active') {
|
||||||
|
console.log('[Session] Expiring session:', sessionId)
|
||||||
|
session.status = 'expired'
|
||||||
|
// Clean up after another minute
|
||||||
|
setTimeout(() => activeSessions.delete(sessionId), 60000)
|
||||||
|
}
|
||||||
|
}, SESSION_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an active session by amount
|
||||||
|
* Returns the session if found and valid, null otherwise
|
||||||
|
*
|
||||||
|
* Note: Since Lightning.Pub requires the pointer to be a valid account identifier,
|
||||||
|
* we can't embed session IDs in the ndebit pointer. Instead, we match by amount.
|
||||||
|
* This means two concurrent sessions with the same amount would conflict.
|
||||||
|
* For production, consider using invoice description or webhooks for session tracking.
|
||||||
|
*/
|
||||||
|
function findActiveSessionByAmount(amountSats: number): ActiveSession | null {
|
||||||
|
for (const session of activeSessions.values()) {
|
||||||
|
if (session.status !== 'active') continue
|
||||||
|
|
||||||
|
// Validate amount matches (with small tolerance for rounding)
|
||||||
|
const tolerance = Math.max(1, Math.floor(session.satsAmount * 0.001)) // 0.1% or 1 sat
|
||||||
|
if (Math.abs(amountSats - session.satsAmount) <= tolerance) {
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Session] No active session found for amount:', amountSats)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use findActiveSessionByAmount instead
|
||||||
|
* Kept for backwards compatibility with tests
|
||||||
|
*/
|
||||||
|
function validateDebitSession(_pointer: string, amountSats: number): ActiveSession | null {
|
||||||
|
return findActiveSessionByAmount(amountSats)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a session as paid (prevents replay)
|
||||||
|
*/
|
||||||
|
function markSessionPaid(sessionId: string): void {
|
||||||
|
const session = activeSessions.get(sessionId)
|
||||||
|
if (session) {
|
||||||
|
console.log('[Session] Marking session as paid:', sessionId)
|
||||||
|
session.status = 'paid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session by ID
|
||||||
|
*/
|
||||||
|
function getSession(sessionId: string): ActiveSession | undefined {
|
||||||
|
return activeSessions.get(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export for testing */
|
||||||
|
export { validateDebitSession, findActiveSessionByAmount, markSessionPaid, getSession }
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Debit Approval Service
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Debit request from Lightning.Pub */
|
||||||
|
interface DebitRequest {
|
||||||
|
requestId: string
|
||||||
|
request_id: string
|
||||||
|
npub: string
|
||||||
|
debit: {
|
||||||
|
type: string
|
||||||
|
invoice?: string
|
||||||
|
amount_sats?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Callback for when a debit payment is approved and sent */
|
||||||
|
type DebitPaymentCallback = (sessionId: string, invoice: string, preimage?: string) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the debit approval subscription
|
||||||
|
*
|
||||||
|
* Listens for GetLiveDebitRequests from Lightning.Pub and auto-approves
|
||||||
|
* debit requests that match active sessions.
|
||||||
|
*
|
||||||
|
* @param nostrClient - Nostr client for subscriptions
|
||||||
|
* @param identity - ATM's identity for signing/encryption
|
||||||
|
* @param onPaymentApproved - Callback when a payment is approved
|
||||||
|
* @returns Cleanup function to stop the subscription
|
||||||
|
*/
|
||||||
|
function startDebitApprovalService(
|
||||||
|
nostrClient: NostrClient,
|
||||||
|
identity: MachineIdentity,
|
||||||
|
onPaymentApproved?: DebitPaymentCallback
|
||||||
|
): () => void {
|
||||||
|
console.log('[Debit] Starting debit approval service')
|
||||||
|
console.log('[Debit] ATM pubkey:', identity.publicKey)
|
||||||
|
console.log('[Debit] Lightning.Pub pubkey:', CONFIG.lightningPubPubkey)
|
||||||
|
|
||||||
|
let subscriptionId: string | null = null
|
||||||
|
|
||||||
|
// Send GetLiveDebitRequests subscription
|
||||||
|
const sendSubscription = async () => {
|
||||||
|
const subscribeRequest = {
|
||||||
|
rpcName: 'GetLiveDebitRequests',
|
||||||
|
authIdentifier: identity.publicKey,
|
||||||
|
body: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = encryptContent(identity, CONFIG.lightningPubPubkey, subscribeRequest)
|
||||||
|
|
||||||
|
const event = createSignedEvent(identity, {
|
||||||
|
kind: 21000,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['p', CONFIG.lightningPubPubkey]],
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
|
||||||
|
await nostrClient.publish(event)
|
||||||
|
console.log('[Debit] Sent GetLiveDebitRequests subscription')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming debit requests
|
||||||
|
const handleDebitRequest = async (message: DebitRequest, eventId: string) => {
|
||||||
|
// Log full message structure for debugging
|
||||||
|
console.log('[Debit] Full message structure:', JSON.stringify(message, null, 2))
|
||||||
|
|
||||||
|
console.log('[Debit] Received debit request:', {
|
||||||
|
eventId: eventId.slice(0, 16) + '...',
|
||||||
|
requestId: message.request_id,
|
||||||
|
npub: message.npub?.slice(0, 16) + '...',
|
||||||
|
debitType: message.debit?.type,
|
||||||
|
amountSats: message.debit?.amount_sats,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if we've already processed this event (replay protection)
|
||||||
|
if (processedEventIds.has(eventId)) {
|
||||||
|
console.log('[Debit] REJECTED: Event already processed:', eventId.slice(0, 16))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.debit?.invoice) {
|
||||||
|
console.log('[Debit] No invoice in debit request')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = message.debit.invoice
|
||||||
|
|
||||||
|
// Check if we've already approved this invoice (double-spend protection)
|
||||||
|
if (approvedInvoices.has(invoice)) {
|
||||||
|
console.log('[Debit] REJECTED: Invoice already approved')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract amount - try message.debit.amount_sats first, then decode from invoice
|
||||||
|
let amountSats = message.debit.amount_sats
|
||||||
|
if (!amountSats) {
|
||||||
|
// Try to decode amount from BOLT11 invoice
|
||||||
|
// BOLT11 format: lnbc<amount><multiplier>...
|
||||||
|
// Multipliers: m=milli (0.001), u=micro (0.000001), n=nano (0.000000001), p=pico
|
||||||
|
const invoiceLower = invoice.toLowerCase()
|
||||||
|
const match = invoiceLower.match(/^ln(bc|tb|bcrt)(\d+)([munp])?/)
|
||||||
|
if (match) {
|
||||||
|
const [, , amountStr, multiplier] = match
|
||||||
|
let amount = parseInt(amountStr!, 10)
|
||||||
|
// Convert to satoshis based on multiplier (amounts are in BTC)
|
||||||
|
// 1 BTC = 100,000,000 sats
|
||||||
|
switch (multiplier) {
|
||||||
|
case 'm': // milli-BTC = 100,000 sats
|
||||||
|
amount = amount * 100000
|
||||||
|
break
|
||||||
|
case 'u': // micro-BTC = 100 sats
|
||||||
|
amount = amount * 100
|
||||||
|
break
|
||||||
|
case 'n': // nano-BTC = 0.1 sats (multiply by 0.1)
|
||||||
|
amount = Math.floor(amount / 10)
|
||||||
|
break
|
||||||
|
case 'p': // pico-BTC = 0.0001 sats
|
||||||
|
amount = Math.floor(amount / 10000)
|
||||||
|
break
|
||||||
|
default: // no multiplier = BTC
|
||||||
|
amount = amount * 100000000
|
||||||
|
}
|
||||||
|
amountSats = amount
|
||||||
|
console.log('[Debit] Decoded amount from invoice:', amountSats, 'sats')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!amountSats) {
|
||||||
|
console.log('[Debit] No amount in debit request and could not decode from invoice')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find matching active session by amount
|
||||||
|
// IMPORTANT: We mark the session as paid BEFORE approving to prevent race conditions
|
||||||
|
let matchingSession: ActiveSession | null = null
|
||||||
|
for (const session of activeSessions.values()) {
|
||||||
|
if (session.status === 'active') {
|
||||||
|
const tolerance = Math.max(1, Math.floor(session.satsAmount * 0.001))
|
||||||
|
if (Math.abs(amountSats - session.satsAmount) <= tolerance) {
|
||||||
|
// Atomically mark as paid to prevent race condition
|
||||||
|
session.status = 'paid'
|
||||||
|
matchingSession = session
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchingSession) {
|
||||||
|
console.log('[Debit] REJECTED: No matching active session for amount:', amountSats)
|
||||||
|
console.log(
|
||||||
|
'[Debit] Active sessions:',
|
||||||
|
Array.from(activeSessions.values()).map((s) => ({
|
||||||
|
id: s.sessionId.slice(0, 8),
|
||||||
|
amount: s.satsAmount,
|
||||||
|
status: s.status,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark event and invoice as processed BEFORE sending approval
|
||||||
|
processedEventIds.add(eventId)
|
||||||
|
approvedInvoices.add(invoice)
|
||||||
|
|
||||||
|
console.log('[Debit] Found matching session:', matchingSession.sessionId)
|
||||||
|
console.log('[Debit] Approving debit request...')
|
||||||
|
|
||||||
|
// Approve the debit request
|
||||||
|
const approveRequest = {
|
||||||
|
rpcName: 'RespondToDebit',
|
||||||
|
authIdentifier: identity.publicKey,
|
||||||
|
body: {
|
||||||
|
npub: message.npub,
|
||||||
|
request_id: message.request_id,
|
||||||
|
response: {
|
||||||
|
type: 'invoice',
|
||||||
|
invoice: message.debit.invoice,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = encryptContent(identity, CONFIG.lightningPubPubkey, approveRequest)
|
||||||
|
|
||||||
|
const approveEvent = createSignedEvent(identity, {
|
||||||
|
kind: 21000,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [['p', CONFIG.lightningPubPubkey]],
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
|
||||||
|
await nostrClient.publish(approveEvent)
|
||||||
|
console.log('[Debit] SUCCESS: Approved debit request for session:', matchingSession.sessionId)
|
||||||
|
|
||||||
|
// Notify callback with a placeholder preimage
|
||||||
|
// For ndebit, the "approval" IS the payment action - Lightning.Pub pays immediately
|
||||||
|
// The actual preimage comes later via GetLiveUserOperations, but we don't need to wait
|
||||||
|
if (onPaymentApproved) {
|
||||||
|
onPaymentApproved(matchingSession.sessionId, invoice, 'ndebit-approved')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to messages from Lightning.Pub
|
||||||
|
const eventHandler = (event: NostrEvent) => {
|
||||||
|
console.log('[Debit] Event received:', {
|
||||||
|
kind: event.kind,
|
||||||
|
from: event.pubkey.slice(0, 16) + '...',
|
||||||
|
id: event.id.slice(0, 16) + '...',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (event.kind !== 21000) {
|
||||||
|
console.log('[Debit] Ignoring non-21000 event')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.pubkey !== CONFIG.lightningPubPubkey) {
|
||||||
|
console.log('[Debit] Ignoring event from unknown pubkey')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[Debit] Decrypting event content...')
|
||||||
|
const decrypted = decryptContent(identity, CONFIG.lightningPubPubkey, event.content)
|
||||||
|
const message = JSON.parse(decrypted)
|
||||||
|
console.log('[Debit] Decrypted message:', {
|
||||||
|
requestId: message.requestId,
|
||||||
|
rpcName: message.rpcName,
|
||||||
|
hasDebit: !!message.debit,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if this is a live debit request
|
||||||
|
if (message.requestId === 'GetLiveDebitRequests' && message.debit) {
|
||||||
|
// Pass the event ID for replay protection
|
||||||
|
handleDebitRequest(message as DebitRequest, event.id)
|
||||||
|
} else if (message.rpcName) {
|
||||||
|
console.log('[Debit] RPC response:', message.rpcName, ':', message.status || 'received')
|
||||||
|
} else if (message.requestId) {
|
||||||
|
console.log('[Debit] Subscription status:', message.status)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Log decryption failures to debug
|
||||||
|
console.log(
|
||||||
|
'[Debit] Failed to decrypt/parse event:',
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start subscription
|
||||||
|
const startSubscription = async () => {
|
||||||
|
console.log('[Debit] Setting up Nostr subscription...')
|
||||||
|
console.log('[Debit] Filter:', {
|
||||||
|
kinds: [21000],
|
||||||
|
authors: [CONFIG.lightningPubPubkey.slice(0, 16) + '...'],
|
||||||
|
'#p': [identity.publicKey.slice(0, 16) + '...'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Subscribe to Kind 21000 events from Lightning.Pub tagged to us
|
||||||
|
try {
|
||||||
|
subscriptionId = nostrClient.subscribe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [21000],
|
||||||
|
authors: [CONFIG.lightningPubPubkey],
|
||||||
|
'#p': [identity.publicKey],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onEvent: eventHandler,
|
||||||
|
onEose: () => {
|
||||||
|
console.log('[Debit] EOSE received - subscription is active and caught up')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
console.log('[Debit] Subscription created:', subscriptionId)
|
||||||
|
} catch (subErr) {
|
||||||
|
console.error('[Debit] Failed to create subscription:', subErr)
|
||||||
|
throw subErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the subscription request
|
||||||
|
await sendSubscription()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the subscription
|
||||||
|
startSubscription().catch((err) => {
|
||||||
|
console.error('[Debit] Failed to start subscription:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
if (subscriptionId) {
|
||||||
|
nostrClient.unsubscribe(subscriptionId)
|
||||||
|
}
|
||||||
|
console.log('[Debit] Stopped debit approval service')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface LightningServices {
|
interface LightningServices {
|
||||||
nostrClient: NostrClient
|
nostrClient: NostrClient
|
||||||
lightningPub: LightningPubClient
|
lightningPub: LightningPubClient
|
||||||
|
|
@ -112,6 +516,10 @@ interface LightningServices {
|
||||||
onOfferRequest: (callback: OfferRequestCallback) => void
|
onOfferRequest: (callback: OfferRequestCallback) => void
|
||||||
/** Set callback for when payments are received (from CLINK or LNURL-withdraw) */
|
/** Set callback for when payments are received (from CLINK or LNURL-withdraw) */
|
||||||
onPaymentReceived: (callback: PaymentReceivedCallback) => void
|
onPaymentReceived: (callback: PaymentReceivedCallback) => void
|
||||||
|
/** Set callback for when ndebit payments are approved */
|
||||||
|
onDebitPaymentApproved: (callback: DebitPaymentCallback) => void
|
||||||
|
/** Stop the debit approval service */
|
||||||
|
stopDebitApproval: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Callback for incoming offer requests */
|
/** Callback for incoming offer requests */
|
||||||
|
|
@ -184,6 +592,24 @@ export async function initializeLightningServices(): Promise<LightningServices>
|
||||||
// Callbacks for events
|
// Callbacks for events
|
||||||
let offerRequestCallback: OfferRequestCallback | null = null
|
let offerRequestCallback: OfferRequestCallback | null = null
|
||||||
let paymentReceivedCallback: PaymentReceivedCallback | null = null
|
let paymentReceivedCallback: PaymentReceivedCallback | null = null
|
||||||
|
let debitPaymentCallback: DebitPaymentCallback | null = null
|
||||||
|
|
||||||
|
// Start the debit approval service
|
||||||
|
const stopDebitApproval = startDebitApprovalService(
|
||||||
|
nostrClient,
|
||||||
|
identity,
|
||||||
|
(sessionId, invoice, preimage) => {
|
||||||
|
console.log('[Lightning] Debit payment approved for session:', sessionId)
|
||||||
|
// Notify payment received callback (for state machine PAYMENT_RECEIVED event)
|
||||||
|
if (paymentReceivedCallback && preimage) {
|
||||||
|
paymentReceivedCallback(preimage)
|
||||||
|
}
|
||||||
|
// Also notify debit-specific callback
|
||||||
|
if (debitPaymentCallback) {
|
||||||
|
debitPaymentCallback(sessionId, invoice, preimage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Set up CLINK offer request handler
|
// Set up CLINK offer request handler
|
||||||
// When someone scans our noffer and requests an invoice, we respond
|
// When someone scans our noffer and requests an invoice, we respond
|
||||||
|
|
@ -253,6 +679,10 @@ export async function initializeLightningServices(): Promise<LightningServices>
|
||||||
onPaymentReceived: (callback: PaymentReceivedCallback) => {
|
onPaymentReceived: (callback: PaymentReceivedCallback) => {
|
||||||
paymentReceivedCallback = callback
|
paymentReceivedCallback = callback
|
||||||
},
|
},
|
||||||
|
onDebitPaymentApproved: (callback: DebitPaymentCallback) => {
|
||||||
|
debitPaymentCallback = callback
|
||||||
|
},
|
||||||
|
stopDebitApproval,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,26 +699,44 @@ function createATMServices(
|
||||||
/**
|
/**
|
||||||
* Generate an ndebit URI for cash-in
|
* Generate an ndebit URI for cash-in
|
||||||
*
|
*
|
||||||
* The ndebit encodes the ATM's pubkey and relay, with amount as query param.
|
* The ndebit encodes Lightning.Pub's pubkey and relay, with amount as query param.
|
||||||
* Format: clink:ndebit1<bech32>?amount=<sats>
|
* Format: clink:ndebit1<bech32>?amount=<sats>
|
||||||
|
*
|
||||||
|
* Single-use protection:
|
||||||
|
* - The pointer MUST be a valid Lightning.Pub account identifier (e.g., "atm")
|
||||||
|
* - Lightning.Pub uses the pointer to find which account should pay
|
||||||
|
* - Session tracking is done locally by registering amount-based sessions
|
||||||
|
* - When GetLiveDebitRequests notifies us, we match by amount
|
||||||
|
* - After payment approval, the session is marked complete to prevent replay
|
||||||
*/
|
*/
|
||||||
generateNdebit: async (context: ATMContext): Promise<string> => {
|
generateNdebit: async (context: ATMContext): Promise<string> => {
|
||||||
console.log('[ATM Service] Generating ndebit for', context.satsAmount, 'sats')
|
console.log('[ATM Service] Generating ndebit for', context.satsAmount, 'sats')
|
||||||
|
console.log('[ATM Service] Session ID:', context.cashInSessionId)
|
||||||
|
|
||||||
// Create ndebit with Lightning.Pub's pubkey (not ATM's pubkey!)
|
if (!context.cashInSessionId) {
|
||||||
// The wallet sends Kind 21002 to the pubkey in the ndebit,
|
throw new Error('No cash-in session ID - state machine error')
|
||||||
// so it must be Lightning.Pub's pubkey for it to receive and pay.
|
}
|
||||||
// The pointer identifies which Lightning.Pub user account pays (e.g., "atm")
|
|
||||||
|
// Create ndebit with Lightning.Pub's pubkey
|
||||||
|
// The wallet sends Kind 21002 to the pubkey in the ndebit
|
||||||
|
// The pointer MUST be a valid Lightning.Pub user identifier
|
||||||
|
// (Lightning.Pub looks up the wallet by this identifier)
|
||||||
|
const pointer = 'atm' // ATM's account identifier in Lightning.Pub
|
||||||
const ndebit = encodeNdebit({
|
const ndebit = encodeNdebit({
|
||||||
pubkey: CONFIG.lightningPubPubkey,
|
pubkey: CONFIG.lightningPubPubkey,
|
||||||
relay: CONFIG.relayUrl,
|
relay: CONFIG.relayUrl,
|
||||||
pointer: 'atm', // Lightning.Pub user identifier for the ATM account
|
pointer,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Register this session as active (for debit approval validation)
|
||||||
|
// We match incoming debit requests by amount
|
||||||
|
registerActiveSession(context.cashInSessionId, context.satsAmount)
|
||||||
|
|
||||||
// Format as full URI with amount
|
// Format as full URI with amount
|
||||||
const uri = formatNdebitUri(ndebit, context.satsAmount)
|
const uri = formatNdebitUri(ndebit, context.satsAmount)
|
||||||
|
|
||||||
console.log('[ATM Service] Generated ndebit URI:', uri.slice(0, 60) + '...')
|
console.log('[ATM Service] Generated ndebit URI:', uri.slice(0, 60) + '...')
|
||||||
|
console.log('[ATM Service] Pointer:', pointer, '(session:', context.cashInSessionId, ')')
|
||||||
return uri
|
return uri
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,23 @@
|
||||||
docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu -generate "$BLOCKS"
|
docker exec lamassu-bitcoind bitcoin-cli -regtest -rpcuser=lamassu -rpcpassword=lamassu -generate "$BLOCKS"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# Start auto-miner (mines 1 block every 30 seconds)
|
||||||
|
auto-mine.exec = ''
|
||||||
|
echo "Starting auto-miner (1 block every 30 seconds)..."
|
||||||
|
docker compose -f docker/docker-compose.dev.yml --profile mining up -d miner
|
||||||
|
echo ""
|
||||||
|
echo "Auto-miner started. This keeps LND in sync."
|
||||||
|
echo "Use 'auto-mine-stop' to stop it."
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Stop auto-miner
|
||||||
|
auto-mine-stop.exec = ''
|
||||||
|
echo "Stopping auto-miner..."
|
||||||
|
docker stop lamassu-miner 2>/dev/null || true
|
||||||
|
docker rm lamassu-miner 2>/dev/null || true
|
||||||
|
echo "Auto-miner stopped."
|
||||||
|
'';
|
||||||
|
|
||||||
# Connect to LND CLI
|
# Connect to LND CLI
|
||||||
lncli.exec = ''
|
lncli.exec = ''
|
||||||
docker exec -it lamassu-lnd lncli --network=regtest "$@"
|
docker exec -it lamassu-lnd lncli --network=regtest "$@"
|
||||||
|
|
@ -537,6 +554,8 @@
|
||||||
echo " lncli LND CLI (Lightning.Pub's node)"
|
echo " lncli LND CLI (Lightning.Pub's node)"
|
||||||
echo " lncli-alice LND CLI (Alice's node for testing payments)"
|
echo " lncli-alice LND CLI (Alice's node for testing payments)"
|
||||||
echo " mine-blocks Mine regtest blocks (default: 1)"
|
echo " mine-blocks Mine regtest blocks (default: 1)"
|
||||||
|
echo " auto-mine Start auto-miner (1 block/30s, keeps LND synced)"
|
||||||
|
echo " auto-mine-stop Stop auto-miner"
|
||||||
echo " setup-channel Setup channel between Alice and LND"
|
echo " setup-channel Setup channel between Alice and LND"
|
||||||
echo " alice-pay Pay invoice from Alice's node"
|
echo " alice-pay Pay invoice from Alice's node"
|
||||||
echo " relay-test Test Nostr relay connection"
|
echo " relay-test Test Nostr relay connection"
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,34 @@ services:
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Automatic block miner for regtest (keeps LND synced)
|
||||||
|
# Mines 1 block every 30 seconds
|
||||||
|
miner:
|
||||||
|
image: alpine:latest
|
||||||
|
container_name: lamassu-miner
|
||||||
|
depends_on:
|
||||||
|
bitcoind:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: /bin/sh
|
||||||
|
command:
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
apk add --no-cache curl jq
|
||||||
|
echo "Starting auto-miner (1 block every 30 seconds)..."
|
||||||
|
while true; do
|
||||||
|
# Create wallet if not exists
|
||||||
|
curl -s --user lamassu:lamassu --data-binary '{"jsonrpc":"1.0","method":"createwallet","params":["miner"]}' http://bitcoind:18443/ > /dev/null 2>&1
|
||||||
|
# Mine a block
|
||||||
|
ADDR=$$(curl -s --user lamassu:lamassu --data-binary '{"jsonrpc":"1.0","method":"getnewaddress","params":[]}' http://bitcoind:18443/ | jq -r '.result // empty')
|
||||||
|
if [ -n "$$ADDR" ]; then
|
||||||
|
curl -s --user lamassu:lamassu --data-binary "{\"jsonrpc\":\"1.0\",\"method\":\"generatetoaddress\",\"params\":[1,\"$$ADDR\"]}" http://bitcoind:18443/ > /dev/null
|
||||||
|
fi
|
||||||
|
sleep 30
|
||||||
|
done
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- mining # Only starts with: docker compose --profile mining up -d
|
||||||
|
|
||||||
# PostgreSQL for optional server-side state
|
# PostgreSQL for optional server-side state
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
|
|
||||||
|
|
@ -299,63 +299,349 @@ Key UI elements:
|
||||||
|
|
||||||
## ATM/Service Implementation
|
## ATM/Service Implementation
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
The ATM implements a **debit approval service** that:
|
||||||
|
|
||||||
|
1. Generates ndebit QR codes with the ATM's Lightning.Pub account
|
||||||
|
2. Subscribes to `GetLiveDebitRequests` to receive incoming debit requests
|
||||||
|
3. Validates requests against active sessions (single-use protection)
|
||||||
|
4. Approves valid requests via `RespondToDebit` RPC
|
||||||
|
|
||||||
|
### Architecture: Debit Approval Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ ATM Machine │ │ Nostr Relay │ │ Lightning.Pub │ │ User's Wallet │
|
||||||
|
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||||
|
│ │ │ │
|
||||||
|
│ 1. GetLiveDebitRequests │ │
|
||||||
|
│ (Kind 21000 subscription) │ │
|
||||||
|
│──────────────────────►│──────────────────────►│ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 2. Display QR: │ │ │
|
||||||
|
│ clink:ndebit1... │ │ 3. Scan QR │
|
||||||
|
│ ?amount=2450 │ │◄──────────────────────┤
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ 4. Kind 21002 │
|
||||||
|
│ │ │ debit request │
|
||||||
|
│ │◄──────────────────────┼───────────────────────┤
|
||||||
|
│ │ │ │
|
||||||
|
│ 5. Live debit request │ │ │
|
||||||
|
│ (via subscription) │ │ │
|
||||||
|
│◄──────────────────────┤◄──────────────────────┤ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 6. Validate session │ │ │
|
||||||
|
│ & approve │ │ │
|
||||||
|
│──────────────────────►│──────────────────────►│ │
|
||||||
|
│ (RespondToDebit) │ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ 7. Pay invoice │
|
||||||
|
│ │ │──────────────────────►│
|
||||||
|
│ │ │ │
|
||||||
|
```
|
||||||
|
|
||||||
### Generating the NDebit QR
|
### Generating the NDebit QR
|
||||||
|
|
||||||
The ATM service needs to:
|
```typescript
|
||||||
|
// apps/machine/src/services/lightning.ts
|
||||||
|
|
||||||
1. **Get the ndebit from Lightning.Pub:**
|
generateNdebit: async (context: ATMContext): Promise<string> => {
|
||||||
|
// The pointer MUST be a valid Lightning.Pub account identifier
|
||||||
|
// Lightning.Pub uses this to find which account should pay
|
||||||
|
const pointer = 'atm' // ATM's account identifier in Lightning.Pub
|
||||||
|
|
||||||
```javascript
|
const ndebit = encodeNdebit({
|
||||||
const user = await getAtmUser() // API call to Lightning.Pub
|
pubkey: CONFIG.lightningPubPubkey, // Lightning.Pub's pubkey (NOT ATM's!)
|
||||||
const ndebit = user.info.ndebit
|
relay: CONFIG.relayUrl,
|
||||||
```
|
pointer,
|
||||||
|
|
||||||
2. **Rewrite relay for browser access** (if needed):
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import { decodeNdebit, encodeNdebit } from '@lamassu/clink'
|
|
||||||
|
|
||||||
function rewriteNdebitRelay(ndebitString, browserRelay) {
|
|
||||||
const decoded = decodeNdebit(ndebitString)
|
|
||||||
if (!decoded) return ndebitString
|
|
||||||
|
|
||||||
return encodeNdebit({
|
|
||||||
pubkey: decoded.pubkey,
|
|
||||||
relay: browserRelay, // e.g., 'ws://localhost:7777'
|
|
||||||
pointer: decoded.pointer,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Register session for single-use tracking (see below)
|
||||||
|
registerActiveSession(context.cashInSessionId, context.satsAmount)
|
||||||
|
|
||||||
|
// Format as full URI with amount
|
||||||
|
return formatNdebitUri(ndebit, context.satsAmount)
|
||||||
|
// Returns: clink:ndebit1qqsyh68zq...?amount=2450
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Format the URI with amount:**
|
**Critical:** The ndebit encodes **Lightning.Pub's pubkey**, not the ATM's pubkey. This is because:
|
||||||
|
|
||||||
```javascript
|
- The wallet sends Kind 21002 to the pubkey in the ndebit
|
||||||
// Using clink: scheme (protocol-agnostic, supports Cashu/Fedimint in future)
|
- Lightning.Pub must receive it to process the payment
|
||||||
function formatNdebitUri(ndebit, amountSats) {
|
- The `pointer` field tells Lightning.Pub which account to debit
|
||||||
const rewritten = rewriteNdebitRelay(ndebit, 'ws://localhost:7777')
|
|
||||||
return `clink:${rewritten}?amount=${amountSats}`
|
### Single-Use Protection (Session Management)
|
||||||
|
|
||||||
|
Each ndebit QR is valid for **one use only**. This prevents:
|
||||||
|
|
||||||
|
- Double-spending from the same QR
|
||||||
|
- Replay attacks with saved QR codes
|
||||||
|
|
||||||
|
#### How It Works
|
||||||
|
|
||||||
|
1. **Session Registration**: When generating an ndebit, we create a session:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ActiveSession {
|
||||||
|
sessionId: string // Unique ID for this transaction
|
||||||
|
satsAmount: number // Expected amount
|
||||||
|
createdAt: number // Timestamp
|
||||||
|
status: 'active' | 'paid' | 'expired'
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSessions = new Map<string, ActiveSession>()
|
||||||
|
|
||||||
|
function registerActiveSession(sessionId: string, satsAmount: number): void {
|
||||||
|
activeSessions.set(sessionId, {
|
||||||
|
sessionId,
|
||||||
|
satsAmount,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-expire after 5 minutes
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
const session = activeSessions.get(sessionId)
|
||||||
|
if (session?.status === 'active') {
|
||||||
|
session.status = 'expired'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
5 * 60 * 1000
|
||||||
|
)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Generate QR code:**
|
2. **Session Validation**: When a debit request arrives, we find a matching session:
|
||||||
|
|
||||||
```javascript
|
```typescript
|
||||||
import QRCode from 'qrcode'
|
function findActiveSessionByAmount(amountSats: number): ActiveSession | null {
|
||||||
|
for (const session of activeSessions.values()) {
|
||||||
|
if (session.status !== 'active') continue
|
||||||
|
|
||||||
const uri = formatNdebitUri(ndebit, 1000)
|
// Match with small tolerance for rounding
|
||||||
const qrDataUrl = await QRCode.toDataURL(uri, { width: 300 })
|
const tolerance = Math.max(1, Math.floor(session.satsAmount * 0.001))
|
||||||
|
if (Math.abs(amountSats - session.satsAmount) <= tolerance) {
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Handling Debit Requests (Lightning.Pub)
|
3. **Atomic Status Update**: Before approving, we mark the session as paid:
|
||||||
|
|
||||||
Lightning.Pub handles Kind 21002 debit requests automatically. It will:
|
```typescript
|
||||||
|
// In debit request handler:
|
||||||
|
const matchingSession = findActiveSessionByAmount(amountSats)
|
||||||
|
if (!matchingSession) {
|
||||||
|
console.log('[Debit] REJECTED: No matching active session')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
1. Receive the debit request with the invoice
|
// Mark as paid BEFORE sending approval (prevents race conditions)
|
||||||
2. Validate the request (check pointer, amount limits, etc.)
|
matchingSession.status = 'paid'
|
||||||
3. Pay the invoice from the ATM user's balance
|
|
||||||
4. Send response back via relay
|
|
||||||
|
|
||||||
**Important:** The ATM user must have sufficient balance to pay invoices.
|
// Now approve...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Additional Protections
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Track processed events (prevents replay of same Nostr event)
|
||||||
|
const processedEventIds = new Set<string>()
|
||||||
|
|
||||||
|
// Track approved invoices (prevents same invoice being approved twice)
|
||||||
|
const approvedInvoices = new Set<string>()
|
||||||
|
|
||||||
|
const handleDebitRequest = async (message: DebitRequest, eventId: string) => {
|
||||||
|
// Check 1: Event already processed?
|
||||||
|
if (processedEventIds.has(eventId)) {
|
||||||
|
console.log('[Debit] REJECTED: Event already processed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 2: Invoice already approved?
|
||||||
|
if (approvedInvoices.has(invoice)) {
|
||||||
|
console.log('[Debit] REJECTED: Invoice already approved')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 3: Matching active session?
|
||||||
|
const session = findActiveSessionByAmount(amountSats)
|
||||||
|
if (!session) {
|
||||||
|
console.log('[Debit] REJECTED: No matching active session')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark everything BEFORE sending approval
|
||||||
|
session.status = 'paid'
|
||||||
|
processedEventIds.add(eventId)
|
||||||
|
approvedInvoices.add(invoice)
|
||||||
|
|
||||||
|
// Now approve...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debit Approval Service
|
||||||
|
|
||||||
|
The ATM runs a background service that listens for debit requests:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function startDebitApprovalService(
|
||||||
|
nostrClient: NostrClient,
|
||||||
|
identity: MachineIdentity,
|
||||||
|
onPaymentApproved?: DebitPaymentCallback
|
||||||
|
): () => void {
|
||||||
|
// 1. Subscribe to Kind 21000 events from Lightning.Pub
|
||||||
|
const subscriptionId = nostrClient.subscribe(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
kinds: [21000],
|
||||||
|
authors: [CONFIG.lightningPubPubkey],
|
||||||
|
'#p': [identity.publicKey],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ onEvent: eventHandler }
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. Send GetLiveDebitRequests subscription request
|
||||||
|
const subscribeRequest = {
|
||||||
|
rpcName: 'GetLiveDebitRequests',
|
||||||
|
authIdentifier: identity.publicKey,
|
||||||
|
body: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = createSignedEvent(identity, {
|
||||||
|
kind: 21000,
|
||||||
|
tags: [['p', CONFIG.lightningPubPubkey]],
|
||||||
|
content: encryptContent(identity, CONFIG.lightningPubPubkey, subscribeRequest),
|
||||||
|
})
|
||||||
|
|
||||||
|
await nostrClient.publish(event)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handling Debit Requests
|
||||||
|
|
||||||
|
When a debit request arrives via the subscription:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const eventHandler = (event: NostrEvent) => {
|
||||||
|
// Decrypt the message
|
||||||
|
const message = JSON.parse(decryptContent(identity, CONFIG.lightningPubPubkey, event.content))
|
||||||
|
|
||||||
|
// Check if it's a live debit request
|
||||||
|
if (message.requestId === 'GetLiveDebitRequests' && message.debit) {
|
||||||
|
handleDebitRequest(message, event.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDebitRequest = async (message: DebitRequest, eventId: string) => {
|
||||||
|
// Message structure:
|
||||||
|
// {
|
||||||
|
// request_id: "9f22a67c...",
|
||||||
|
// npub: "43bfda6c...",
|
||||||
|
// debit: {
|
||||||
|
// type: "invoice",
|
||||||
|
// invoice: "lnbcrt24500n1p5hm..."
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Extract amount from BOLT11 invoice (amount_sats field is often missing)
|
||||||
|
let amountSats = message.debit.amount_sats
|
||||||
|
if (!amountSats) {
|
||||||
|
amountSats = decodeAmountFromBolt11(message.debit.invoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate against active sessions (single-use check)
|
||||||
|
const session = findActiveSessionByAmount(amountSats)
|
||||||
|
if (!session) {
|
||||||
|
console.log('[Debit] REJECTED: No matching active session')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark session as paid (prevents double-use)
|
||||||
|
session.status = 'paid'
|
||||||
|
|
||||||
|
// Approve the debit request
|
||||||
|
const approveRequest = {
|
||||||
|
rpcName: 'RespondToDebit',
|
||||||
|
authIdentifier: identity.publicKey,
|
||||||
|
body: {
|
||||||
|
npub: message.npub,
|
||||||
|
request_id: message.request_id,
|
||||||
|
response: {
|
||||||
|
type: 'invoice',
|
||||||
|
invoice: message.debit.invoice,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const approveEvent = createSignedEvent(identity, {
|
||||||
|
kind: 21000,
|
||||||
|
tags: [['p', CONFIG.lightningPubPubkey]],
|
||||||
|
content: encryptContent(identity, CONFIG.lightningPubPubkey, approveRequest),
|
||||||
|
})
|
||||||
|
|
||||||
|
await nostrClient.publish(approveEvent)
|
||||||
|
console.log('[Debit] SUCCESS: Approved debit request')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### BOLT11 Amount Decoding
|
||||||
|
|
||||||
|
Lightning.Pub doesn't always include `amount_sats` in the debit request, so we decode it from the invoice:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function decodeAmountFromBolt11(invoice: string): number | null {
|
||||||
|
// BOLT11 format: ln<network><amount><multiplier>...
|
||||||
|
// Networks: bc (mainnet), tb (testnet), bcrt (regtest)
|
||||||
|
// Multipliers: m=milli (0.001), u=micro (0.000001), n=nano, p=pico
|
||||||
|
|
||||||
|
const match = invoice.toLowerCase().match(/^ln(bc|tb|bcrt)(\d+)([munp])?/)
|
||||||
|
if (!match) return null
|
||||||
|
|
||||||
|
const [, , amountStr, multiplier] = match
|
||||||
|
let amount = parseInt(amountStr, 10)
|
||||||
|
|
||||||
|
// Convert to satoshis (1 BTC = 100,000,000 sats)
|
||||||
|
switch (multiplier) {
|
||||||
|
case 'm':
|
||||||
|
amount = amount * 100000
|
||||||
|
break // milli-BTC
|
||||||
|
case 'u':
|
||||||
|
amount = amount * 100
|
||||||
|
break // micro-BTC
|
||||||
|
case 'n':
|
||||||
|
amount = Math.floor(amount / 10)
|
||||||
|
break // nano-BTC
|
||||||
|
case 'p':
|
||||||
|
amount = Math.floor(amount / 10000)
|
||||||
|
break // pico-BTC
|
||||||
|
default:
|
||||||
|
amount = amount * 100000000 // BTC
|
||||||
|
}
|
||||||
|
|
||||||
|
return amount
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lightning.Pub's Role
|
||||||
|
|
||||||
|
Lightning.Pub handles the actual payment:
|
||||||
|
|
||||||
|
1. Receives Kind 21002 debit request from user's wallet
|
||||||
|
2. Looks up the account by `pointer` field (e.g., "atm")
|
||||||
|
3. Forwards request to GetLiveDebitRequests subscribers
|
||||||
|
4. Waits for approval via RespondToDebit
|
||||||
|
5. Pays the invoice from the account's balance
|
||||||
|
6. Sends Kind 21002 response to user's wallet
|
||||||
|
|
||||||
|
**Important:** The ATM account must have sufficient balance to pay invoices.
|
||||||
|
|
||||||
## Infrastructure Requirements
|
## Infrastructure Requirements
|
||||||
|
|
||||||
|
|
@ -432,16 +718,113 @@ lncli payinvoice <invoice>
|
||||||
**Problem:** Trying to encode amount in the ndebit bech32 string
|
**Problem:** Trying to encode amount in the ndebit bech32 string
|
||||||
**Solution:** Use query parameter: `?amount=1000`
|
**Solution:** Use query parameter: `?amount=1000`
|
||||||
|
|
||||||
|
### 7. Invalid Pointer in NDebit
|
||||||
|
|
||||||
|
**Problem:** Using custom session IDs or arbitrary strings as the ndebit pointer
|
||||||
|
|
||||||
|
```
|
||||||
|
wallet >> user atm:ml2b5abxdppn58sty5 not found wallet
|
||||||
|
DebitManager >> ERROR application user not found
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** Lightning.Pub uses the `pointer` field to look up which account should pay. It must be a valid Lightning.Pub user identifier (e.g., `atm`), not a custom session string.
|
||||||
|
|
||||||
|
**Solution:** Use the ATM's Lightning.Pub account identifier as the pointer:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const pointer = 'atm' // NOT 'atm:sessionId123'
|
||||||
|
```
|
||||||
|
|
||||||
|
Track sessions locally using amount-based matching instead.
|
||||||
|
|
||||||
|
### 8. Debit Requests Not Received
|
||||||
|
|
||||||
|
**Problem:** GetLiveDebitRequests subscription sent but no debit requests arrive
|
||||||
|
|
||||||
|
**Possible Causes:**
|
||||||
|
|
||||||
|
1. Wrong pubkey in ndebit (must be Lightning.Pub's pubkey)
|
||||||
|
2. Subscription filter doesn't match (check `authors` and `#p` tags)
|
||||||
|
3. Using SimplePool instead of direct Relay connection (see `packages/lightning/TROUBLESHOOTING.md`)
|
||||||
|
4. Race condition - published before subscription was ready
|
||||||
|
|
||||||
|
**Solution:** Add verbose logging to trace the flow:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
nostrClient.subscribe([...], {
|
||||||
|
onEvent: (event) => {
|
||||||
|
console.log('[Debit] Event received:', event.kind, event.id)
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
onEose: () => {
|
||||||
|
console.log('[Debit] EOSE received - subscription active')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Missing Amount in Debit Request
|
||||||
|
|
||||||
|
**Problem:** `message.debit.amount_sats` is undefined
|
||||||
|
|
||||||
|
**Cause:** Lightning.Pub doesn't always include the amount in the forwarded debit request
|
||||||
|
|
||||||
|
**Solution:** Decode the amount from the BOLT11 invoice:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const amountSats = message.debit.amount_sats || decodeAmountFromBolt11(invoice)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Double Debit (Same QR Used Twice)
|
||||||
|
|
||||||
|
**Problem:** User can claim the same ndebit QR multiple times
|
||||||
|
|
||||||
|
**Solution:** Implement session-based single-use protection:
|
||||||
|
|
||||||
|
1. Register each ndebit generation as a session with expected amount
|
||||||
|
2. Match incoming requests against active sessions
|
||||||
|
3. Mark session as `paid` **before** sending approval (atomic)
|
||||||
|
4. Reject requests with no matching active session
|
||||||
|
|
||||||
|
See "Single-Use Protection" section above for implementation details.
|
||||||
|
|
||||||
## Testing Checklist
|
## Testing Checklist
|
||||||
|
|
||||||
1. [ ] Lightning.Pub health check returns OK
|
### Infrastructure
|
||||||
2. [ ] LND is synced (`synced_to_chain: true`)
|
|
||||||
3. [ ] ATM user exists and has balance
|
- [ ] Lightning.Pub health check returns OK
|
||||||
4. [ ] Relay is accessible from browser
|
- [ ] LND is synced (`synced_to_chain: true`)
|
||||||
5. [ ] Wallet source configured with correct relay
|
- [ ] ATM user exists and has balance (`fund-atm` command)
|
||||||
6. [ ] NDebit QR contains `clink:` prefix
|
- [ ] Relay is accessible from browser (`ws://localhost:7777`)
|
||||||
7. [ ] NDebit QR contains `?amount=` parameter
|
|
||||||
8. [ ] NDebit relay is browser-accessible
|
### NDebit QR Generation
|
||||||
|
|
||||||
|
- [ ] QR contains `clink:` prefix
|
||||||
|
- [ ] QR contains `?amount=` parameter
|
||||||
|
- [ ] NDebit encodes **Lightning.Pub's pubkey** (not ATM's)
|
||||||
|
- [ ] NDebit pointer is valid account identifier (`atm`)
|
||||||
|
- [ ] Relay URL is browser-accessible (not Docker-internal)
|
||||||
|
|
||||||
|
### Debit Approval Service
|
||||||
|
|
||||||
|
- [ ] Service starts: `[Debit] Starting debit approval service`
|
||||||
|
- [ ] Subscription active: `[Debit] EOSE received`
|
||||||
|
- [ ] Events received: `[Debit] Event received: {kind: 21000, ...}`
|
||||||
|
- [ ] Decryption works: `[Debit] Decrypted message: {...}`
|
||||||
|
|
||||||
|
### Single-Use Protection
|
||||||
|
|
||||||
|
- [ ] First claim succeeds: `[Debit] SUCCESS: Approved debit request`
|
||||||
|
- [ ] Second claim fails: `[Debit] REJECTED: No matching active session`
|
||||||
|
- [ ] Session marked as paid after approval
|
||||||
|
- [ ] Same invoice rejected: `[Debit] REJECTED: Invoice already approved`
|
||||||
|
|
||||||
|
### End-to-End
|
||||||
|
|
||||||
|
- [ ] ATM displays ndebit QR
|
||||||
|
- [ ] Wallet scans and claims
|
||||||
|
- [ ] Debit approved and payment received
|
||||||
|
- [ ] State machine transitions to success
|
||||||
|
- [ ] Repeated scan is rejected
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,10 +131,12 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
|
||||||
actions: {
|
actions: {
|
||||||
resetContext: assign(() => ({
|
resetContext: assign(() => ({
|
||||||
...initialContext,
|
...initialContext,
|
||||||
|
cashInSessionId: null,
|
||||||
})),
|
})),
|
||||||
setStartTime: assign({
|
setStartTime: assign({
|
||||||
startedAt: () => Date.now(),
|
startedAt: () => Date.now(),
|
||||||
txid: () => generateTxId(),
|
txid: () => generateTxId(),
|
||||||
|
cashInSessionId: () => generateSessionId(),
|
||||||
}),
|
}),
|
||||||
addBill: assign({
|
addBill: assign({
|
||||||
billsInserted: ({ context, event }) => {
|
billsInserted: ({ context, event }) => {
|
||||||
|
|
@ -627,6 +629,16 @@ function generateTxId(): string {
|
||||||
return `tx_${timestamp}_${random}`
|
return `tx_${timestamp}_${random}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique session ID for cash-in ndebit single-use protection
|
||||||
|
* This is used in the ndebit pointer field to link debit requests to specific transactions
|
||||||
|
*/
|
||||||
|
function generateSessionId(): string {
|
||||||
|
const timestamp = Date.now().toString(36)
|
||||||
|
const random = Math.random().toString(36).substring(2, 12)
|
||||||
|
return `${timestamp}${random}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default ATM machine (no services - for type checking)
|
* Default ATM machine (no services - for type checking)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,10 @@ export interface ATMContext {
|
||||||
txid: string | null
|
txid: string | null
|
||||||
/** Transaction start time */
|
/** Transaction start time */
|
||||||
startedAt: number | null
|
startedAt: number | null
|
||||||
|
|
||||||
|
// Cash-in session (for ndebit single-use protection)
|
||||||
|
/** Unique session ID for this cash-in transaction (used in ndebit pointer) */
|
||||||
|
cashInSessionId: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ATM events */
|
/** ATM events */
|
||||||
|
|
@ -144,6 +148,7 @@ export const initialContext: ATMContext = {
|
||||||
retryCount: 0,
|
retryCount: 0,
|
||||||
txid: null,
|
txid: null,
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
|
cashInSessionId: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Service inputs for actors */
|
/** Service inputs for actors */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue