From 0049528b461a724c40f0a909dac9ac1455b8f1ba Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 29 Oct 2025 14:18:27 -0400 Subject: [PATCH] Ensure zap requests carry bech32 LNURL tags --- js/payments/lnurl.js | 32 +++++++++++ js/payments/zapSplit.js | 36 ++++++++++-- tests/zap-split.test.mjs | 118 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 176 insertions(+), 10 deletions(-) diff --git a/js/payments/lnurl.js b/js/payments/lnurl.js index 52d536b8..14f1f503 100644 --- a/js/payments/lnurl.js +++ b/js/payments/lnurl.js @@ -112,6 +112,36 @@ function convertWords(words, fromBits, toBits, { pad = true } = {}) { return result; } +function createChecksum(hrp, data) { + const values = [...hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0]; + const mod = polymod(values) ^ 1; + const checksum = []; + for (let i = 0; i < 6; i += 1) { + checksum.push((mod >> ((5 - i) * 5)) & 31); + } + return checksum; +} + +function bech32Encode(hrp, data) { + const checksum = createChecksum(hrp, data); + const combined = [...data, ...checksum]; + const encodedData = combined.map((value) => { + if (value < 0 || value >= CHARSET.length) { + throw new Error("Invalid bech32 word value."); + } + return CHARSET[value]; + }); + return `${hrp}1${encodedData.join("")}`; +} + +export function encodeLnurlBech32(url) { + const sanitized = sanitizeUrl(url); + const encoder = new TextEncoder(); + const bytes = Array.from(encoder.encode(sanitized)); + const words = convertWords(bytes, 8, 5, { pad: true }); + return bech32Encode("lnurl", words).toLowerCase(); +} + function decodeLnurlBech32(encoded) { const { prefix, words } = bech32Decode(encoded); if (prefix !== "lnurl") { @@ -366,7 +396,9 @@ export async function requestInvoice( } export const __TESTING__ = Object.freeze({ + bech32Encode, bech32Decode, decodeLnurlBech32, convertWords, + createChecksum, }); diff --git a/js/payments/zapSplit.js b/js/payments/zapSplit.js index 62e1b43b..71cb4a44 100644 --- a/js/payments/zapSplit.js +++ b/js/payments/zapSplit.js @@ -7,6 +7,7 @@ import { fetchPayServiceData, validateInvoiceAmount, requestInvoice, + encodeLnurlBech32, } from "./lnurl.js"; import { ensureWallet, sendPayment } from "./nwcClient.js"; import { getPlatformLightningAddress } from "./platformAddress.js"; @@ -26,6 +27,7 @@ const DEFAULT_DEPS = Object.freeze({ fetchPayServiceData, validateInvoiceAmount, requestInvoice, + encodeLnurlBech32, }), wallet: Object.freeze({ ensureWallet, sendPayment }), platformAddress: Object.freeze({ getPlatformLightningAddress }), @@ -306,6 +308,24 @@ function resolveRelayUrls(wallet) { return normalized; } +function resolveBech32Lnurl(resolved, encodeFn) { + const candidate = typeof resolved?.address === "string" ? resolved.address.trim() : ""; + if (candidate && candidate.toLowerCase().startsWith("lnurl")) { + return candidate.toLowerCase(); + } + + const callbackUrl = typeof resolved?.url === "string" ? resolved.url.trim() : ""; + if (!callbackUrl) { + return ""; + } + + if (typeof encodeFn !== "function") { + throw new Error("LNURL encoder is unavailable."); + } + + return encodeFn(callbackUrl); +} + function buildZapRequest({ wallet, recipientPubkey, @@ -330,8 +350,11 @@ function buildZapRequest({ if (pointerTag) { tags.push(pointerTag); } - if (lnurl) { - tags.push(["lnurl", lnurl]); + if (typeof lnurl === "string") { + const normalizedLnurl = lnurl.trim(); + if (normalizedLnurl) { + tags.push(["lnurl", normalizedLnurl]); + } } if (Number.isFinite(amountSats)) { tags.push(["amount", String(Math.max(0, Math.round(amountSats)) * 1000)]); @@ -382,6 +405,11 @@ async function processShare({ const metadata = await deps.lnurl.fetchPayServiceData(resolved.url); const { amountMsats } = deps.lnurl.validateInvoiceAmount(metadata, amountSats); + const lnurlTag = resolveBech32Lnurl(resolved, deps.lnurl.encodeLnurlBech32); + if (!lnurlTag) { + throw new Error("Unable to resolve LNURL for zap request."); + } + let zapRequest = null; if (metadata.allowsNostr === true || metadata.nostrPubkey) { const recipientPubkey = determineRecipientPubkey({ @@ -396,7 +424,7 @@ async function processShare({ videoEvent, amountSats, comment, - lnurl: resolved.url, + lnurl: lnurlTag, }); } } @@ -411,7 +439,7 @@ async function processShare({ const payment = await deps.wallet.sendPayment(invoice.invoice, { amountSats, zapRequest, - lnurl: resolved.url, + lnurl: lnurlTag, }); return { diff --git a/tests/zap-split.test.mjs b/tests/zap-split.test.mjs index 375da56f..7af0f706 100644 --- a/tests/zap-split.test.mjs +++ b/tests/zap-split.test.mjs @@ -78,6 +78,12 @@ globalThis.fetch = async () => { const { splitAndZap } = await import("../js/payments/zapSplit.js"); const platformAddressModule = await import("../js/payments/platformAddress.js"); const { __resetPlatformAddressCache } = platformAddressModule; +const lnurlModule = await import("../js/payments/lnurl.js"); +const { + encodeLnurlBech32, + resolveLightningAddress: baseResolveLightningAddress, +} = lnurlModule; +const { decodeLnurlBech32 } = lnurlModule.__TESTING__; const { nostrClient } = await import("../js/nostr.js"); const DEFAULT_WALLET_RELAYS = [ @@ -102,11 +108,10 @@ function createDeps({ deps: { lnurl: { resolveLightningAddress(address) { - return { - type: address.includes("platform") ? "platform" : "creator", - url: `https://lnurl.test/${address}`, - address, - }; + return { ...baseResolveLightningAddress(address) }; + }, + encodeLnurlBech32(url) { + return encodeLnurlBech32(url); }, async fetchPayServiceData(url) { const isPlatform = url.includes("platform"); @@ -193,6 +198,9 @@ async function testSplitMath() { deps ); + const creatorResolved = baseResolveLightningAddress(videoEvent.lightningAddress); + const platformResolved = baseResolveLightningAddress("platform@example.com"); + assert.equal(result.totalAmount, 1000); assert.equal(result.creatorShare, 900); assert.equal(result.platformShare, 100); @@ -227,13 +235,105 @@ async function testSplitMath() { "relays tag should match wallet relay list" ); + const creatorLnurlTag = parsedZap.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "lnurl" + ); + assert(creatorLnurlTag, "creator zap should include lnurl tag"); + assert.equal(creatorLnurlTag[1], creatorLnurlTag[1].toLowerCase()); + assert(creatorLnurlTag[1].startsWith("lnurl")); + assert.equal( + decodeLnurlBech32(creatorLnurlTag[1]), + creatorResolved.url + ); + const platformReceipt = result.receipts[1]; assert.equal(platformReceipt.recipientType, "platform"); assert.equal(platformReceipt.status, "success"); + const platformZap = JSON.parse(platformReceipt.zapRequest); + const platformLnurlTag = platformZap.tags.find( + (tag) => Array.isArray(tag) && tag[0] === "lnurl" + ); + assert(platformLnurlTag, "platform zap should include lnurl tag"); + assert.equal(platformLnurlTag[1], platformLnurlTag[1].toLowerCase()); + assert(platformLnurlTag[1].startsWith("lnurl")); + assert.equal( + decodeLnurlBech32(platformLnurlTag[1]), + platformResolved.url + ); + delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; } +async function testBech32LightningAddressZapTag() { + const previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0; + + const { deps } = createDeps(); + const callbackUrl = "https://lnurl.example/api/callback"; + const bech32Address = encodeLnurlBech32(callbackUrl); + + const videoEvent = { + id: "event-id", + pubkey: "c".repeat(64), + lightningAddress: bech32Address, + tags: [["d", "pointer"]], + kind: 30078, + }; + + const result = await splitAndZap({ videoEvent, amountSats: 500, comment: "Bech32" }, deps); + + assert.equal(result.platformShare, 0); + assert.equal(result.receipts.length, 1); + + const parsedZap = JSON.parse(result.receipts[0].zapRequest); + const lnurlTag = parsedZap.tags.find((tag) => Array.isArray(tag) && tag[0] === "lnurl"); + assert(lnurlTag, "bech32 address zap should include lnurl tag"); + assert.equal(lnurlTag[1], lnurlTag[1].toLowerCase()); + assert.equal(lnurlTag[1], bech32Address.toLowerCase()); + assert.equal(decodeLnurlBech32(lnurlTag[1]), callbackUrl); + + if (previousOverride === undefined) { + delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; + } else { + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride; + } +} + +async function testUrlLightningAddressZapTag() { + const previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0; + + const { deps } = createDeps(); + const directUrl = "https://direct-lnurl.example/api/pay"; + + const videoEvent = { + id: "event-id", + pubkey: "c".repeat(64), + lightningAddress: directUrl, + tags: [["d", "pointer"]], + kind: 30078, + }; + + const result = await splitAndZap({ videoEvent, amountSats: 800, comment: "Direct" }, deps); + + assert.equal(result.platformShare, 0); + assert.equal(result.receipts.length, 1); + + const parsedZap = JSON.parse(result.receipts[0].zapRequest); + const lnurlTag = parsedZap.tags.find((tag) => Array.isArray(tag) && tag[0] === "lnurl"); + assert(lnurlTag, "direct url zap should include lnurl tag"); + assert.equal(lnurlTag[1], lnurlTag[1].toLowerCase()); + assert(lnurlTag[1].startsWith("lnurl")); + assert.equal(decodeLnurlBech32(lnurlTag[1]), directUrl); + + if (previousOverride === undefined) { + delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; + } else { + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride; + } +} + async function testWaitsForPoolBeforePlatformLookup() { const previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 10; @@ -366,9 +466,13 @@ async function testStringFeeOverride() { async function testPlatformShareFailure() { globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 10; + const platformCallbackMarker = `${ + baseResolveLightningAddress("platform@example.com").url + }/callback`; + const { deps, getSendCalls } = createDeps({ sendPaymentImplementation: ({ invoice, amountSats, zapRequest }) => { - if (invoice.includes("platform@example.com")) { + if (invoice.includes(platformCallbackMarker)) { const error = new Error("Budget exceeded"); error.code = "budget_exceeded"; throw error; @@ -492,6 +596,8 @@ async function testWalletFailure() { } await testSplitMath(); +await testBech32LightningAddressZapTag(); +await testUrlLightningAddressZapTag(); await testWaitsForPoolBeforePlatformLookup(); await testLnurlBounds(); await testMissingAddress();