bitspire/scripts/test-withdraw-rpc.mjs
Patrick Mulligan 9e6ee4813c test: add LNURL-withdraw Nostr RPC test scripts
- test-withdraw-rpc.mjs: basic withdraw.createLink via kind 21000
- test-full-withdraw.mjs: end-to-end create + LNURL redeem
- test-update-delete.mjs: update/delete lifecycle tests (TEST 1 + TEST 2)

TEST 1 payment blocked by app balance (see script header comment).
TEST 2 (delete + reject) fully passes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 13:02:48 -05:00

201 lines
6.6 KiB
JavaScript

/**
* Test: Nostr RPC path for withdraw.createLink
*
* This script:
* 1. Generates a fresh Nostr keypair (simulating an ATM client)
* 2. Connects to the strfry relay via WebSocket
* 3. Sends an encrypted kind 21000 event with rpcName: "withdraw.createLink"
* 4. Subscribes for the encrypted response
* 5. Decrypts and displays the result
*
* Uses NIP-44v1 encryption (XChaCha20) matching LP's kind 21000 handling.
*/
import WebSocket from 'ws'
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
import { secp256k1 } from '@noble/curves/secp256k1.js'
import { sha256 } from '@noble/hashes/sha2.js'
import { streamXOR as xchacha20 } from '@stablelib/xchacha20'
import { base64 } from '@scure/base'
import crypto from 'crypto'
// --- Config ---
const RELAY_URL = 'ws://localhost:7777'
const LP_APP_PUBKEY = '56aa3cce99c384df79c7ca5c89fabeffb192dd86dd0914e38512d12d9b14ff1f'
const LP_APP_ID = 'd1a5173da4a69e178439bb57f74149f17ef2adaa24a838bf3293b6d7d815940f'
const TIMEOUT_MS = 15000
// --- NIP-44v1 Encryption (matching LP's nip44v1.ts) ---
function getSharedSecret(privateKeyHex, publicKeyHex) {
const shared = secp256k1.getSharedSecret(
hexToBytes(privateKeyHex),
hexToBytes('02' + publicKeyHex)
)
return sha256(shared.slice(1, 33))
}
function encrypt(content, sharedSecret) {
const nonce = crypto.randomBytes(24)
const plaintext = new TextEncoder().encode(content)
const ciphertext = new Uint8Array(plaintext.length)
xchacha20(sharedSecret, nonce, plaintext, ciphertext)
// Encode as: [version_byte(1)][nonce(24)][ciphertext(n)] then base64
const payload = new Uint8Array([1, ...nonce, ...ciphertext])
return base64.encode(payload)
}
function decrypt(content, sharedSecret) {
let nonce, ciphertext
if (content.startsWith('{') && content.endsWith('}')) {
const parsed = JSON.parse(content)
if (parsed.v !== 1) throw new Error('Unsupported encryption version')
nonce = base64.decode(parsed.nonce)
ciphertext = base64.decode(parsed.ciphertext)
} else {
const buf = base64.decode(content)
if (buf[0] !== 1) throw new Error('Unsupported encryption version: ' + buf[0])
nonce = buf.subarray(1, 25)
ciphertext = buf.subarray(25)
}
const plaintext = new Uint8Array(ciphertext.length)
xchacha20(sharedSecret, nonce, ciphertext, plaintext)
return new TextDecoder().decode(plaintext)
}
// --- Main ---
async function main() {
// 1. Generate client keypair
const clientSecretKey = generateSecretKey()
const clientPrivKeyHex = bytesToHex(clientSecretKey)
const clientPubKey = getPublicKey(clientSecretKey)
console.log('Client pubkey:', clientPubKey)
console.log('LP app pubkey:', LP_APP_PUBKEY)
// 2. Derive shared secret
const sharedSecret = getSharedSecret(clientPrivKeyHex, LP_APP_PUBKEY)
console.log('Shared secret derived ✓')
// 3. Build the RPC request
const requestId = crypto.randomUUID()
const rpcPayload = {
rpcName: 'withdraw.createLink',
requestId,
authIdentifier: clientPubKey,
appId: LP_APP_ID,
body: {
title: 'Nostr RPC Test',
min_withdrawable: 1000,
max_withdrawable: 5000,
uses: 1,
wait_time: 0,
},
}
console.log('\nRPC request:', JSON.stringify(rpcPayload, null, 2))
// 4. Encrypt the payload
const encryptedContent = encrypt(JSON.stringify(rpcPayload), sharedSecret)
console.log('Encrypted content length:', encryptedContent.length)
// 5. Build the unsigned Nostr event (kind 21000)
const unsignedEvent = {
kind: 21000,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', LP_APP_PUBKEY]],
content: encryptedContent,
}
// 6. Sign the event
const signedEvent = finalizeEvent(unsignedEvent, clientSecretKey)
console.log('Signed event id:', signedEvent.id)
// 7. Connect to relay and set up subscription + publish
const ws = new WebSocket(RELAY_URL)
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
console.error('\n✗ Timeout waiting for response after', TIMEOUT_MS, 'ms')
ws.close()
reject(new Error('timeout'))
}, TIMEOUT_MS)
ws.on('open', () => {
console.log('\nConnected to relay:', RELAY_URL)
// Subscribe for responses (kind 21000 from LP app, tagged to us)
const subId = 'test-' + crypto.randomBytes(4).toString('hex')
const subFilter = {
kinds: [21000],
authors: [LP_APP_PUBKEY],
'#p': [clientPubKey],
since: Math.floor(Date.now() / 1000) - 5,
}
console.log('Subscribing with filter:', JSON.stringify(subFilter))
ws.send(JSON.stringify(['REQ', subId, subFilter]))
// Publish the event
console.log('Publishing event...')
ws.send(JSON.stringify(['EVENT', signedEvent]))
})
ws.on('message', (data) => {
const msg = JSON.parse(data.toString())
if (msg[0] === 'OK') {
console.log('Event accepted:', msg[1], msg[2] ? '✓' : '✗', msg[3] || '')
} else if (msg[0] === 'EOSE') {
console.log('End of stored events, waiting for live response...')
} else if (msg[0] === 'EVENT') {
const event = msg[2]
console.log('\n--- Received response event ---')
console.log('From:', event.pubkey)
console.log('Kind:', event.kind)
try {
const decrypted = decrypt(event.content, sharedSecret)
const response = JSON.parse(decrypted)
console.log('\nDecrypted response:', JSON.stringify(response, null, 2))
if (response.requestId === requestId) {
if (response.status === 'OK') {
console.log('\n✓ Nostr RPC path works! withdraw.createLink succeeded.')
if (response.lnurl) {
console.log('LNURL:', response.lnurl)
}
} else {
console.log('\n✗ RPC returned error:', response.reason || response.status)
}
} else {
console.log('(Response for different requestId, ignoring)')
return
}
} catch (e) {
console.error('Failed to decrypt/parse response:', e.message)
}
clearTimeout(timeout)
ws.close()
resolve()
} else if (msg[0] === 'NOTICE') {
console.log('Relay notice:', msg[1])
}
})
ws.on('error', (err) => {
console.error('WebSocket error:', err.message)
clearTimeout(timeout)
reject(err)
})
})
}
main()
.then(() => {
console.log('\nDone.')
process.exit(0)
})
.catch((err) => {
console.error('Test failed:', err.message)
process.exit(1)
})