Merge pull request #992 from PR0M3TH3AN/codex/update-zapsplit.js-for-bech32-lnurl-support

Ensure zap requests include bech32 LNURL tags
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-29 14:18:46 -04:00
commit db9b79efbe
3 files changed
+176 -10

No files matched your search

+32
View File
@@ -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,
});
+32 -4
View File
@@ -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 {
+112 -6
View File
@@ -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("[email protected]");
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("[email protected]").url
}/callback`;
const { deps, getSendCalls } = createDeps({
sendPaymentImplementation: ({ invoice, amountSats, zapRequest }) => {
if (invoice.includes("[email protected]")) {
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();