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 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
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "../dist-electron",
|
||||
"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": {
|
||||
"dev": "concurrently -n vite,electron \"vite\" \"pnpm run electron:dev\"",
|
||||
"dev:vite": "vite",
|
||||
"electron:dev": "tsc -p electron/tsconfig.json && electron dist-electron/main.js",
|
||||
"build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json",
|
||||
"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 && tsc -p electron/tsconfig.preload.json",
|
||||
"build:electron": "pnpm build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ import {
|
|||
NostrClient,
|
||||
generateIdentity,
|
||||
loadIdentityFromHex,
|
||||
encryptContent,
|
||||
decryptContent,
|
||||
createSignedEvent,
|
||||
type MachineIdentity,
|
||||
type Event as NostrEvent,
|
||||
} from '@lamassu/nostr-client'
|
||||
import { LightningPubClient } from '@lamassu/lightning'
|
||||
import {
|
||||
|
|
@ -102,6 +106,406 @@ async function loadLightningConfig(): Promise<LightningConfig> {
|
|||
// Config is loaded async now - will be set in initializeLightningServices
|
||||
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 {
|
||||
nostrClient: NostrClient
|
||||
lightningPub: LightningPubClient
|
||||
|
|
@ -112,6 +516,10 @@ interface LightningServices {
|
|||
onOfferRequest: (callback: OfferRequestCallback) => void
|
||||
/** Set callback for when payments are received (from CLINK or LNURL-withdraw) */
|
||||
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 */
|
||||
|
|
@ -184,6 +592,24 @@ export async function initializeLightningServices(): Promise<LightningServices>
|
|||
// Callbacks for events
|
||||
let offerRequestCallback: OfferRequestCallback | 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
|
||||
// When someone scans our noffer and requests an invoice, we respond
|
||||
|
|
@ -253,6 +679,10 @@ export async function initializeLightningServices(): Promise<LightningServices>
|
|||
onPaymentReceived: (callback: PaymentReceivedCallback) => {
|
||||
paymentReceivedCallback = callback
|
||||
},
|
||||
onDebitPaymentApproved: (callback: DebitPaymentCallback) => {
|
||||
debitPaymentCallback = callback
|
||||
},
|
||||
stopDebitApproval,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -269,26 +699,44 @@ function createATMServices(
|
|||
/**
|
||||
* 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>
|
||||
*
|
||||
* 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> => {
|
||||
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!)
|
||||
// The wallet sends Kind 21002 to the pubkey in the ndebit,
|
||||
// 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")
|
||||
if (!context.cashInSessionId) {
|
||||
throw new Error('No cash-in session ID - state machine error')
|
||||
}
|
||||
|
||||
// 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({
|
||||
pubkey: CONFIG.lightningPubPubkey,
|
||||
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
|
||||
const uri = formatNdebitUri(ndebit, context.satsAmount)
|
||||
|
||||
console.log('[ATM Service] Generated ndebit URI:', uri.slice(0, 60) + '...')
|
||||
console.log('[ATM Service] Pointer:', pointer, '(session:', context.cashInSessionId, ')')
|
||||
return uri
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -198,6 +198,23 @@
|
|||
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
|
||||
lncli.exec = ''
|
||||
docker exec -it lamassu-lnd lncli --network=regtest "$@"
|
||||
|
|
@ -537,6 +554,8 @@
|
|||
echo " lncli LND CLI (Lightning.Pub's node)"
|
||||
echo " lncli-alice LND CLI (Alice's node for testing payments)"
|
||||
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 " alice-pay Pay invoice from Alice's node"
|
||||
echo " relay-test Test Nostr relay connection"
|
||||
|
|
|
|||
|
|
@ -170,6 +170,34 @@ services:
|
|||
start_period: 30s
|
||||
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
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
|
|
|
|||
|
|
@ -299,63 +299,349 @@ Key UI elements:
|
|||
|
||||
## 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
|
||||
|
||||
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 user = await getAtmUser() // API call to Lightning.Pub
|
||||
const ndebit = user.info.ndebit
|
||||
```
|
||||
|
||||
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,
|
||||
const ndebit = encodeNdebit({
|
||||
pubkey: CONFIG.lightningPubPubkey, // Lightning.Pub's pubkey (NOT ATM's!)
|
||||
relay: CONFIG.relayUrl,
|
||||
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
|
||||
// Using clink: scheme (protocol-agnostic, supports Cashu/Fedimint in future)
|
||||
function formatNdebitUri(ndebit, amountSats) {
|
||||
const rewritten = rewriteNdebitRelay(ndebit, 'ws://localhost:7777')
|
||||
return `clink:${rewritten}?amount=${amountSats}`
|
||||
- The wallet sends Kind 21002 to the pubkey in the ndebit
|
||||
- Lightning.Pub must receive it to process the payment
|
||||
- The `pointer` field tells Lightning.Pub which account to debit
|
||||
|
||||
### 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
|
||||
import QRCode from 'qrcode'
|
||||
```typescript
|
||||
function findActiveSessionByAmount(amountSats: number): ActiveSession | null {
|
||||
for (const session of activeSessions.values()) {
|
||||
if (session.status !== 'active') continue
|
||||
|
||||
const uri = formatNdebitUri(ndebit, 1000)
|
||||
const qrDataUrl = await QRCode.toDataURL(uri, { width: 300 })
|
||||
// Match with small tolerance for rounding
|
||||
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
|
||||
2. Validate the request (check pointer, amount limits, etc.)
|
||||
3. Pay the invoice from the ATM user's balance
|
||||
4. Send response back via relay
|
||||
// Mark as paid BEFORE sending approval (prevents race conditions)
|
||||
matchingSession.status = 'paid'
|
||||
|
||||
**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
|
||||
|
||||
|
|
@ -432,16 +718,113 @@ lncli payinvoice <invoice>
|
|||
**Problem:** Trying to encode amount in the ndebit bech32 string
|
||||
**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
|
||||
|
||||
1. [ ] Lightning.Pub health check returns OK
|
||||
2. [ ] LND is synced (`synced_to_chain: true`)
|
||||
3. [ ] ATM user exists and has balance
|
||||
4. [ ] Relay is accessible from browser
|
||||
5. [ ] Wallet source configured with correct relay
|
||||
6. [ ] NDebit QR contains `clink:` prefix
|
||||
7. [ ] NDebit QR contains `?amount=` parameter
|
||||
8. [ ] NDebit relay is browser-accessible
|
||||
### Infrastructure
|
||||
|
||||
- [ ] Lightning.Pub health check returns OK
|
||||
- [ ] LND is synced (`synced_to_chain: true`)
|
||||
- [ ] ATM user exists and has balance (`fund-atm` command)
|
||||
- [ ] Relay is accessible from browser (`ws://localhost:7777`)
|
||||
|
||||
### 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
|
||||
|
||||
|
|
|
|||
|
|
@ -131,10 +131,12 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
|
|||
actions: {
|
||||
resetContext: assign(() => ({
|
||||
...initialContext,
|
||||
cashInSessionId: null,
|
||||
})),
|
||||
setStartTime: assign({
|
||||
startedAt: () => Date.now(),
|
||||
txid: () => generateTxId(),
|
||||
cashInSessionId: () => generateSessionId(),
|
||||
}),
|
||||
addBill: assign({
|
||||
billsInserted: ({ context, event }) => {
|
||||
|
|
@ -627,6 +629,16 @@ function generateTxId(): string {
|
|||
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)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ export interface ATMContext {
|
|||
txid: string | null
|
||||
/** Transaction start time */
|
||||
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 */
|
||||
|
|
@ -144,6 +148,7 @@ export const initialContext: ATMContext = {
|
|||
retryCount: 0,
|
||||
txid: null,
|
||||
startedAt: null,
|
||||
cashInSessionId: null,
|
||||
}
|
||||
|
||||
/** Service inputs for actors */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue