- 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>
212 lines
6.3 KiB
JavaScript
212 lines
6.3 KiB
JavaScript
/**
|
|
* 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)
|
|
})
|