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>
This commit is contained in:
Patrick Mulligan 2026-03-07 13:02:48 -05:00
commit 9e6ee4813c
3 changed files with 715 additions and 0 deletions

View file

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