mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 17:02:28 +00:00
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:
3 files changed
+176
-10
No files matched your search
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user