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:
Patrick Mulligan 2026-01-31 08:12:34 -05:00
commit 595ac3b863
10 changed files with 991 additions and 52 deletions

View file

@ -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

View file

@ -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"]
} }

View 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"]
}

View file

@ -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",

View file

@ -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
}, },

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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)
*/ */

View file

@ -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 */