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; }
Simulated Bitcoin ATM for testing
-Rate: 1,000 sats/$
+Scan with ShockWallet to claim 1000 sats
-