diff --git a/lamassu-next/docker/strfry.conf b/lamassu-next/docker/strfry.conf index 686f68d..e775670 100644 --- a/lamassu-next/docker/strfry.conf +++ b/lamassu-next/docker/strfry.conf @@ -40,6 +40,15 @@ events { # 22242: NIP-42 auth # 30078: Service Beacon (replaceable, service discovery) # 30079: Transaction records (replaceable) + + # TODO: Review if these ephemeral event settings actually do anything useful. + # CLINK events (21000-21003) are in the ephemeral range but need to persist + # long enough for request/response flows. These settings may not be recognized + # by strfry - verify against strfry documentation. + # Allow ephemeral events up to 5 minutes old to be accepted + rejectEphemeralEventsOlderThanSeconds = 300 + # Keep ephemeral events for 5 minutes before deletion + ephemeralEventsLifetimeSeconds = 300 } # Negentropy sync (for relay federation) diff --git a/lamassu-next/packages/nostr-client/mock-machine.mjs b/lamassu-next/packages/nostr-client/mock-machine.mjs index 8c85444..5aca755 100644 --- a/lamassu-next/packages/nostr-client/mock-machine.mjs +++ b/lamassu-next/packages/nostr-client/mock-machine.mjs @@ -33,6 +33,18 @@ let LIGHTNING_PUB_PUBKEY = null const ATM_PRIVATE_KEY = generateSecretKey() const ATM_PUBLIC_KEY = getPublicKey(ATM_PRIVATE_KEY) +// Fake exchange rate: sats per USD (approximately $100k/BTC) +const SATS_PER_USD = 1000 + +// ATM states +const ATM_STATE = { + IDLE: 'idle', + CASH_INSERTED: 'cash_inserted', + WAITING_FOR_SCAN: 'waiting_for_scan', + PROCESSING: 'processing', + COMPLETE: 'complete', +} + // State let relay = null let appToken = null @@ -41,7 +53,9 @@ let ndebitQR = null let atmBalance = 0 let wsClients = [] let isLinked = false -let withdrawAmount = 1000 // Default withdrawal amount in sats +let withdrawAmount = 0 // Amount in sats (calculated from cash) +let cashInserted = 0 // Amount in USD +let atmState = ATM_STATE.IDLE // Rewrite ndebit relay for browser access (strfry:7777 -> localhost:7777) function rewriteNdebitRelay(ndebitString) { @@ -239,13 +253,16 @@ async function subscribeToDebitRequests() { async function handleDebitRequest(message, conversationKey) { const { debit } = message + atmState = ATM_STATE.PROCESSING + broadcastState() + log(`[ALERT] DEBIT REQUEST RECEIVED!`) log(` Amount: ${debit.amount || 'invoice amount'} sats`) log(` Type: ${debit.type}`) broadcast('debit_request', { debit }) - // Auto-approve after 2 seconds + // Auto-approve after 1 second setTimeout(async () => { log('[OK] Auto-approving debit request...') @@ -274,11 +291,19 @@ async function handleDebitRequest(message, conversationKey) { ) await relay.publish(event) - log('[OK] Approval sent!') + log('[OK] Approval sent! Payment complete.') - // Update balance after a short delay - setTimeout(updateBalance, 2000) - }, 2000) + // Mark as complete + atmState = ATM_STATE.COMPLETE + broadcastState() + + // Update balance and reset after delay + setTimeout(async () => { + await updateBalance() + // Auto-reset after showing success + setTimeout(resetAtm, 3000) + }, 2000) + }, 1000) } async function updateBalance() { @@ -286,14 +311,7 @@ async function updateBalance() { if (user.status === 'OK') { atmBalance = user.info.balance ndebit = user.info.ndebit - await regenerateQR() - broadcast('status', { - balance: atmBalance, - ndebit, - ndebitQR, - ndebitUri: formatNdebitUri(ndebit, withdrawAmount), - withdrawAmount, - }) + broadcastState() log(`Balance updated: ${atmBalance} sats`) } } @@ -309,14 +327,44 @@ async function regenerateQR() { function setWithdrawAmount(amount) { withdrawAmount = amount regenerateQR() + broadcastState() + log(`Withdrawal amount set to ${amount} sats`) +} + +async function insertCash(usdAmount) { + cashInserted = usdAmount + withdrawAmount = usdAmount * SATS_PER_USD + atmState = ATM_STATE.CASH_INSERTED + log(`[CASH] $${usdAmount} inserted → ${withdrawAmount.toLocaleString()} sats`) + + // Brief delay then show QR + await new Promise((r) => setTimeout(r, 500)) + atmState = ATM_STATE.WAITING_FOR_SCAN + await regenerateQR() + broadcastState() + log(`[QR] Scan to receive ${withdrawAmount.toLocaleString()} sats`) +} + +function resetAtm() { + atmState = ATM_STATE.IDLE + cashInserted = 0 + withdrawAmount = 0 + ndebitQR = null + broadcastState() + log('[RESET] ATM ready for next customer') +} + +function broadcastState() { broadcast('status', { balance: atmBalance, ndebit, ndebitQR, - ndebitUri: formatNdebitUri(ndebit, withdrawAmount), + ndebitUri: withdrawAmount > 0 ? formatNdebitUri(ndebit, withdrawAmount) : null, withdrawAmount, + cashInserted, + atmState, + satsPerUsd: SATS_PER_USD, }) - log(`Withdrawal amount set to ${amount} sats`) } async function initialize() { @@ -354,19 +402,12 @@ async function initialize() { // Subscribe to debit requests await subscribeToDebitRequests() - // Generate QR code with amount - await regenerateQR() + // Start in IDLE state (no QR until cash inserted) + atmState = ATM_STATE.IDLE + broadcastState() - broadcast('status', { - balance: atmBalance, - ndebit, - ndebitQR, - ndebitUri: formatNdebitUri(ndebit, withdrawAmount), - withdrawAmount, - isLinked, - }) log('[READY] Mock ATM Machine ready!') - log(` Open http://localhost:${PORT} to see the QR code`) + log(` Open http://localhost:${PORT} to use the ATM`) } // HTML page @@ -383,22 +424,87 @@ const html = ` min-height: 100vh; padding: 20px; } - .container { max-width: 600px; margin: 0 auto; } - h1 { text-align: center; margin-bottom: 20px; color: #00d4ff; } + .container { max-width: 500px; margin: 0 auto; } + h1 { text-align: center; margin-bottom: 8px; color: #00d4ff; font-size: 28px; } + .subtitle { text-align: center; opacity: 0.6; margin-bottom: 20px; font-size: 14px; } .card { background: rgba(255,255,255,0.1); border-radius: 16px; padding: 24px; - margin-bottom: 20px; + margin-bottom: 16px; backdrop-filter: blur(10px); } - .balance { - font-size: 48px; - text-align: center; - color: #00ff88; - font-weight: bold; + .balance-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: rgba(0,0,0,0.2); + border-radius: 8px; + margin-bottom: 16px; + font-size: 14px; } - .balance span { font-size: 24px; opacity: 0.7; } + .balance-bar .label { opacity: 0.7; } + .balance-bar .value { color: #00ff88; font-weight: bold; } + .exchange-rate { font-size: 12px; opacity: 0.5; } + + /* ATM Screen */ + .atm-screen { + min-height: 400px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + } + .screen-title { + font-size: 24px; + font-weight: bold; + margin-bottom: 8px; + } + .screen-subtitle { + opacity: 0.7; + margin-bottom: 24px; + } + + /* Cash buttons */ + .cash-buttons { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + width: 100%; + max-width: 320px; + } + .cash-btn { + background: linear-gradient(135deg, #2d5a27 0%, #1e3d1a 100%); + border: 2px solid #3d7a35; + border-radius: 12px; + padding: 24px 16px; + color: #fff; + cursor: pointer; + transition: all 0.2s; + text-align: center; + } + .cash-btn:hover { + transform: scale(1.05); + border-color: #00ff88; + box-shadow: 0 4px 20px rgba(0,255,136,0.3); + } + .cash-btn:active { + transform: scale(0.98); + } + .cash-btn .amount { + font-size: 32px; + font-weight: bold; + color: #00ff88; + } + .cash-btn .sats { + font-size: 14px; + opacity: 0.8; + margin-top: 4px; + } + + /* QR Display */ #qr-container { display: flex; justify-content: center; @@ -407,110 +513,158 @@ const html = ` border-radius: 12px; margin: 20px 0; } - #qr-container canvas { max-width: 100%; height: auto !important; } + #qr-container img { max-width: 250px; } + .amount-display { + font-size: 36px; + font-weight: bold; + color: #00ff88; + margin-bottom: 8px; + } + .amount-usd { + font-size: 18px; + opacity: 0.7; + margin-bottom: 16px; + } .ndebit-code { font-family: monospace; - font-size: 10px; + font-size: 9px; word-break: break-all; background: rgba(0,0,0,0.3); padding: 12px; border-radius: 8px; - margin-top: 12px; + margin-top: 16px; + max-width: 100%; } + .cancel-btn { + background: transparent; + border: 2px solid rgba(255,255,255,0.3); + border-radius: 8px; + padding: 12px 32px; + color: #fff; + cursor: pointer; + margin-top: 16px; + font-size: 14px; + } + .cancel-btn:hover { + border-color: #ff6b6b; + color: #ff6b6b; + } + + /* Processing */ + .spinner { + width: 60px; + height: 60px; + border: 4px solid rgba(255,255,255,0.2); + border-top-color: #00d4ff; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 20px; + } + @keyframes spin { + to { transform: rotate(360deg); } + } + + /* Success */ + .success-icon { + width: 80px; + height: 80px; + background: #00ff88; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; + font-size: 40px; + } + + /* Logs */ + .logs-card { margin-top: 8px; } + .logs-card h3 { font-size: 14px; margin-bottom: 8px; opacity: 0.7; } .logs { background: rgba(0,0,0,0.3); border-radius: 8px; padding: 12px; - height: 200px; + height: 150px; overflow-y: auto; font-family: monospace; - font-size: 12px; + font-size: 11px; } .log-entry { margin: 4px 0; } - .status { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } - .status-dot { - width: 12px; height: 12px; - border-radius: 50%; - background: #00ff88; - animation: pulse 2s infinite; - } - @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } - } - .instructions { - text-align: center; - opacity: 0.8; - margin-top: 12px; - } - .amount-input { - display: flex; - align-items: center; - justify-content: center; - gap: 12px; - margin-bottom: 16px; - } - .amount-input input { - background: rgba(0,0,0,0.3); - border: 2px solid #00d4ff; - border-radius: 8px; - padding: 12px 16px; - font-size: 24px; - color: #fff; - width: 150px; - text-align: center; - } - .amount-input input:focus { - outline: none; - border-color: #00ff88; - } - .amount-input button { - background: #00d4ff; - border: none; - border-radius: 8px; - padding: 12px 24px; - font-size: 16px; - color: #1a1a2e; - cursor: pointer; - font-weight: bold; - } - .amount-input button:hover { - background: #00ff88; - } - .amount-label { - font-size: 18px; - opacity: 0.8; - } + .log-entry.alert { color: #00d4ff; } + .log-entry.success { color: #00ff88; } + + .hidden { display: none !important; }
-

Mock ATM Machine

+

Mock ATM

+

Simulated Bitcoin ATM for testing

-
-
-
- Ready for deposits -
-
0 sats
+
+ ATM Balance: + 0 sats
-

Scan to Withdraw

-
- Amount: - - sats - +
+ +
+
Insert Cash
+
Select amount to withdraw as Bitcoin
+
+ + + + +
+

Rate: 1,000 sats/$

+
+ + + + + + + + +
-
- QR Code -
-

Scan with ShockWallet to claim 1000 sats

-
Loading...
-
+

Activity Log

@@ -519,63 +673,109 @@ const html = ` @@ -599,8 +799,10 @@ wss.on('connection', (ws) => { ws.on('message', (data) => { try { const msg = JSON.parse(data) - if (msg.type === 'set_amount' && typeof msg.amount === 'number') { - setWithdrawAmount(msg.amount) + if (msg.type === 'insert_cash' && typeof msg.amount === 'number') { + insertCash(msg.amount) + } else if (msg.type === 'reset') { + resetAtm() } } catch (e) {} }) @@ -613,8 +815,11 @@ wss.on('connection', (ws) => { balance: atmBalance, ndebit, ndebitQR, - ndebitUri: formatNdebitUri(ndebit, withdrawAmount), + ndebitUri: withdrawAmount > 0 ? formatNdebitUri(ndebit, withdrawAmount) : null, withdrawAmount, + cashInserted, + atmState, + satsPerUsd: SATS_PER_USD, isLinked, }) ) diff --git a/lamassu-next/packages/nostr-client/test-ndebit.mjs b/lamassu-next/packages/nostr-client/test-ndebit.mjs new file mode 100644 index 0000000..bcddaf4 --- /dev/null +++ b/lamassu-next/packages/nostr-client/test-ndebit.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node +/** + * Test script to simulate a wallet sending an ndebit claim request + * This tests whether Lightning.Pub sends Kind 21002 responses after the fix + */ + +import { Relay } from 'nostr-tools/relay' +import { nip44, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools' +import { decodeBech32 } from '@shocknet/clink-sdk' + +const { getConversationKey, encrypt, decrypt } = nip44 + +const NDEBIT = + 'ndebit1qgpkzardqyg8wue69uhhxarjvee8jw3hxumnwqpqf05wyqarxsdm9d62fh9lsa6wqc2r0a37cgd00mp3gnydpf5w9uusavytcn' +const RELAY_URL = 'ws://localhost:7777' +const AMOUNT_SATS = 5000 // Small test amount + +// Generate a wallet keypair for this test +const WALLET_PRIVATE_KEY = generateSecretKey() +const WALLET_PUBLIC_KEY = getPublicKey(WALLET_PRIVATE_KEY) + +async function main() { + console.log('šŸ”§ Test: ndebit claim flow (NIP-44 v2)') + console.log('='.repeat(50)) + + // Decode ndebit to get Lightning.Pub pubkey and pointer + const decoded = decodeBech32(NDEBIT) + const LPUB_PUBKEY = decoded.data.pubkey + const POINTER = decoded.data.pointer + console.log(`\nšŸ“ Lightning.Pub pubkey: ${LPUB_PUBKEY.slice(0, 16)}...`) + console.log(`šŸ”‘ Pointer (user ID): ${POINTER.slice(0, 16)}...`) + console.log(`šŸ‘› Test wallet pubkey: ${WALLET_PUBLIC_KEY.slice(0, 16)}...`) + console.log(`šŸ’° Amount: ${AMOUNT_SATS} sats`) + + // Connect to relay + console.log(`\nšŸ”Œ Connecting to relay: ${RELAY_URL}`) + const relay = await Relay.connect(RELAY_URL) + console.log('āœ… Connected!') + + // Build the debit request data (NdebitData format) + // Using newNdebitFullAccessRequest format with amount + const debitData = { + amount_sats: AMOUNT_SATS, + pointer: POINTER, + } + + // Encrypt using NIP-44 v2 + const conversationKey = getConversationKey(WALLET_PRIVATE_KEY, LPUB_PUBKEY) + const encryptedContent = encrypt(JSON.stringify(debitData), conversationKey) + + // Build event with correct tags (including clink_version) + const event = finalizeEvent( + { + kind: 21002, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['p', LPUB_PUBKEY], + ['clink_version', '1'], + ], + content: encryptedContent, + }, + WALLET_PRIVATE_KEY + ) + + console.log(`\nšŸ“¤ Sending Kind 21002 debit request`) + console.log(` Event ID: ${event.id.slice(0, 16)}...`) + console.log(` Content length: ${encryptedContent.length} chars`) + + // Subscribe for responses BEFORE sending the request + let responseReceived = false + const startTime = Date.now() + + // Filter for Kind 21002 responses from Lightning.Pub that reference our event + const sub = relay.subscribe( + [ + { + kinds: [21002], + authors: [LPUB_PUBKEY], + '#p': [WALLET_PUBLIC_KEY], + '#e': [event.id], + since: Math.floor(Date.now() / 1000) - 5, + }, + ], + { + onevent(evt) { + console.log(`\nšŸ“„ Received Kind 21002 response!`) + console.log(` Event ID: ${evt.id.slice(0, 16)}...`) + console.log(` Author: ${evt.pubkey.slice(0, 16)}...`) + + // Check #e tag (should reference our original event) + const eTag = evt.tags.find((t) => t[0] === 'e') + if (eTag) { + console.log(` #e tag: ${eTag[1].slice(0, 16)}...`) + if (eTag[1] === event.id) { + console.log(' āœ… Correctly references our original event!') + } + } else { + console.log(' āš ļø No #e tag found') + } + + try { + const response = JSON.parse(decrypt(evt.content, conversationKey)) + console.log(`\nšŸ“‹ Response content:`) + console.log(JSON.stringify(response, null, 2)) + + if (response.res === 'OK') { + console.log('\nāœ…āœ…āœ… SUCCESS! Lightning.Pub sent Kind 21002 response correctly!') + console.log(' The fix is working!') + } else if (response.res === 'GFY' || response.error) { + console.log(`\nāš ļø Response indicates error: ${response.error || 'unknown'}`) + console.log(' (Expected if payment denied or auth required)') + } + } catch (e) { + console.log(' āŒ Could not decrypt response:', e.message) + } + + responseReceived = true + }, + } + ) + + // Publish the debit request + await relay.publish(event) + console.log('āœ… Request published!') + + // Wait for response with timeout + console.log('\nā³ Waiting for Kind 21002 response (30s timeout)...') + const timeout = 30000 + const checkInterval = 1000 + + while (!responseReceived && Date.now() - startTime < timeout) { + await new Promise((r) => setTimeout(r, checkInterval)) + const elapsed = Math.floor((Date.now() - startTime) / 1000) + process.stdout.write(`\r ${elapsed}s elapsed...`) + } + + console.log('') + + if (!responseReceived) { + console.log('\nāŒāŒāŒ TIMEOUT! No Kind 21002 response received.') + console.log(' This means the fix did NOT work or there was another issue.') + + // Let's check what Kind 21002 events exist + console.log('\nšŸ” Checking for any Kind 21002 events on relay...') + let foundEvents = 0 + const allDebitSub = relay.subscribe( + [ + { + kinds: [21002], + limit: 10, + }, + ], + { + onevent(evt) { + foundEvents++ + const pTags = evt.tags.filter((t) => t[0] === 'p').map((t) => t[1].slice(0, 8) + '...') + const eTags = evt.tags.filter((t) => t[0] === 'e').map((t) => t[1].slice(0, 8) + '...') + console.log( + ` ${foundEvents}. id=${evt.id.slice(0, 12)}... by=${evt.pubkey.slice(0, 8)}... #p=${pTags.join(',')} #e=${eTags.join(',')}` + ) + }, + oneose() { + console.log(` (Found ${foundEvents} Kind 21002 events total)`) + }, + } + ) + + await new Promise((r) => setTimeout(r, 3000)) + allDebitSub.close() + } + + sub.close() + relay.close() + console.log('\nšŸ Test complete') + process.exit(responseReceived ? 0 : 1) +} + +main().catch((e) => { + console.error('Fatal error:', e) + process.exit(1) +})