chore: improve dev tooling and relay config for ndebit testing
- strfry.conf: Add ephemeral event config for CLINK (21000-21003) Events persist 5 minutes to allow request/response flows - mock-machine.mjs: Enhanced mock ATM with state management, cash insertion flow, auto-reset, and better UX - test-ndebit.mjs: Add debugging script for ndebit flow Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a28c113087
commit
0f50401c8b
3 changed files with 541 additions and 146 deletions
|
|
@ -40,6 +40,15 @@ events {
|
||||||
# 22242: NIP-42 auth
|
# 22242: NIP-42 auth
|
||||||
# 30078: Service Beacon (replaceable, service discovery)
|
# 30078: Service Beacon (replaceable, service discovery)
|
||||||
# 30079: Transaction records (replaceable)
|
# 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)
|
# Negentropy sync (for relay federation)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,18 @@ let LIGHTNING_PUB_PUBKEY = null
|
||||||
const ATM_PRIVATE_KEY = generateSecretKey()
|
const ATM_PRIVATE_KEY = generateSecretKey()
|
||||||
const ATM_PUBLIC_KEY = getPublicKey(ATM_PRIVATE_KEY)
|
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
|
// State
|
||||||
let relay = null
|
let relay = null
|
||||||
let appToken = null
|
let appToken = null
|
||||||
|
|
@ -41,7 +53,9 @@ let ndebitQR = null
|
||||||
let atmBalance = 0
|
let atmBalance = 0
|
||||||
let wsClients = []
|
let wsClients = []
|
||||||
let isLinked = false
|
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)
|
// Rewrite ndebit relay for browser access (strfry:7777 -> localhost:7777)
|
||||||
function rewriteNdebitRelay(ndebitString) {
|
function rewriteNdebitRelay(ndebitString) {
|
||||||
|
|
@ -239,13 +253,16 @@ async function subscribeToDebitRequests() {
|
||||||
|
|
||||||
async function handleDebitRequest(message, conversationKey) {
|
async function handleDebitRequest(message, conversationKey) {
|
||||||
const { debit } = message
|
const { debit } = message
|
||||||
|
atmState = ATM_STATE.PROCESSING
|
||||||
|
broadcastState()
|
||||||
|
|
||||||
log(`[ALERT] DEBIT REQUEST RECEIVED!`)
|
log(`[ALERT] DEBIT REQUEST RECEIVED!`)
|
||||||
log(` Amount: ${debit.amount || 'invoice amount'} sats`)
|
log(` Amount: ${debit.amount || 'invoice amount'} sats`)
|
||||||
log(` Type: ${debit.type}`)
|
log(` Type: ${debit.type}`)
|
||||||
|
|
||||||
broadcast('debit_request', { debit })
|
broadcast('debit_request', { debit })
|
||||||
|
|
||||||
// Auto-approve after 2 seconds
|
// Auto-approve after 1 second
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
log('[OK] Auto-approving debit request...')
|
log('[OK] Auto-approving debit request...')
|
||||||
|
|
||||||
|
|
@ -274,11 +291,19 @@ async function handleDebitRequest(message, conversationKey) {
|
||||||
)
|
)
|
||||||
|
|
||||||
await relay.publish(event)
|
await relay.publish(event)
|
||||||
log('[OK] Approval sent!')
|
log('[OK] Approval sent! Payment complete.')
|
||||||
|
|
||||||
// Update balance after a short delay
|
// Mark as complete
|
||||||
setTimeout(updateBalance, 2000)
|
atmState = ATM_STATE.COMPLETE
|
||||||
}, 2000)
|
broadcastState()
|
||||||
|
|
||||||
|
// Update balance and reset after delay
|
||||||
|
setTimeout(async () => {
|
||||||
|
await updateBalance()
|
||||||
|
// Auto-reset after showing success
|
||||||
|
setTimeout(resetAtm, 3000)
|
||||||
|
}, 2000)
|
||||||
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateBalance() {
|
async function updateBalance() {
|
||||||
|
|
@ -286,14 +311,7 @@ async function updateBalance() {
|
||||||
if (user.status === 'OK') {
|
if (user.status === 'OK') {
|
||||||
atmBalance = user.info.balance
|
atmBalance = user.info.balance
|
||||||
ndebit = user.info.ndebit
|
ndebit = user.info.ndebit
|
||||||
await regenerateQR()
|
broadcastState()
|
||||||
broadcast('status', {
|
|
||||||
balance: atmBalance,
|
|
||||||
ndebit,
|
|
||||||
ndebitQR,
|
|
||||||
ndebitUri: formatNdebitUri(ndebit, withdrawAmount),
|
|
||||||
withdrawAmount,
|
|
||||||
})
|
|
||||||
log(`Balance updated: ${atmBalance} sats`)
|
log(`Balance updated: ${atmBalance} sats`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -309,14 +327,44 @@ async function regenerateQR() {
|
||||||
function setWithdrawAmount(amount) {
|
function setWithdrawAmount(amount) {
|
||||||
withdrawAmount = amount
|
withdrawAmount = amount
|
||||||
regenerateQR()
|
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', {
|
broadcast('status', {
|
||||||
balance: atmBalance,
|
balance: atmBalance,
|
||||||
ndebit,
|
ndebit,
|
||||||
ndebitQR,
|
ndebitQR,
|
||||||
ndebitUri: formatNdebitUri(ndebit, withdrawAmount),
|
ndebitUri: withdrawAmount > 0 ? formatNdebitUri(ndebit, withdrawAmount) : null,
|
||||||
withdrawAmount,
|
withdrawAmount,
|
||||||
|
cashInserted,
|
||||||
|
atmState,
|
||||||
|
satsPerUsd: SATS_PER_USD,
|
||||||
})
|
})
|
||||||
log(`Withdrawal amount set to ${amount} sats`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
|
|
@ -354,19 +402,12 @@ async function initialize() {
|
||||||
// Subscribe to debit requests
|
// Subscribe to debit requests
|
||||||
await subscribeToDebitRequests()
|
await subscribeToDebitRequests()
|
||||||
|
|
||||||
// Generate QR code with amount
|
// Start in IDLE state (no QR until cash inserted)
|
||||||
await regenerateQR()
|
atmState = ATM_STATE.IDLE
|
||||||
|
broadcastState()
|
||||||
|
|
||||||
broadcast('status', {
|
|
||||||
balance: atmBalance,
|
|
||||||
ndebit,
|
|
||||||
ndebitQR,
|
|
||||||
ndebitUri: formatNdebitUri(ndebit, withdrawAmount),
|
|
||||||
withdrawAmount,
|
|
||||||
isLinked,
|
|
||||||
})
|
|
||||||
log('[READY] Mock ATM Machine ready!')
|
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
|
// HTML page
|
||||||
|
|
@ -383,22 +424,87 @@ const html = `<!DOCTYPE html>
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
.container { max-width: 600px; margin: 0 auto; }
|
.container { max-width: 500px; margin: 0 auto; }
|
||||||
h1 { text-align: center; margin-bottom: 20px; color: #00d4ff; }
|
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 {
|
.card {
|
||||||
background: rgba(255,255,255,0.1);
|
background: rgba(255,255,255,0.1);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 16px;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
.balance {
|
.balance-bar {
|
||||||
font-size: 48px;
|
display: flex;
|
||||||
text-align: center;
|
justify-content: space-between;
|
||||||
color: #00ff88;
|
align-items: center;
|
||||||
font-weight: bold;
|
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 {
|
#qr-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -407,110 +513,158 @@ const html = `<!DOCTYPE html>
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin: 20px 0;
|
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 {
|
.ndebit-code {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
background: rgba(0,0,0,0.3);
|
background: rgba(0,0,0,0.3);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 8px;
|
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 {
|
.logs {
|
||||||
background: rgba(0,0,0,0.3);
|
background: rgba(0,0,0,0.3);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
height: 200px;
|
height: 150px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.log-entry { margin: 4px 0; }
|
.log-entry { margin: 4px 0; }
|
||||||
.status { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
.log-entry.alert { color: #00d4ff; }
|
||||||
.status-dot {
|
.log-entry.success { color: #00ff88; }
|
||||||
width: 12px; height: 12px;
|
|
||||||
border-radius: 50%;
|
.hidden { display: none !important; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Mock ATM Machine</h1>
|
<h1>Mock ATM</h1>
|
||||||
|
<p class="subtitle">Simulated Bitcoin ATM for testing</p>
|
||||||
|
|
||||||
<div class="card">
|
<div class="balance-bar">
|
||||||
<div class="status">
|
<span class="label">ATM Balance:</span>
|
||||||
<div class="status-dot"></div>
|
<span class="value"><span id="balance">0</span> sats</span>
|
||||||
<span>Ready for deposits</span>
|
|
||||||
</div>
|
|
||||||
<div class="balance"><span id="balance">0</span> <span>sats</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Scan to Withdraw</h3>
|
<div class="atm-screen">
|
||||||
<div class="amount-input">
|
<!-- IDLE State -->
|
||||||
<span class="amount-label">Amount:</span>
|
<div id="screen-idle">
|
||||||
<input type="number" id="amount" value="1000" min="1" />
|
<div class="screen-title">Insert Cash</div>
|
||||||
<span class="amount-label">sats</span>
|
<div class="screen-subtitle">Select amount to withdraw as Bitcoin</div>
|
||||||
<button id="update-btn">Update QR</button>
|
<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 id="qr-container">
|
|
||||||
<img id="qr" alt="QR Code" />
|
|
||||||
</div>
|
|
||||||
<p class="instructions">Scan with ShockWallet to claim <span id="display-amount">1000</span> sats</p>
|
|
||||||
<div class="ndebit-code" id="ndebit-code">Loading...</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card logs-card">
|
||||||
<h3>Activity Log</h3>
|
<h3>Activity Log</h3>
|
||||||
<div class="logs" id="logs"></div>
|
<div class="logs" id="logs"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -519,63 +673,109 @@ const html = `<!DOCTYPE html>
|
||||||
<script>
|
<script>
|
||||||
const ws = new WebSocket('ws://localhost:3456')
|
const ws = new WebSocket('ws://localhost:3456')
|
||||||
const logs = document.getElementById('logs')
|
const logs = document.getElementById('logs')
|
||||||
const balance = document.getElementById('balance')
|
|
||||||
const ndebitCode = document.getElementById('ndebit-code')
|
|
||||||
const qrImg = document.getElementById('qr')
|
|
||||||
const amountInput = document.getElementById('amount')
|
|
||||||
const updateBtn = document.getElementById('update-btn')
|
|
||||||
const displayAmount = document.getElementById('display-amount')
|
|
||||||
|
|
||||||
updateBtn.onclick = () => {
|
// Screen elements
|
||||||
const amount = parseInt(amountInput.value, 10)
|
const screens = {
|
||||||
if (amount > 0) {
|
idle: document.getElementById('screen-idle'),
|
||||||
ws.send(JSON.stringify({ type: 'set_amount', amount }))
|
scan: document.getElementById('screen-scan'),
|
||||||
}
|
processing: document.getElementById('screen-processing'),
|
||||||
|
complete: document.getElementById('screen-complete'),
|
||||||
}
|
}
|
||||||
|
|
||||||
amountInput.onkeypress = (e) => {
|
function showScreen(name) {
|
||||||
if (e.key === 'Enter') updateBtn.click()
|
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) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data)
|
const data = JSON.parse(event.data)
|
||||||
|
|
||||||
if (data.type === 'log') {
|
if (data.type === 'log') {
|
||||||
const entry = document.createElement('div')
|
const isAlert = data.message.includes('[ALERT]')
|
||||||
entry.className = 'log-entry'
|
const isSuccess = data.message.includes('[OK]') || data.message.includes('Success')
|
||||||
entry.textContent = data.message
|
addLog(data.message, isAlert ? 'alert' : isSuccess ? 'success' : '')
|
||||||
logs.appendChild(entry)
|
|
||||||
logs.scrollTop = logs.scrollHeight
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.type === 'status') {
|
if (data.type === 'status') {
|
||||||
balance.textContent = data.balance.toLocaleString()
|
// Update balance
|
||||||
if (data.ndebitUri) {
|
document.getElementById('balance').textContent = data.balance.toLocaleString()
|
||||||
ndebitCode.textContent = data.ndebitUri
|
|
||||||
|
// 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) {
|
if (data.ndebitQR) {
|
||||||
qrImg.src = data.ndebitQR
|
document.getElementById('qr').src = data.ndebitQR
|
||||||
}
|
}
|
||||||
if (data.withdrawAmount) {
|
if (data.ndebitUri) {
|
||||||
amountInput.value = data.withdrawAmount
|
document.getElementById('ndebit-code').textContent = data.ndebitUri
|
||||||
displayAmount.textContent = data.withdrawAmount.toLocaleString()
|
}
|
||||||
|
|
||||||
|
// 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') {
|
if (data.type === 'debit_request') {
|
||||||
const entry = document.createElement('div')
|
addLog('[DEBIT] Request received from wallet', 'alert')
|
||||||
entry.className = 'log-entry'
|
|
||||||
entry.style.color = '#00d4ff'
|
|
||||||
entry.textContent = '[ALERT] DEBIT REQUEST: ' + JSON.stringify(data.debit)
|
|
||||||
logs.appendChild(entry)
|
|
||||||
logs.scrollTop = logs.scrollHeight
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
const entry = document.createElement('div')
|
addLog('Connected to ATM server')
|
||||||
entry.textContent = 'Connected to ATM server'
|
}
|
||||||
logs.appendChild(entry)
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
addLog('Connection error - is the server running?')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
@ -599,8 +799,10 @@ wss.on('connection', (ws) => {
|
||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(data)
|
const msg = JSON.parse(data)
|
||||||
if (msg.type === 'set_amount' && typeof msg.amount === 'number') {
|
if (msg.type === 'insert_cash' && typeof msg.amount === 'number') {
|
||||||
setWithdrawAmount(msg.amount)
|
insertCash(msg.amount)
|
||||||
|
} else if (msg.type === 'reset') {
|
||||||
|
resetAtm()
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
})
|
})
|
||||||
|
|
@ -613,8 +815,11 @@ wss.on('connection', (ws) => {
|
||||||
balance: atmBalance,
|
balance: atmBalance,
|
||||||
ndebit,
|
ndebit,
|
||||||
ndebitQR,
|
ndebitQR,
|
||||||
ndebitUri: formatNdebitUri(ndebit, withdrawAmount),
|
ndebitUri: withdrawAmount > 0 ? formatNdebitUri(ndebit, withdrawAmount) : null,
|
||||||
withdrawAmount,
|
withdrawAmount,
|
||||||
|
cashInserted,
|
||||||
|
atmState,
|
||||||
|
satsPerUsd: SATS_PER_USD,
|
||||||
isLinked,
|
isLinked,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
181
lamassu-next/packages/nostr-client/test-ndebit.mjs
Normal file
181
lamassu-next/packages/nostr-client/test-ndebit.mjs
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue