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:
Patrick Mulligan 2026-02-16 17:48:49 -05:00
commit 5448a620a9
10 changed files with 3 additions and 3 deletions

View file

@ -0,0 +1,270 @@
/**
* ATM Debit Authorization Agent
*
* This script demonstrates how an ATM can act as the authorization authority
* for CLINK debit requests, similar to an "admin macaroon" for Lightning.
*
* Flow:
* 1. Generate keypair for ATM (or load from secure storage)
* 2. Link keypair to Lightning.Pub user account
* 3. Subscribe to live debit requests
* 4. Auto-approve requests (within configured limits)
*
* Prerequisites:
* - Get a linking token from Lightning.Pub HTTP API
* - Run: curl -X POST "http://localhost:1776/api/app/user/npub/token/reset" \
* -H "Authorization: Bearer $APP_TOKEN" \
* -H "Content-Type: application/json" \
* -d '{"user_identifier": "YOUR_USER_IDENTIFIER"}'
*/
import { Relay } from 'nostr-tools/relay'
import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools'
import * as nip44v1 from './nip44v1.mjs'
// Configuration
const LINKING_TOKEN = process.argv[2]
const LIGHTNING_PUB_PUBKEY = '6c59284e3da31b776cb1c06324c25f4a0b0308177af9f8aec5ebef07b44c3fdf'
const RELAY_URL = process.env.RELAY_URL || 'ws://localhost:7777'
// Generate ATM keypair (in production, this would be stored securely)
const ATM_PRIVATE_KEY = generateSecretKey()
const ATM_PUBLIC_KEY = getPublicKey(ATM_PRIVATE_KEY)
const ATM_PRIVATE_KEY_HEX = Buffer.from(ATM_PRIVATE_KEY).toString('hex')
if (!LINKING_TOKEN) {
console.log('ATM Debit Authorization Agent')
console.log('==============================')
console.log('')
console.log('Usage: node atm-debit-agent.mjs <linking-token>')
console.log('')
console.log('Get a linking token:')
console.log(' curl -X POST "http://localhost:1776/api/app/user/npub/token/reset" \\')
console.log(' -H "Authorization: Bearer $APP_TOKEN" \\')
console.log(' -H "Content-Type: application/json" \\')
console.log(' -d \'{"user_identifier": "YOUR_USER_IDENTIFIER"}\'')
process.exit(1)
}
console.log('=== ATM Debit Authorization Agent ===')
console.log('')
console.log('ATM Pubkey:', ATM_PUBLIC_KEY)
console.log('Lightning.Pub Pubkey:', LIGHTNING_PUB_PUBKEY)
console.log('Linking Token:', LINKING_TOKEN.substring(0, 16) + '...')
console.log('')
async function main() {
// Connect to relay
console.log('Connecting to relay...')
const relay = await Relay.connect(RELAY_URL)
console.log('Connected!')
console.log('')
// Create conversation key for NIP-44 v1 encryption (used by Kind 21000 RPC)
const conversationKey = nip44v1.getConversationKey(ATM_PRIVATE_KEY_HEX, LIGHTNING_PUB_PUBKEY)
// Step 1: Link NPub through token
console.log('Step 1: Linking ATM keypair to user account...')
const linkRequest = {
rpcName: 'LinkNPubThroughToken',
authIdentifier: ATM_PUBLIC_KEY,
body: {
token: LINKING_TOKEN,
},
}
const linkEvent = finalizeEvent(
{
kind: 21000,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', LIGHTNING_PUB_PUBKEY]],
content: nip44v1.encrypt(JSON.stringify(linkRequest), conversationKey),
},
ATM_PRIVATE_KEY
)
// Subscribe for response
let linkingComplete = false
const linkSub = relay.subscribe(
[
{
kinds: [21000],
authors: [LIGHTNING_PUB_PUBKEY],
'#p': [ATM_PUBLIC_KEY],
since: Math.floor(Date.now() / 1000) - 5,
},
],
{
onevent(evt) {
try {
const decrypted = nip44v1.decrypt(evt.content, conversationKey)
const response = JSON.parse(decrypted)
console.log('Link response:', JSON.stringify(response))
if (response.status === 'OK') {
linkingComplete = true
console.log('Keypair linked successfully!')
}
} catch (err) {
console.log('Failed to decrypt link response:', err.message)
}
},
}
)
await relay.publish(linkEvent)
console.log(
'Link request sent (event id:',
linkEvent.id.substring(0, 16) + '...), waiting for confirmation...'
)
// Wait for linking to complete
for (let i = 0; i < 10 && !linkingComplete; i++) {
await new Promise((r) => setTimeout(r, 1000))
if (i % 3 === 2) console.log('Still waiting for link confirmation...')
}
linkSub.close()
if (!linkingComplete) {
console.log('Warning: Did not receive linking confirmation, continuing anyway...')
}
console.log('')
// Step 2: Subscribe to live debit requests
console.log('Step 2: Subscribing to live debit requests...')
const subscribeRequest = {
rpcName: 'GetLiveDebitRequests',
authIdentifier: ATM_PUBLIC_KEY,
body: {},
}
const subEvent = finalizeEvent(
{
kind: 21000,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', LIGHTNING_PUB_PUBKEY]],
content: nip44v1.encrypt(JSON.stringify(subscribeRequest), conversationKey),
},
ATM_PRIVATE_KEY
)
// Subscribe for debit requests and responses
console.log('Listening for debit requests...')
console.log('(Scan the ndebit QR code with ShockWallet to test)')
console.log('')
const debitSub = relay.subscribe(
[
{
kinds: [21000],
authors: [LIGHTNING_PUB_PUBKEY],
'#p': [ATM_PUBLIC_KEY],
since: Math.floor(Date.now() / 1000) - 5,
},
],
{
async onevent(evt) {
try {
const decrypted = nip44v1.decrypt(evt.content, conversationKey)
const message = JSON.parse(decrypted)
// Check if this is a debit request (has request_id and debit fields)
if (message.requestId === 'GetLiveDebitRequests' && message.debit) {
console.log('')
console.log('========================================')
console.log('Received debit request!')
console.log(' Request ID:', message.request_id)
console.log(' From npub:', message.npub)
console.log(' Debit type:', message.debit.type)
if (message.debit.type === 'invoice' && message.debit.invoice) {
console.log(' Invoice:', message.debit.invoice.substring(0, 50) + '...')
// Auto-approve by responding with INVOICE type
console.log('')
console.log('Auto-approving debit request...')
const approveRequest = {
rpcName: 'RespondToDebit',
authIdentifier: ATM_PUBLIC_KEY,
body: {
npub: message.npub,
request_id: message.request_id,
response: {
type: 'invoice',
invoice: message.debit.invoice,
},
},
}
const approveEvent = finalizeEvent(
{
kind: 21000,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', LIGHTNING_PUB_PUBKEY]],
content: nip44v1.encrypt(JSON.stringify(approveRequest), conversationKey),
},
ATM_PRIVATE_KEY
)
await relay.publish(approveEvent)
console.log('Approval sent! (event id:', approveEvent.id.substring(0, 16) + '...)')
} else if (message.debit.type === 'budget') {
console.log(' Budget request - ignoring for now')
} else if (message.debit.type === 'fullAccess') {
console.log(' Full access request - ignoring for now')
}
console.log('========================================')
console.log('')
} else if (message.rpcName) {
// This is a response to our RPC request
console.log('RPC response:', message.rpcName, ':', message.status || 'received')
} else {
// Log other messages for debugging
console.log('Message received:', JSON.stringify(message).substring(0, 100))
}
} catch (err) {
// Ignore decryption failures (may be messages for other clients)
if (!err.message.includes('Unsupported')) {
console.log('Error processing message:', err.message)
}
}
},
}
)
await relay.publish(subEvent)
console.log('Subscription request sent (event id:', subEvent.id.substring(0, 16) + '...)')
console.log('')
// Keep running
console.log('ATM Debit Agent running. Press Ctrl+C to exit.')
console.log('')
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('\nShutting down...')
debitSub.close()
relay.close()
process.exit(0)
})
// Keep alive with heartbeat
let heartbeatCount = 0
while (true) {
await new Promise((r) => setTimeout(r, 10000))
heartbeatCount++
if (heartbeatCount % 6 === 0) {
// Every minute
console.log('Still listening... (' + heartbeatCount * 10 + 's)')
}
}
}
main().catch((err) => {
console.error('Error:', err.message)
console.error(err.stack)
process.exit(1)
})

View file

@ -0,0 +1,133 @@
#!/usr/bin/env node
/**
* Fund the ATM's Lightning.Pub account via Nostr RPC
*
* This creates an invoice for the ATM user (authenticated via Nostr),
* so the funds go directly to the user that will be queried for balance.
*
* Usage:
* VITE_ATM_PRIVATE_KEY=xxx VITE_LIGHTNING_PUB_PUBKEY=yyy node fund-dev.mjs [amount]
*/
import { NostrClient, loadIdentityFromHex, encryptContent, decryptJSON } from '../dist/index.js'
import { finalizeEvent } from 'nostr-tools'
import { randomUUID } from 'crypto'
const ATM_PRIVATE_KEY = process.env.VITE_ATM_PRIVATE_KEY
const LIGHTNING_PUB_PUBKEY = process.env.VITE_LIGHTNING_PUB_PUBKEY
const RELAY_URL = process.env.VITE_RELAY_URL || 'ws://localhost:7777'
const APP_ID = process.env.VITE_APP_ID || ''
const FUND_AMOUNT = parseInt(process.argv[2] || process.env.FUND_AMOUNT || '100000', 10)
if (!ATM_PRIVATE_KEY) {
console.error('Error: VITE_ATM_PRIVATE_KEY environment variable required')
process.exit(1)
}
if (!LIGHTNING_PUB_PUBKEY) {
console.error('Error: VITE_LIGHTNING_PUB_PUBKEY environment variable required')
process.exit(1)
}
if (!APP_ID) {
console.error('Error: VITE_APP_ID environment variable required')
console.error('This ensures funds go to the same user as LNURL-withdraw uses')
process.exit(1)
}
async function main() {
const identity = loadIdentityFromHex(ATM_PRIVATE_KEY)
console.error('[fund-dev] ATM pubkey:', identity.publicKey)
console.error('[fund-dev] Lightning.Pub pubkey:', LIGHTNING_PUB_PUBKEY)
console.error('[fund-dev] Creating invoice for', FUND_AMOUNT, 'sats')
const client = new NostrClient({
relays: [{ url: RELAY_URL }],
identity,
})
await client.connect()
console.error('[fund-dev] Connected to relay:', RELAY_URL)
const requestId = randomUUID()
console.error('[fund-dev] App ID:', APP_ID)
// Correct RPC structure per Lightning.Pub documentation
// appId ensures the Nostr user is created under the same app as HTTP API users
const rpcRequest = {
rpcName: 'NewInvoice',
params: {},
query: {},
body: {
amountSats: FUND_AMOUNT,
memo: `ATM funding (${FUND_AMOUNT} sats)`,
},
authIdentifier: identity.publicKey,
requestId,
appId: APP_ID,
}
const encryptedContent = encryptContent(identity, LIGHTNING_PUB_PUBKEY, rpcRequest)
const event = finalizeEvent(
{
kind: 21000,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', LIGHTNING_PUB_PUBKEY]],
content: encryptedContent,
},
identity.privateKey
)
// Subscribe to responses before publishing
let invoice = null
const subId = client.subscribe(
[
{
kinds: [21000],
authors: [LIGHTNING_PUB_PUBKEY],
'#p': [identity.publicKey],
since: Math.floor(Date.now() / 1000) - 5,
},
],
{
onEvent: (evt) => {
try {
const response = decryptJSON(identity, LIGHTNING_PUB_PUBKEY, evt.content)
if (response.requestId === requestId && response.invoice) {
invoice = response.invoice
console.error('[fund-dev] Got invoice!')
}
} catch (err) {
// Ignore decrypt errors for other messages
}
},
}
)
await client.publish(event)
console.error('[fund-dev] Request published, waiting for invoice...')
// Wait up to 15 seconds for response
for (let i = 0; i < 30; i++) {
await new Promise((resolve) => setTimeout(resolve, 500))
if (invoice) break
}
client.unsubscribe(subId)
client.disconnect()
if (!invoice) {
console.error('[fund-dev] Error: No invoice received within timeout')
process.exit(1)
}
// Output just the invoice to stdout (for piping to payment command)
console.log(invoice)
process.exit(0)
}
main().catch((err) => {
console.error('[fund-dev] Error:', err.message)
process.exit(1)
})

View file

@ -0,0 +1,84 @@
/**
* Generate an ndebit string for ShockWallet to scan
*
* Usage: node generate-ndebit.mjs [pointer]
*
* The ndebit allows ShockWallet to send an invoice that Lightning.Pub will pay.
* This is the LNURL-withdraw equivalent for CLINK.
*/
import { bech32 } from '@scure/base'
const LIGHTNING_PUB_PUBKEY =
process.env.LIGHTNING_PUB_PUBKEY ||
'4a72e400a254bf74a70cc711ab97e461b8d4fd9738b1ac3b5194cdeb3192ab91'
const RELAY_URL = process.env.NOSTR_RELAY_URL || 'wss://strfry.shock.network'
const POINTER = process.argv[2] || 'atm-cashin-' + Date.now()
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
}
return bytes
}
function encodeTLV(tlv) {
const entries = []
Object.entries(tlv)
.reverse()
.forEach(([t, vs]) => {
vs.forEach((v) => {
const entry = new Uint8Array(v.length + 2)
entry.set([parseInt(t)], 0)
entry.set([v.length], 1)
entry.set(v, 2)
entries.push(entry)
})
})
// Concatenate all entries
const totalLength = entries.reduce((sum, e) => sum + e.length, 0)
const result = new Uint8Array(totalLength)
let offset = 0
for (const entry of entries) {
result.set(entry, offset)
offset += entry.length
}
return result
}
function ndebitEncode(debit) {
const encoder = new TextEncoder()
const tlv = {
0: [hexToBytes(debit.pubkey)],
1: [encoder.encode(debit.relay)],
}
if (debit.pointer) {
tlv[2] = [encoder.encode(debit.pointer)]
}
const data = encodeTLV(tlv)
const words = bech32.toWords(data)
return bech32.encode('ndebit', words, 5000)
}
// Generate ndebit
const ndebit = ndebitEncode({
pubkey: LIGHTNING_PUB_PUBKEY,
relay: RELAY_URL,
pointer: POINTER,
})
console.log('=== NDEBIT for ShockWallet ===')
console.log('')
console.log('Pubkey:', LIGHTNING_PUB_PUBKEY)
console.log('Relay:', RELAY_URL)
console.log('Pointer:', POINTER)
console.log('')
console.log('ndebit string:')
console.log(ndebit)
console.log('')
console.log('ShockWallet should scan this QR code to receive sats from the ATM.')

View 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()
})

View file

@ -0,0 +1,111 @@
/**
* NIP-44 v1 Implementation
*
* This is the XChaCha20-based encryption used by Lightning.Pub for Kind 21000 RPC events.
* It differs from standard NIP-44 v2 (used in nostr-tools) which uses ChaCha20-Poly1305.
*/
import { base64 } from '@scure/base'
import { randomBytes } from '@noble/hashes/utils.js'
import { streamXOR as xchacha20 } from '@stablelib/xchacha20'
import { secp256k1 } from '@noble/curves/secp256k1.js'
import { sha256 } from '@noble/hashes/sha2.js'
const XCHACHA20_VERSION = 1
/**
* Convert hex string to Uint8Array
* @param {string} hex - Hex string
* @returns {Uint8Array}
*/
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
}
return bytes
}
/**
* Get shared secret for NIP-44 v1 encryption
* @param {Uint8Array|string} privateKey - Private key (32 bytes or hex string)
* @param {string} publicKey - Public key (32 bytes hex string, no prefix)
* @returns {Uint8Array} - 32-byte shared secret
*/
export function getConversationKey(privateKey, publicKey) {
// Convert private key to Uint8Array if it's a hex string
const privKeyBytes = typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey
// Convert public key (with 02 prefix) to Uint8Array
const pubKeyBytes = hexToBytes('02' + publicKey)
// Get ECDH shared point
const sharedPoint = secp256k1.getSharedSecret(privKeyBytes, pubKeyBytes)
// Hash the x-coordinate of the shared point
return sha256(sharedPoint.slice(1, 33))
}
/**
* Encrypt content using NIP-44 v1 (XChaCha20)
* @param {string} content - Plaintext content to encrypt
* @param {Uint8Array} conversationKey - 32-byte conversation key from getConversationKey
* @returns {string} - Base64-encoded encrypted payload
*/
export function encrypt(content, conversationKey) {
const nonce = randomBytes(24)
const plaintext = new TextEncoder().encode(content)
// XChaCha20 stream cipher - encrypts in place
const ciphertext = new Uint8Array(plaintext.length)
xchacha20(conversationKey, nonce, plaintext, ciphertext)
// Encode: version byte + nonce + ciphertext
return base64.encode(new Uint8Array([XCHACHA20_VERSION, ...nonce, ...ciphertext]))
}
/**
* Decrypt content using NIP-44 v1 (XChaCha20)
* @param {string} content - Base64-encoded encrypted payload
* @param {Uint8Array} conversationKey - 32-byte conversation key from getConversationKey
* @returns {string} - Decrypted plaintext
*/
export function decrypt(content, conversationKey) {
const payload = decodePayload(content)
// XChaCha20 stream cipher - decrypts in place
const plaintext = new Uint8Array(payload.ciphertext.length)
xchacha20(conversationKey, payload.nonce, payload.ciphertext, plaintext)
return new TextDecoder().decode(plaintext)
}
/**
* Decode encrypted payload (supports both formats)
* @param {string} content - Base64-encoded or JSON-encoded payload
* @returns {{nonce: Uint8Array, ciphertext: Uint8Array}}
*/
function decodePayload(content) {
// Check for JSON format
if (content.startsWith('{') && content.endsWith('}')) {
const parsed = JSON.parse(content)
if (parsed.v !== XCHACHA20_VERSION) {
throw new Error(`Unsupported encryption version: ${parsed.v}`)
}
return {
nonce: base64.decode(parsed.nonce),
ciphertext: base64.decode(parsed.ciphertext),
}
}
// Binary format: version byte + nonce (24 bytes) + ciphertext
const buf = base64.decode(content)
if (buf[0] !== XCHACHA20_VERSION) {
throw new Error(`Unsupported encryption version: ${buf[0]}`)
}
return {
nonce: buf.subarray(1, 25),
ciphertext: buf.subarray(25),
}
}

View file

@ -0,0 +1,221 @@
#!/usr/bin/env node
/**
* ATM Debit Approval Agent (TESTING ONLY)
*
* ⚠️ WARNING: This script auto-approves ALL debit requests without validation!
* ⚠️ DO NOT use in production - use the integrated debit approval service instead.
*
* Purpose:
* - Standalone debugging tool for testing the GetLiveDebitRequests subscription
* - Helps diagnose relay connectivity and message decryption issues
* - Useful when the integrated service isn't receiving events
*
* Usage:
* # Set environment variables (or create .env file in apps/machine/)
* export ATM_PRIVATE_KEY=<hex-private-key>
* export LIGHTNING_PUB_PUBKEY=<hex-pubkey>
* export RELAY_URL=ws://localhost:7777
*
* # Run the script
* node run-debit-agent.mjs
*
* Production alternative:
* The ATM app (apps/machine) includes an integrated debit approval service
* with session-based single-use protection. See:
* - apps/machine/src/services/lightning.ts (startDebitApprovalService)
* - docs/ndebit-cash-in-flow.md
*/
import { Relay } from 'nostr-tools/relay'
import { finalizeEvent, getPublicKey } from 'nostr-tools'
import * as nip44v1 from './nip44v1.mjs'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Load .env file from apps/machine if it exists
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const envPath = path.join(__dirname, '../../apps/machine/.env')
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) {
// Map VITE_ prefixed vars to non-prefixed
const envKey = key.replace(/^VITE_/, '')
process.env[envKey] = valueParts.join('=')
}
}
}
console.log('[Config] Loaded .env from:', envPath)
}
// Configuration from environment
const ATM_PRIVATE_KEY_HEX = process.env.ATM_PRIVATE_KEY
const LIGHTNING_PUB_PUBKEY = process.env.LIGHTNING_PUB_PUBKEY
const RELAY_URL = process.env.RELAY_URL || 'ws://localhost:7777'
// Validate required config
if (!ATM_PRIVATE_KEY_HEX) {
console.error('ERROR: ATM_PRIVATE_KEY environment variable is required')
console.error('Set it directly or create apps/machine/.env with VITE_ATM_PRIVATE_KEY')
process.exit(1)
}
if (!LIGHTNING_PUB_PUBKEY) {
console.error('ERROR: LIGHTNING_PUB_PUBKEY environment variable is required')
console.error('Set it directly or create apps/machine/.env with VITE_LIGHTNING_PUB_PUBKEY')
process.exit(1)
}
const ATM_PRIVATE_KEY = Uint8Array.from(Buffer.from(ATM_PRIVATE_KEY_HEX, 'hex'))
const ATM_PUBLIC_KEY = getPublicKey(ATM_PRIVATE_KEY)
console.log('')
console.log('='.repeat(60))
console.log(' ATM Debit Approval Agent (TESTING ONLY)')
console.log('='.repeat(60))
console.log('')
console.log('⚠️ WARNING: Auto-approves ALL requests without validation!')
console.log('⚠️ For production, use the integrated service in apps/machine')
console.log('')
console.log('ATM Pubkey:', ATM_PUBLIC_KEY)
console.log('Lightning.Pub Pubkey:', LIGHTNING_PUB_PUBKEY)
console.log('Relay URL:', RELAY_URL)
console.log('')
async function main() {
// Connect to relay
console.log('Connecting to relay...')
const relay = await Relay.connect(RELAY_URL)
console.log('Connected!')
console.log('')
// Create conversation key for NIP-44 v1 encryption
const conversationKey = nip44v1.getConversationKey(ATM_PRIVATE_KEY_HEX, LIGHTNING_PUB_PUBKEY)
// Subscribe to GetLiveDebitRequests
console.log('Subscribing to live debit requests...')
const subscribeRequest = {
rpcName: 'GetLiveDebitRequests',
authIdentifier: ATM_PUBLIC_KEY,
body: {},
}
const subEvent = finalizeEvent(
{
kind: 21000,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', LIGHTNING_PUB_PUBKEY]],
content: nip44v1.encrypt(JSON.stringify(subscribeRequest), conversationKey),
},
ATM_PRIVATE_KEY
)
// Listen for debit requests
console.log('Listening for debit requests...')
console.log('(Test by scanning ndebit QR with ShockWallet)')
console.log('')
const debitSub = relay.subscribe(
[
{
kinds: [21000],
authors: [LIGHTNING_PUB_PUBKEY],
'#p': [ATM_PUBLIC_KEY],
since: Math.floor(Date.now() / 1000) - 5,
},
],
{
async onevent(evt) {
try {
const decrypted = nip44v1.decrypt(evt.content, conversationKey)
const message = JSON.parse(decrypted)
// Check if this is a live debit request
if (message.requestId === 'GetLiveDebitRequests' && message.debit) {
console.log('')
console.log('========================================')
console.log('DEBIT REQUEST RECEIVED!')
console.log(' Request ID:', message.request_id)
console.log(' From npub:', message.npub?.substring(0, 16) + '...')
console.log(' Debit type:', message.debit.type)
if (message.debit.invoice) {
console.log(' Invoice:', message.debit.invoice.substring(0, 50) + '...')
// Auto-approve by responding with INVOICE type
console.log('')
console.log('⚠️ AUTO-APPROVING debit request (NO VALIDATION)...')
const approveRequest = {
rpcName: 'RespondToDebit',
authIdentifier: ATM_PUBLIC_KEY,
body: {
npub: message.npub,
request_id: message.request_id,
response: {
type: 'invoice',
invoice: message.debit.invoice,
},
},
}
const approveEvent = finalizeEvent(
{
kind: 21000,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', LIGHTNING_PUB_PUBKEY]],
content: nip44v1.encrypt(JSON.stringify(approveRequest), conversationKey),
},
ATM_PRIVATE_KEY
)
await relay.publish(approveEvent)
console.log('APPROVED! (event id:', approveEvent.id.substring(0, 16) + '...)')
}
console.log('========================================')
console.log('')
} else if (message.rpcName) {
console.log('RPC response:', message.rpcName, ':', message.status || 'received')
} else if (message.requestId) {
console.log('Live subscription active:', message.status)
}
} catch (err) {
// Ignore decryption failures for events not meant for us
if (!err.message?.includes('Unsupported')) {
// Uncomment for debugging:
// console.log('Decryption error:', err.message)
}
}
},
}
)
await relay.publish(subEvent)
console.log('Subscription sent, waiting for debit requests...')
console.log('')
// Keep running
process.on('SIGINT', () => {
console.log('\nShutting down...')
debitSub.close()
relay.close()
process.exit(0)
})
// Heartbeat
while (true) {
await new Promise((r) => setTimeout(r, 30000))
console.log('Still listening...')
}
}
main().catch((err) => {
console.error('Error:', err.message)
process.exit(1)
})

View file

@ -0,0 +1,182 @@
/**
* Test CLINK Debit flow
*
* This simulates what ShockWallet does when scanning an ndebit:
* 1. Decode the ndebit to get pubkey, relay, pointer
* 2. Create a bolt11 invoice (we'll get one from Alice)
* 3. Send Kind 21002 debit request to Lightning.Pub
* 4. Wait for payment response
*/
import { Relay } from 'nostr-tools/relay'
import { finalizeEvent, getPublicKey } from 'nostr-tools'
import { nip44 } from 'nostr-tools'
import { bech32 } from '@scure/base'
import { randomBytes } from 'crypto'
// Generate a random keypair for this test (simulates ShockWallet)
const WALLET_PRIVATE_KEY = randomBytes(32)
const WALLET_PUBLIC_KEY = getPublicKey(WALLET_PRIVATE_KEY)
// The ndebit to test
const NDEBIT = process.argv[2]
// The bolt11 invoice to be paid
const BOLT11 = process.argv[3]
if (!NDEBIT || !BOLT11) {
console.log('Usage: node test-debit.mjs <ndebit> <bolt11>')
console.log('')
console.log('Example:')
console.log(' # First create an invoice on Alice:')
console.log(' docker exec lamassu-lnd-alice lncli --network=regtest addinvoice --amt 1000')
console.log('')
console.log(' # Then test the debit:')
console.log(' node test-debit.mjs ndebit1... lnbcrt...')
process.exit(1)
}
function decodeNdebit(ndebit) {
const { prefix, words } = bech32.decode(ndebit, 5000)
if (prefix !== 'ndebit') throw new Error('Invalid ndebit prefix')
const data = new Uint8Array(bech32.fromWords(words))
let pubkey, relay, pointer
let offset = 0
while (offset < data.length) {
const type = data[offset]
const length = data[offset + 1]
const value = data.slice(offset + 2, offset + 2 + length)
switch (type) {
case 0:
pubkey = Buffer.from(value).toString('hex')
break
case 1:
relay = new TextDecoder().decode(value)
break
case 2:
pointer = new TextDecoder().decode(value)
break
}
offset += 2 + length
}
return { pubkey, relay, pointer }
}
async function main() {
console.log('=== CLINK Debit Test ===')
console.log('')
console.log('Test wallet pubkey:', WALLET_PUBLIC_KEY)
console.log('')
// Decode ndebit
const debit = decodeNdebit(NDEBIT)
console.log('Decoded ndebit:')
console.log(' Pubkey:', debit.pubkey)
console.log(' Relay:', debit.relay)
console.log(' Pointer:', debit.pointer || '(none)')
console.log('')
// Connect to relay (override Docker internal hostnames with localhost for local testing)
const relayUrl = debit.relay
.replace('host.docker.internal', 'localhost')
.replace('ws://strfry:', 'ws://localhost:')
console.log('Connecting to relay:', relayUrl)
const relay = await Relay.connect(relayUrl)
console.log('Connected!')
console.log('')
// Build debit request payload
const requestPayload = {
pointer: debit.pointer,
bolt11: BOLT11,
}
console.log('Request payload:', JSON.stringify(requestPayload, null, 2))
console.log('')
// Encrypt with NIP-44
const conversationKey = nip44.getConversationKey(WALLET_PRIVATE_KEY, debit.pubkey)
const encryptedContent = nip44.encrypt(JSON.stringify(requestPayload), conversationKey)
// Create Kind 21002 event
const event = finalizeEvent(
{
kind: 21002,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', debit.pubkey],
['clink_version', '1'],
],
content: encryptedContent,
},
WALLET_PRIVATE_KEY
)
console.log('Publishing debit request (event id:', event.id.substring(0, 16) + '...)...')
// Subscribe to responses
let responseReceived = false
const sub = relay.subscribe(
[
{
kinds: [21002],
authors: [debit.pubkey],
'#p': [WALLET_PUBLIC_KEY],
'#e': [event.id],
since: Math.floor(Date.now() / 1000) - 5,
},
],
{
onevent(evt) {
console.log('')
console.log('Got response event:', evt.id.substring(0, 16) + '...')
try {
const decrypted = nip44.decrypt(evt.content, conversationKey)
const response = JSON.parse(decrypted)
console.log('Response:', JSON.stringify(response, null, 2))
if (response.res === 'ok') {
console.log('')
console.log('✅ DEBIT SUCCESS!')
if (response.preimage) {
console.log('Preimage:', response.preimage)
}
} else if (response.res === 'GFY') {
console.log('')
console.log('❌ DEBIT FAILED:', response.error)
}
responseReceived = true
} catch (err) {
console.log('Failed to decrypt:', err.message)
}
},
}
)
// Publish request
await relay.publish(event)
console.log('Request published, waiting for response...')
// Wait for response
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 1000))
if (responseReceived) break
if (i % 5 === 4) console.log('Still waiting... (' + (i + 1) + 's)')
}
if (!responseReceived) {
console.log('')
console.log('❌ No response received within timeout')
}
sub.close()
relay.close()
}
main().catch(console.error)

View 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)
})

View file

@ -0,0 +1,116 @@
import { NostrClient, loadIdentityFromHex, encryptContent, decryptJSON } from '../dist/index.js'
import { Relay } from 'nostr-tools/relay'
import { finalizeEvent } from 'nostr-tools'
import { randomUUID } from 'crypto'
const DEV_PRIVATE_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
const LIGHTNING_PUB_PUBKEY =
process.env.LIGHTNING_PUB_PUBKEY ||
'4a72e400a254bf74a70cc711ab97e461b8d4fd9738b1ac3b5194cdeb3192ab91'
const RELAY_URL = process.env.NOSTR_RELAY_URL || 'ws://localhost:7777'
// Get a fresh invoice from Alice first:
// docker exec lamassu-lnd-alice lncli --network=regtest addinvoice --amt 1000
const TEST_INVOICE = process.argv[2]
if (!TEST_INVOICE) {
console.log('Usage: node test-pay.mjs <invoice>')
console.log(
'Generate invoice: docker exec lamassu-lnd-alice lncli --network=regtest addinvoice --amt 1000'
)
process.exit(1)
}
async function main() {
const identity = loadIdentityFromHex(DEV_PRIVATE_KEY)
console.log('Using identity:', identity.publicKey)
console.log('Connecting to relay...')
const relay = await Relay.connect(RELAY_URL)
console.log('Connected!')
const requestId = randomUUID()
// Check if invoice has amount (look for pattern before '1' separator)
const amountMatch = TEST_INVOICE.toLowerCase().match(/ln(?:bc|tb|bcrt)(\d+)?([munp])?1/)
const hasAmount = amountMatch && amountMatch[1]
console.log('Invoice amount match:', amountMatch ? amountMatch.slice(0, 3) : null)
console.log('Has embedded amount:', hasAmount)
// Build body - amount is always required (use 0 for invoices with embedded amounts)
const body = {
invoice: TEST_INVOICE,
amount: hasAmount ? 0 : 2000, // 0 means "use invoice amount"
}
console.log('Body amount:', body.amount, hasAmount ? '(use invoice amount)' : '(explicit amount)')
const rpcRequest = {
rpcName: 'PayInvoice',
params: {},
query: {},
body,
authIdentifier: identity.publicKey,
requestId,
}
console.log('\nRequest structure:', JSON.stringify(rpcRequest, null, 2))
const encryptedContent = encryptContent(identity, LIGHTNING_PUB_PUBKEY, rpcRequest)
const event = finalizeEvent(
{
kind: 21000,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', LIGHTNING_PUB_PUBKEY]],
content: encryptedContent,
},
identity.privateKey
)
console.log('\nPublishing PayInvoice request (event id:', event.id.substring(0, 16) + '...)...')
// Subscribe to responses
const filter = {
kinds: [21000],
authors: [LIGHTNING_PUB_PUBKEY],
since: Math.floor(Date.now() / 1000) - 5,
}
let responseReceived = false
const sub = relay.subscribe([filter], {
onevent(evt) {
// Check if for us
const pTags = evt.tags.filter((t) => t[0] === 'p')
if (!pTags.some((t) => t[1] === identity.publicKey)) return
console.log('\nGot response event:', evt.id.substring(0, 16) + '...')
try {
const response = decryptJSON(identity, LIGHTNING_PUB_PUBKEY, evt.content)
console.log('Response:', JSON.stringify(response, null, 2))
if (response.requestId === requestId) {
responseReceived = true
}
} catch (err) {
console.log('Failed to decrypt:', err.message)
}
},
})
await relay.publish(event)
console.log('Request published, waiting for response...')
// Wait for response
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500))
if (responseReceived) break
}
if (!responseReceived) {
console.log('\nNo response received for our requestId within timeout')
}
sub.close()
relay.close()
}
main().catch(console.error)