chore(nostr-client): move dev scripts to dev/ folder
Move 9 development/testing .mjs scripts out of the package root into dev/ to keep the published package clean. Update relative imports and dev.sh reference. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
01bac04cdf
commit
5448a620a9
10 changed files with 3 additions and 3 deletions
833
packages/nostr-client/dev/mock-machine.mjs
Normal file
833
packages/nostr-client/dev/mock-machine.mjs
Normal file
|
|
@ -0,0 +1,833 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Mock ATM Machine - Simulates the ATM cash-in flow with CLINK
|
||||
*
|
||||
* Usage: node mock-machine.mjs
|
||||
*
|
||||
* This creates a web server that:
|
||||
* 1. Displays the ndebit QR code for customers to scan
|
||||
* 2. Runs the ATM debit agent to authorize payments
|
||||
* 3. Shows real-time status updates
|
||||
*/
|
||||
|
||||
import http from 'http'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import { Relay } from 'nostr-tools/relay'
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { getConversationKey, encrypt, decrypt } from './nip44v1.mjs'
|
||||
import { randomUUID } from 'crypto'
|
||||
import QRCode from 'qrcode'
|
||||
import { decodeBech32, ndebitEncode } from '@shocknet/clink-sdk'
|
||||
|
||||
const PORT = 3456
|
||||
const RELAY_URL = 'ws://localhost:7777'
|
||||
// Relay URL for browser access (different from Docker internal strfry:7777)
|
||||
const BROWSER_RELAY_URL = 'ws://localhost:7777'
|
||||
const LIGHTNING_PUB_HTTP = 'http://localhost:1776'
|
||||
const ADMIN_TOKEN = 'lamassu-dev-admin-token'
|
||||
|
||||
// Lightning.Pub pubkey - fetched dynamically from the ATM user's ndebit
|
||||
let LIGHTNING_PUB_PUBKEY = null
|
||||
|
||||
// ATM keypair (persistent for this session)
|
||||
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
|
||||
let ndebit = null
|
||||
let ndebitQR = null
|
||||
let atmBalance = 0
|
||||
let wsClients = []
|
||||
let isLinked = false
|
||||
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) {
|
||||
try {
|
||||
const decoded = decodeBech32(ndebitString)
|
||||
if (decoded.type !== 'ndebit') return ndebitString
|
||||
|
||||
// Replace Docker internal relay with browser-accessible relay
|
||||
const data = {
|
||||
pubkey: decoded.data.pubkey,
|
||||
relay: BROWSER_RELAY_URL,
|
||||
pointer: decoded.data.pointer,
|
||||
}
|
||||
return ndebitEncode(data)
|
||||
} catch (e) {
|
||||
console.error('Failed to rewrite ndebit relay:', e)
|
||||
return ndebitString
|
||||
}
|
||||
}
|
||||
|
||||
// Format ndebit with clink: prefix and amount parameter
|
||||
// Using clink: instead of lightning: because CLINK is protocol-agnostic
|
||||
// (could work with Cashu/Fedimint, not just Lightning)
|
||||
function formatNdebitUri(ndebitString, amount) {
|
||||
const rewritten = rewriteNdebitRelay(ndebitString)
|
||||
return `clink:${rewritten}?amount=${amount}`
|
||||
}
|
||||
|
||||
function broadcast(type, data) {
|
||||
const msg = JSON.stringify({ type, ...data })
|
||||
wsClients.forEach((ws) => {
|
||||
if (ws.readyState === 1) ws.send(msg)
|
||||
})
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
const timestamp = new Date().toLocaleTimeString()
|
||||
console.log(`[${timestamp}] ${message}`)
|
||||
broadcast('log', { message: `[${timestamp}] ${message}` })
|
||||
}
|
||||
|
||||
async function getAppToken() {
|
||||
const res = await fetch(`${LIGHTNING_PUB_HTTP}/api/admin/app/auth`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ADMIN_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: 'wallet' }),
|
||||
})
|
||||
const data = await res.json()
|
||||
return data.auth_token
|
||||
}
|
||||
|
||||
async function getAtmUser() {
|
||||
const res = await fetch(`${LIGHTNING_PUB_HTTP}/api/app/user/get`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${appToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_identifier: 'atm' }),
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function getLinkingToken() {
|
||||
const res = await fetch(`${LIGHTNING_PUB_HTTP}/api/app/user/npub/token/reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${appToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_identifier: 'atm' }),
|
||||
})
|
||||
const data = await res.json()
|
||||
return data.token
|
||||
}
|
||||
|
||||
async function sendRPC(rpcName, body) {
|
||||
const requestId = randomUUID()
|
||||
const request = {
|
||||
rpcName,
|
||||
authIdentifier: ATM_PUBLIC_KEY,
|
||||
body,
|
||||
}
|
||||
|
||||
const conversationKey = getConversationKey(ATM_PRIVATE_KEY, LIGHTNING_PUB_PUBKEY)
|
||||
const encryptedContent = encrypt(JSON.stringify(request), conversationKey)
|
||||
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 21000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||
content: encryptedContent,
|
||||
},
|
||||
ATM_PRIVATE_KEY
|
||||
)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('RPC timeout')), 30000)
|
||||
|
||||
const sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [LIGHTNING_PUB_PUBKEY],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(evt) {
|
||||
const pTags = evt.tags.filter((t) => t[0] === 'p')
|
||||
if (!pTags.some((t) => t[1] === ATM_PUBLIC_KEY)) return
|
||||
|
||||
try {
|
||||
const response = JSON.parse(decrypt(evt.content, conversationKey))
|
||||
clearTimeout(timeout)
|
||||
sub.close()
|
||||
resolve(response)
|
||||
} catch (e) {}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
relay.publish(event)
|
||||
})
|
||||
}
|
||||
|
||||
async function linkKeypair(token) {
|
||||
log('Linking ATM keypair to user account...')
|
||||
const response = await sendRPC('LinkNPubThroughToken', { token })
|
||||
if (response.status === 'OK') {
|
||||
log('[OK] Keypair linked successfully!')
|
||||
isLinked = true
|
||||
return true
|
||||
} else {
|
||||
log(`[ERROR] Link failed: ${response.reason}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeToDebitRequests() {
|
||||
log('Subscribing to debit requests...')
|
||||
|
||||
const conversationKey = getConversationKey(ATM_PRIVATE_KEY, LIGHTNING_PUB_PUBKEY)
|
||||
|
||||
// Subscribe to Kind 21000 messages from Lightning.Pub
|
||||
relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [LIGHTNING_PUB_PUBKEY],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(evt) {
|
||||
const pTags = evt.tags.filter((t) => t[0] === 'p')
|
||||
if (!pTags.some((t) => t[1] === ATM_PUBLIC_KEY)) return
|
||||
|
||||
try {
|
||||
const message = JSON.parse(decrypt(evt.content, conversationKey))
|
||||
|
||||
if (message.debit) {
|
||||
handleDebitRequest(message, conversationKey)
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Send GetLiveDebitRequests to start the stream
|
||||
const request = {
|
||||
rpcName: 'GetLiveDebitRequests',
|
||||
authIdentifier: ATM_PUBLIC_KEY,
|
||||
body: {},
|
||||
}
|
||||
|
||||
const encryptedContent = encrypt(JSON.stringify(request), conversationKey)
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 21000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||
content: encryptedContent,
|
||||
},
|
||||
ATM_PRIVATE_KEY
|
||||
)
|
||||
|
||||
await relay.publish(event)
|
||||
log('[OK] Listening for debit requests...')
|
||||
}
|
||||
|
||||
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 1 second
|
||||
setTimeout(async () => {
|
||||
log('[OK] Auto-approving debit request...')
|
||||
|
||||
const approval = {
|
||||
rpcName: 'RespondToDebit',
|
||||
authIdentifier: ATM_PUBLIC_KEY,
|
||||
body: {
|
||||
npub: message.npub,
|
||||
request_id: message.request_id,
|
||||
response: {
|
||||
type: 'invoice',
|
||||
invoice: debit.invoice,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const encryptedContent = encrypt(JSON.stringify(approval), conversationKey)
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 21000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||
content: encryptedContent,
|
||||
},
|
||||
ATM_PRIVATE_KEY
|
||||
)
|
||||
|
||||
await relay.publish(event)
|
||||
log('[OK] Approval sent! Payment complete.')
|
||||
|
||||
// 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() {
|
||||
const user = await getAtmUser()
|
||||
if (user.status === 'OK') {
|
||||
atmBalance = user.info.balance
|
||||
ndebit = user.info.ndebit
|
||||
broadcastState()
|
||||
log(`Balance updated: ${atmBalance} sats`)
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerateQR() {
|
||||
if (ndebit) {
|
||||
const uri = formatNdebitUri(ndebit, withdrawAmount)
|
||||
ndebitQR = await QRCode.toDataURL(uri, { width: 300, margin: 2 })
|
||||
log(`QR code generated for ${withdrawAmount} sats`)
|
||||
}
|
||||
}
|
||||
|
||||
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: withdrawAmount > 0 ? formatNdebitUri(ndebit, withdrawAmount) : null,
|
||||
withdrawAmount,
|
||||
cashInserted,
|
||||
atmState,
|
||||
satsPerUsd: SATS_PER_USD,
|
||||
})
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
log('Starting Mock ATM Machine...')
|
||||
|
||||
// Get app token
|
||||
appToken = await getAppToken()
|
||||
log('Got app token')
|
||||
|
||||
// Get ATM user info
|
||||
const user = await getAtmUser()
|
||||
if (user.status === 'OK') {
|
||||
atmBalance = user.info.balance
|
||||
ndebit = user.info.ndebit
|
||||
|
||||
// Extract Lightning.Pub pubkey from ndebit
|
||||
const decoded = decodeBech32(ndebit)
|
||||
LIGHTNING_PUB_PUBKEY = decoded.data.pubkey
|
||||
log(`ATM user found: ${atmBalance} sats balance`)
|
||||
log(`Lightning.Pub pubkey: ${LIGHTNING_PUB_PUBKEY.substring(0, 16)}...`)
|
||||
} else {
|
||||
log('ATM user not found - please create one first')
|
||||
return
|
||||
}
|
||||
|
||||
// Connect to relay
|
||||
log('Connecting to relay...')
|
||||
relay = await Relay.connect(RELAY_URL)
|
||||
log('Connected to relay')
|
||||
|
||||
// Get linking token and link keypair
|
||||
const token = await getLinkingToken()
|
||||
await linkKeypair(token)
|
||||
|
||||
// Subscribe to debit requests
|
||||
await subscribeToDebitRequests()
|
||||
|
||||
// Start in IDLE state (no QR until cash inserted)
|
||||
atmState = ATM_STATE.IDLE
|
||||
broadcastState()
|
||||
|
||||
log('[READY] Mock ATM Machine ready!')
|
||||
log(` Open http://localhost:${PORT} to use the ATM`)
|
||||
}
|
||||
|
||||
// HTML page
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Mock ATM Machine</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #fff;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.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: 16px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.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-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;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
#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: 9px;
|
||||
word-break: break-all;
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
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: 150px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.log-entry { margin: 4px 0; }
|
||||
.log-entry.alert { color: #00d4ff; }
|
||||
.log-entry.success { color: #00ff88; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Mock ATM</h1>
|
||||
<p class="subtitle">Simulated Bitcoin ATM for testing</p>
|
||||
|
||||
<div class="balance-bar">
|
||||
<span class="label">ATM Balance:</span>
|
||||
<span class="value"><span id="balance">0</span> sats</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="atm-screen">
|
||||
<!-- IDLE State -->
|
||||
<div id="screen-idle">
|
||||
<div class="screen-title">Insert Cash</div>
|
||||
<div class="screen-subtitle">Select amount to withdraw as Bitcoin</div>
|
||||
<div class="cash-buttons">
|
||||
<button class="cash-btn" data-usd="20">
|
||||
<div class="amount">$20</div>
|
||||
<div class="sats" id="sats-20">20,000 sats</div>
|
||||
</button>
|
||||
<button class="cash-btn" data-usd="50">
|
||||
<div class="amount">$50</div>
|
||||
<div class="sats" id="sats-50">50,000 sats</div>
|
||||
</button>
|
||||
<button class="cash-btn" data-usd="100">
|
||||
<div class="amount">$100</div>
|
||||
<div class="sats" id="sats-100">100,000 sats</div>
|
||||
</button>
|
||||
<button class="cash-btn" data-usd="200">
|
||||
<div class="amount">$200</div>
|
||||
<div class="sats" id="sats-200">200,000 sats</div>
|
||||
</button>
|
||||
</div>
|
||||
<p class="exchange-rate">Rate: <span id="rate">1,000</span> sats/$</p>
|
||||
</div>
|
||||
|
||||
<!-- WAITING_FOR_SCAN State -->
|
||||
<div id="screen-scan" class="hidden">
|
||||
<div class="screen-title">Scan QR Code</div>
|
||||
<div class="screen-subtitle">Open your wallet and scan to receive</div>
|
||||
<div class="amount-display"><span id="display-sats">0</span> sats</div>
|
||||
<div class="amount-usd">($<span id="display-usd">0</span>)</div>
|
||||
<div id="qr-container">
|
||||
<img id="qr" alt="QR Code" />
|
||||
</div>
|
||||
<div class="ndebit-code" id="ndebit-code"></div>
|
||||
<button class="cancel-btn" id="cancel-btn">Cancel Transaction</button>
|
||||
</div>
|
||||
|
||||
<!-- PROCESSING State -->
|
||||
<div id="screen-processing" class="hidden">
|
||||
<div class="spinner"></div>
|
||||
<div class="screen-title">Processing...</div>
|
||||
<div class="screen-subtitle">Verifying payment request</div>
|
||||
</div>
|
||||
|
||||
<!-- COMPLETE State -->
|
||||
<div id="screen-complete" class="hidden">
|
||||
<div class="success-icon">✓</div>
|
||||
<div class="screen-title">Success!</div>
|
||||
<div class="screen-subtitle">
|
||||
<span id="complete-sats">0</span> sats sent to your wallet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card logs-card">
|
||||
<h3>Activity Log</h3>
|
||||
<div class="logs" id="logs"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ws = new WebSocket('ws://localhost:3456')
|
||||
const logs = document.getElementById('logs')
|
||||
|
||||
// Screen elements
|
||||
const screens = {
|
||||
idle: document.getElementById('screen-idle'),
|
||||
scan: document.getElementById('screen-scan'),
|
||||
processing: document.getElementById('screen-processing'),
|
||||
complete: document.getElementById('screen-complete'),
|
||||
}
|
||||
|
||||
function showScreen(name) {
|
||||
Object.values(screens).forEach(s => s.classList.add('hidden'))
|
||||
if (screens[name]) screens[name].classList.remove('hidden')
|
||||
}
|
||||
|
||||
// Cash buttons
|
||||
document.querySelectorAll('.cash-btn').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const usd = parseInt(btn.dataset.usd, 10)
|
||||
ws.send(JSON.stringify({ type: 'insert_cash', amount: usd }))
|
||||
}
|
||||
})
|
||||
|
||||
// Cancel button
|
||||
document.getElementById('cancel-btn').onclick = () => {
|
||||
ws.send(JSON.stringify({ type: 'reset' }))
|
||||
}
|
||||
|
||||
function addLog(message, type = '') {
|
||||
const entry = document.createElement('div')
|
||||
entry.className = 'log-entry' + (type ? ' ' + type : '')
|
||||
entry.textContent = message
|
||||
logs.appendChild(entry)
|
||||
logs.scrollTop = logs.scrollHeight
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
if (data.type === 'log') {
|
||||
const isAlert = data.message.includes('[ALERT]')
|
||||
const isSuccess = data.message.includes('[OK]') || data.message.includes('Success')
|
||||
addLog(data.message, isAlert ? 'alert' : isSuccess ? 'success' : '')
|
||||
}
|
||||
|
||||
if (data.type === 'status') {
|
||||
// Update balance
|
||||
document.getElementById('balance').textContent = data.balance.toLocaleString()
|
||||
|
||||
// Update exchange rate display
|
||||
if (data.satsPerUsd) {
|
||||
document.getElementById('rate').textContent = data.satsPerUsd.toLocaleString()
|
||||
document.getElementById('sats-20').textContent = (20 * data.satsPerUsd).toLocaleString() + ' sats'
|
||||
document.getElementById('sats-50').textContent = (50 * data.satsPerUsd).toLocaleString() + ' sats'
|
||||
document.getElementById('sats-100').textContent = (100 * data.satsPerUsd).toLocaleString() + ' sats'
|
||||
document.getElementById('sats-200').textContent = (200 * data.satsPerUsd).toLocaleString() + ' sats'
|
||||
}
|
||||
|
||||
// Update amounts
|
||||
if (data.withdrawAmount !== undefined) {
|
||||
document.getElementById('display-sats').textContent = data.withdrawAmount.toLocaleString()
|
||||
document.getElementById('complete-sats').textContent = data.withdrawAmount.toLocaleString()
|
||||
}
|
||||
if (data.cashInserted !== undefined) {
|
||||
document.getElementById('display-usd').textContent = data.cashInserted
|
||||
}
|
||||
|
||||
// Update QR
|
||||
if (data.ndebitQR) {
|
||||
document.getElementById('qr').src = data.ndebitQR
|
||||
}
|
||||
if (data.ndebitUri) {
|
||||
document.getElementById('ndebit-code').textContent = data.ndebitUri
|
||||
}
|
||||
|
||||
// Show correct screen based on state
|
||||
switch (data.atmState) {
|
||||
case 'idle':
|
||||
showScreen('idle')
|
||||
break
|
||||
case 'cash_inserted':
|
||||
case 'waiting_for_scan':
|
||||
showScreen('scan')
|
||||
break
|
||||
case 'processing':
|
||||
showScreen('processing')
|
||||
break
|
||||
case 'complete':
|
||||
showScreen('complete')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === 'debit_request') {
|
||||
addLog('[DEBIT] Request received from wallet', 'alert')
|
||||
}
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
addLog('Connected to ATM server')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
addLog('Connection error - is the server running?')
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// Create HTTP server
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' })
|
||||
res.end(html)
|
||||
})
|
||||
|
||||
// Create WebSocket server
|
||||
const wss = new WebSocketServer({ server })
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
wsClients.push(ws)
|
||||
ws.on('close', () => {
|
||||
wsClients = wsClients.filter((c) => c !== ws)
|
||||
})
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data)
|
||||
if (msg.type === 'insert_cash' && typeof msg.amount === 'number') {
|
||||
insertCash(msg.amount)
|
||||
} else if (msg.type === 'reset') {
|
||||
resetAtm()
|
||||
}
|
||||
} catch (e) {}
|
||||
})
|
||||
|
||||
// Send current state
|
||||
if (ndebit) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
balance: atmBalance,
|
||||
ndebit,
|
||||
ndebitQR,
|
||||
ndebitUri: withdrawAmount > 0 ? formatNdebitUri(ndebit, withdrawAmount) : null,
|
||||
withdrawAmount,
|
||||
cashInserted,
|
||||
atmState,
|
||||
satsPerUsd: SATS_PER_USD,
|
||||
isLinked,
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Start server
|
||||
server.listen(PORT, async () => {
|
||||
console.log(`Mock ATM Machine running at http://localhost:${PORT}`)
|
||||
await initialize()
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue