From 9e6ee4813c0aacad6c8e62d4027eae7ff182423b Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Sat, 7 Mar 2026 13:02:48 -0500 Subject: [PATCH] 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 --- scripts/test-full-withdraw.mjs | 212 +++++++++++++++++++++++ scripts/test-update-delete.mjs | 302 +++++++++++++++++++++++++++++++++ scripts/test-withdraw-rpc.mjs | 201 ++++++++++++++++++++++ 3 files changed, 715 insertions(+) create mode 100644 scripts/test-full-withdraw.mjs create mode 100644 scripts/test-update-delete.mjs create mode 100644 scripts/test-withdraw-rpc.mjs diff --git a/scripts/test-full-withdraw.mjs b/scripts/test-full-withdraw.mjs new file mode 100644 index 0000000..5d23034 --- /dev/null +++ b/scripts/test-full-withdraw.mjs @@ -0,0 +1,212 @@ +/** + * Full end-to-end test: Nostr RPC withdraw.createLink + LNURL redeem + * + * Uses the LP default app (the one created at startup with balance from invoice) + * Creates a withdraw link, then redeems it via LNURL protocol + */ + +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 = + process.env.LP_PUBKEY || '56aa3cce99c384df79c7ca5c89fabeffb192dd86dd0914e38512d12d9b14ff1f' +const LP_APP_ID = + process.env.LP_APP_ID || 'd1a5173da4a69e178439bb57f74149f17ef2adaa24a838bf3293b6d7d815940f' +const LN_INVOICE = process.env.LN_INVOICE // pre-generated invoice to redeem with +const TIMEOUT_MS = 15000 + +// --- NIP-44v1 Encryption --- +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) + 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) + nonce = base64.decode(parsed.nonce) + ciphertext = base64.decode(parsed.ciphertext) + } else { + const buf = base64.decode(content) + 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) +} + +// --- Send Nostr RPC and wait for response --- +function sendRpc(ws, clientSecretKey, clientPubKey, sharedSecret, rpcName, body) { + return new Promise((resolve, reject) => { + const requestId = crypto.randomUUID() + const rpcPayload = { + rpcName, + requestId, + authIdentifier: clientPubKey, + appId: LP_APP_ID, + body, + } + + const encryptedContent = encrypt(JSON.stringify(rpcPayload), sharedSecret) + const unsignedEvent = { + kind: 21000, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', LP_APP_PUBKEY]], + content: encryptedContent, + } + const signedEvent = finalizeEvent(unsignedEvent, clientSecretKey) + + const timeout = setTimeout(() => reject(new Error('RPC timeout')), TIMEOUT_MS) + + const handler = (data) => { + const msg = JSON.parse(data.toString()) + if (msg[0] === 'EVENT') { + try { + const decrypted = JSON.parse(decrypt(msg[2].content, sharedSecret)) + if (decrypted.requestId === requestId) { + clearTimeout(timeout) + ws.removeListener('message', handler) + resolve(decrypted) + } + } catch {} + } + } + ws.on('message', handler) + ws.send(JSON.stringify(['EVENT', signedEvent])) + }) +} + +async function main() { + const clientSecretKey = generateSecretKey() + const clientPrivKeyHex = bytesToHex(clientSecretKey) + const clientPubKey = getPublicKey(clientSecretKey) + const sharedSecret = getSharedSecret(clientPrivKeyHex, LP_APP_PUBKEY) + + console.log('Client pubkey:', clientPubKey) + console.log('LP app:', LP_APP_ID) + console.log('') + + // Connect + const ws = new WebSocket(RELAY_URL) + await new Promise((res, rej) => { + ws.on('open', res) + ws.on('error', rej) + }) + console.log('Connected to relay') + + // Subscribe for responses + const subId = 'test-' + crypto.randomBytes(4).toString('hex') + ws.send( + JSON.stringify([ + 'REQ', + subId, + { + kinds: [21000], + authors: [LP_APP_PUBKEY], + '#p': [clientPubKey], + since: Math.floor(Date.now() / 1000) - 5, + }, + ]) + ) + + // Wait for EOSE + await new Promise((res) => { + const h = (data) => { + const msg = JSON.parse(data.toString()) + if (msg[0] === 'EOSE') { + ws.removeListener('message', h) + res() + } + } + ws.on('message', h) + }) + + // Step 1: Create withdraw link via Nostr RPC + console.log('1. Creating withdraw link via Nostr RPC...') + const createResult = await sendRpc( + ws, + clientSecretKey, + clientPubKey, + sharedSecret, + 'withdraw.createLink', + { + title: 'E2E Nostr Test', + min_withdrawable: 1000, + max_withdrawable: 3000, + uses: 1, + wait_time: 0, + } + ) + + if (createResult.status !== 'OK') { + console.error('✗ createLink failed:', createResult.reason || createResult.status) + ws.close() + process.exit(1) + } + console.log(' ✓ Link created:', createResult.link.id) + console.log(' LNURL:', createResult.link.lnurl_url) + + // Step 2: LNURL withdraw step 1 + console.log('\n2. LNURL step 1 (GET withdraw request)...') + const lnurlResp = await fetch(createResult.link.lnurl_url) + const lnurlData = await lnurlResp.json() + console.log(' ✓ tag:', lnurlData.tag) + console.log(' min:', lnurlData.minWithdrawable / 1000, 'sats') + console.log(' max:', lnurlData.maxWithdrawable / 1000, 'sats') + + if (!LN_INVOICE) { + console.log('\n--- LNURL flow verified up to step 1 ---') + console.log('To test step 2 (actual payment), pass LN_INVOICE env var:') + console.log(` LN_INVOICE= node test-full-withdraw.mjs`) + console.log(' k1:', lnurlData.k1) + console.log(' callback:', lnurlData.callback) + ws.close() + return + } + + // Step 3: LNURL withdraw step 2 + console.log('\n3. LNURL step 2 (submit invoice)...') + const cbUrl = `${lnurlData.callback}?k1=${lnurlData.k1}&pr=${LN_INVOICE}` + const cbResp = await fetch(cbUrl) + const cbData = await cbResp.json() + + if (cbData.status === 'OK') { + console.log(' ✓ Payment sent!') + } else { + console.log(' ✗ Payment failed:', cbData.reason) + } + + ws.close() +} + +main() + .then(() => { + console.log('\nDone.') + process.exit(0) + }) + .catch((err) => { + console.error('Error:', err.message) + process.exit(1) + }) diff --git a/scripts/test-update-delete.mjs b/scripts/test-update-delete.mjs new file mode 100644 index 0000000..d35bf4b --- /dev/null +++ b/scripts/test-update-delete.mjs @@ -0,0 +1,302 @@ +/** + * Tests for LNURL-withdraw: update and delete flows + * + * TEST 1: Create → Update → Verify updated params → Pay successfully + * TEST 2: Create → Delete → Attempt pay → Should fail + * + * All link operations use Nostr RPC (kind 21000) to ensure consistent appId. + * + * NOTE: TEST 1 payment currently fails with "not enough balance to decrement" + * because the LP default app (used by Nostr RPC) has no funded balance. + * To fix: the default app needs a JWT so we can call POST /api/app/add/invoice + * over HTTP, then pay that invoice from LND-1. The core LP doesn't expose + * AddAppInvoice over kind 21000 — only extension methods (withdraw.*) are + * registered for Nostr RPC. Once the app has balance, the payment step works. + * + * The update and delete RPCs themselves are fully verified. + */ + +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 EXTENSION_URL = 'http://localhost:1777' +const LP_APP_PUBKEY = '31206d8f3174def0f4d9c8aafae9cd309220c6bdbb70c5d60e2533d5cd5399fc' +const LP_APP_ID = '8c234e9c358beb7989e25d952eb4e644ecdc515e8fed664c0193258596c06067' +const TIMEOUT_MS = 15000 + +// --- NIP-44v1 --- +function getSharedSecret(priv, pub) { + const shared = secp256k1.getSharedSecret(hexToBytes(priv), hexToBytes('02' + pub)) + return sha256(shared.slice(1, 33)) +} +function encrypt(content, ss) { + const nonce = crypto.randomBytes(24) + const pt = new TextEncoder().encode(content) + const ct = new Uint8Array(pt.length) + xchacha20(ss, nonce, pt, ct) + return base64.encode(new Uint8Array([1, ...nonce, ...ct])) +} +function decrypt(content, ss) { + const buf = base64.decode(content) + const nonce = buf.subarray(1, 25), + ct = buf.subarray(25) + const pt = new Uint8Array(ct.length) + xchacha20(ss, nonce, ct, pt) + return new TextDecoder().decode(pt) +} + +// --- Nostr RPC helper --- +async function sendRpc(ws, sk, pk, ss, rpcName, body) { + return new Promise((resolve, reject) => { + const rid = crypto.randomUUID() + const payload = { rpcName, requestId: rid, authIdentifier: pk, appId: LP_APP_ID, body } + const enc = encrypt(JSON.stringify(payload), ss) + const ev = finalizeEvent( + { + kind: 21000, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', LP_APP_PUBKEY]], + content: enc, + }, + sk + ) + + const timeout = setTimeout(() => reject(new Error(`RPC timeout for ${rpcName}`)), TIMEOUT_MS) + const handler = (data) => { + const msg = JSON.parse(data.toString()) + if (msg[0] === 'EVENT') { + try { + const dec = JSON.parse(decrypt(msg[2].content, ss)) + if (dec.requestId === rid) { + clearTimeout(timeout) + ws.removeListener('message', handler) + resolve(dec) + } + } catch {} + } + } + ws.on('message', handler) + ws.send(JSON.stringify(['EVENT', ev])) + }) +} + +// --- HTTP helpers --- +async function getLnurlParams(uniqueHash) { + const resp = await fetch(`${EXTENSION_URL}/api/v1/lnurl/${uniqueHash}`) + return resp.json() +} + +async function redeemLnurl(callbackUrl, k1, invoice) { + // Replace Docker/LAN IP with localhost for local testing + const fixedUrl = callbackUrl.replace(/http:\/\/[^:]+:1777/, EXTENSION_URL) + const url = `${fixedUrl}?k1=${k1}&pr=${invoice}` + const resp = await fetch(url) + return resp.json() +} + +// --- Generate invoice on LND-1 --- +async function generateInvoice(amtSats) { + const { execSync } = await import('child_process') + const result = execSync( + `docker exec regtest-lnd-1-1 lncli --network=regtest --rpcserver=lnd-1:10009 addinvoice --amt=${amtSats}`, + { encoding: 'utf8' } + ) + return JSON.parse(result) +} + +// ===================== +// MAIN +// ===================== +async function main() { + const sk = generateSecretKey() + const pkHex = bytesToHex(sk) + const pk = getPublicKey(sk) + const ss = getSharedSecret(pkHex, LP_APP_PUBKEY) + + // Connect to relay + const ws = new WebSocket(RELAY_URL) + await new Promise((res, rej) => { + ws.on('open', res) + ws.on('error', rej) + }) + + // Subscribe for responses + ws.send( + JSON.stringify([ + 'REQ', + 'sub1', + { + kinds: [21000], + authors: [LP_APP_PUBKEY], + '#p': [pk], + since: Math.floor(Date.now() / 1000) - 5, + }, + ]) + ) + await new Promise((res) => { + const h = (d) => { + if (JSON.parse(d.toString())[0] === 'EOSE') { + ws.removeListener('message', h) + res() + } + } + ws.on('message', h) + }) + console.log('Connected to relay\n') + + // ========================================== + // TEST 1: Create → Update → Pay (new params) + // ========================================== + console.log('=========================================') + console.log('TEST 1: Create → Update → Pay') + console.log('=========================================\n') + + // 1a. Create link via Nostr RPC (10-50 sats) + console.log('1a. Creating withdraw link via Nostr RPC (10-50 sats)...') + const create1 = await sendRpc(ws, sk, pk, ss, 'withdraw.createLink', { + title: 'Test1 Original', + min_withdrawable: 10, + max_withdrawable: 50, + uses: 1, + wait_time: 0, + }) + if (create1.status !== 'OK') { + console.error(' FAIL:', create1.reason || create1) + process.exit(1) + } + const link1 = create1.link + console.log( + ` OK: id=${link1.id}, min=${link1.min_withdrawable}, max=${link1.max_withdrawable}` + ) + console.log(` unique_hash=${link1.unique_hash}`) + + // 1b. Verify original params via LNURL + const lnurlUrl1 = link1.lnurl_url || `${EXTENSION_URL}/api/v1/lnurl/${link1.unique_hash}` + const params1 = await getLnurlParams(link1.unique_hash) + console.log( + ` LNURL params: min=${params1.minWithdrawable / 1000}sats, max=${params1.maxWithdrawable / 1000}sats` + ) + + // 1c. Update link via Nostr RPC (change to 20-80 sats) + console.log('\n1b. Updating link via Nostr RPC (20-80 sats)...') + const updateResult = await sendRpc(ws, sk, pk, ss, 'withdraw.updateLink', { + id: link1.id, + title: 'Test1 Updated', + min_withdrawable: 20, + max_withdrawable: 80, + uses: 1, + }) + console.log(` RPC response: status=${updateResult.status}`) + if (updateResult.status !== 'OK') { + console.error(' FAIL: update returned', updateResult.reason || updateResult) + process.exit(1) + } + + // 1d. Verify updated params via LNURL + const params2 = await getLnurlParams(link1.unique_hash) + console.log( + ` Updated LNURL params: min=${params2.minWithdrawable / 1000}sats, max=${params2.maxWithdrawable / 1000}sats` + ) + + if (params2.minWithdrawable !== 20000 || params2.maxWithdrawable !== 80000) { + console.error(' FAIL: params not updated!') + process.exit(1) + } + console.log(' PASS: params updated correctly') + + // 1e. Redeem with updated params (50 sats, within new 20-80 range) + console.log('\n1c. Redeeming with 50 sats (within new 20-80 range)...') + const inv1 = await generateInvoice(50) + console.log(` Invoice: ${inv1.payment_request.substring(0, 50)}...`) + + const redeem1 = await redeemLnurl(params2.callback, params2.k1, inv1.payment_request) + console.log(` Redeem response: status=${redeem1.status}`) + if (redeem1.status === 'OK') { + console.log(' PASS: Payment sent successfully!') + } else { + console.error(` FAIL: ${redeem1.reason}`) + } + + // Wait for payment to settle + await new Promise((r) => setTimeout(r, 3000)) + + // ========================================== + // TEST 2: Create → Delete → Attempt Pay → Fail + // ========================================== + console.log('\n\n=========================================') + console.log('TEST 2: Create → Delete → Pay (should fail)') + console.log('=========================================\n') + + // 2a. Create link via Nostr RPC (10-50 sats) + console.log('2a. Creating withdraw link via Nostr RPC (10-50 sats)...') + const create2 = await sendRpc(ws, sk, pk, ss, 'withdraw.createLink', { + title: 'Test2 ToDelete', + min_withdrawable: 10, + max_withdrawable: 50, + uses: 1, + wait_time: 0, + }) + if (create2.status !== 'OK') { + console.error(' FAIL:', create2.reason || create2) + process.exit(1) + } + const link2 = create2.link + console.log(` OK: id=${link2.id}, unique_hash=${link2.unique_hash}`) + + // 2b. Get LNURL params (save k1 and callback for later) + const params3 = await getLnurlParams(link2.unique_hash) + console.log(` LNURL params saved: k1=${params3.k1.substring(0, 16)}...`) + const savedCallback = params3.callback + const savedK1 = params3.k1 + + // 2c. Delete link via Nostr RPC + console.log('\n2b. Deleting link via Nostr RPC...') + const deleteResult = await sendRpc(ws, sk, pk, ss, 'withdraw.deleteLink', { + id: link2.id, + }) + console.log(` RPC response: status=${deleteResult.status}`) + if (deleteResult.status !== 'OK') { + console.error(' FAIL: delete returned', deleteResult.reason || deleteResult) + process.exit(1) + } + console.log(' Link deleted') + + // 2d. Verify LNURL endpoint returns error + console.log('\n2c. Checking LNURL endpoint after deletion...') + const params4 = await getLnurlParams(link2.unique_hash) + console.log(` LNURL response: ${JSON.stringify(params4)}`) + + // 2e. Try to redeem anyway using saved k1 + console.log('\n2d. Attempting to redeem deleted link...') + const inv2 = await generateInvoice(30) + const redeem2 = await redeemLnurl(savedCallback, savedK1, inv2.payment_request) + console.log(` Redeem response: status=${redeem2.status}`) + if (redeem2.status === 'ERROR') { + console.log(` PASS: Payment correctly rejected: ${redeem2.reason}`) + } else if (redeem2.status === 'OK') { + console.error(' FAIL: Payment should have been rejected for deleted link!') + } else { + console.log(` Result: ${JSON.stringify(redeem2)}`) + } + + ws.close() + + console.log('\n\n=========================================') + console.log('ALL TESTS COMPLETED') + console.log('=========================================') +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error('Error:', err.message) + process.exit(1) + }) diff --git a/scripts/test-withdraw-rpc.mjs b/scripts/test-withdraw-rpc.mjs new file mode 100644 index 0000000..a138ef0 --- /dev/null +++ b/scripts/test-withdraw-rpc.mjs @@ -0,0 +1,201 @@ +/** + * 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) + })