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:
parent
25e5dfe46c
commit
9e6ee4813c
3 changed files with 715 additions and 0 deletions
212
scripts/test-full-withdraw.mjs
Normal file
212
scripts/test-full-withdraw.mjs
Normal file
|
|
@ -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=<bolt11> 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)
|
||||||
|
})
|
||||||
302
scripts/test-update-delete.mjs
Normal file
302
scripts/test-update-delete.mjs
Normal 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)
|
||||||
|
})
|
||||||
201
scripts/test-withdraw-rpc.mjs
Normal file
201
scripts/test-withdraw-rpc.mjs
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue