mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
410 lines
11 KiB
JavaScript
410 lines
11 KiB
JavaScript
// js/payments/lnurl.js
|
||
|
||
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||
const CHARKEY = new Map(
|
||
Array.from(CHARSET).map((char, index) => [char, index])
|
||
);
|
||
const GENERATORS = [
|
||
0x3b6a57b2,
|
||
0x26508e6d,
|
||
0x1ea119fa,
|
||
0x3d4233dd,
|
||
0x2a1462b3,
|
||
];
|
||
|
||
const DEFAULT_COMMENT_MAX = 0;
|
||
|
||
function hrpExpand(hrp) {
|
||
const chars = Array.from(hrp);
|
||
const left = chars.map((char) => char.charCodeAt(0) >> 5);
|
||
const right = chars.map((char) => char.charCodeAt(0) & 31);
|
||
return [...left, 0, ...right];
|
||
}
|
||
|
||
function polymod(values) {
|
||
let chk = 1;
|
||
for (const value of values) {
|
||
const top = chk >>> 25;
|
||
chk = ((chk & 0x1ffffff) << 5) ^ value;
|
||
for (let i = 0; i < GENERATORS.length; i += 1) {
|
||
if (((top >> i) & 1) !== 0) {
|
||
chk ^= GENERATORS[i];
|
||
}
|
||
}
|
||
}
|
||
return chk;
|
||
}
|
||
|
||
function verifyChecksum(hrp, data) {
|
||
return polymod([...hrpExpand(hrp), ...data]) === 1;
|
||
}
|
||
|
||
function bech32Decode(input) {
|
||
if (typeof input !== "string") {
|
||
throw new Error("LNURL must be a string.");
|
||
}
|
||
|
||
const trimmed = input.trim();
|
||
if (!trimmed) {
|
||
throw new Error("LNURL cannot be empty.");
|
||
}
|
||
|
||
const lower = trimmed.toLowerCase();
|
||
const upper = trimmed.toUpperCase();
|
||
if (trimmed !== lower && trimmed !== upper) {
|
||
throw new Error("LNURL cannot mix upper and lower case characters.");
|
||
}
|
||
|
||
const normalized = lower;
|
||
const separatorIndex = normalized.lastIndexOf("1");
|
||
if (separatorIndex <= 0 || separatorIndex + 7 > normalized.length) {
|
||
throw new Error("Invalid LNURL bech32 payload.");
|
||
}
|
||
|
||
const hrp = normalized.slice(0, separatorIndex);
|
||
const dataPart = normalized.slice(separatorIndex + 1);
|
||
|
||
const data = [];
|
||
for (const char of dataPart) {
|
||
if (!CHARKEY.has(char)) {
|
||
throw new Error("LNURL payload includes invalid characters.");
|
||
}
|
||
data.push(CHARKEY.get(char));
|
||
}
|
||
|
||
if (!verifyChecksum(hrp, data)) {
|
||
throw new Error("LNURL checksum is invalid.");
|
||
}
|
||
|
||
return {
|
||
prefix: hrp,
|
||
words: data.slice(0, -6),
|
||
};
|
||
}
|
||
|
||
function convertWords(words, fromBits, toBits, { pad = true } = {}) {
|
||
let value = 0;
|
||
let bits = 0;
|
||
const maxValue = (1 << toBits) - 1;
|
||
const result = [];
|
||
|
||
for (const word of words) {
|
||
if (word < 0 || word >> fromBits !== 0) {
|
||
throw new Error("Invalid bech32 word value.");
|
||
}
|
||
value = (value << fromBits) | word;
|
||
bits += fromBits;
|
||
|
||
while (bits >= toBits) {
|
||
bits -= toBits;
|
||
result.push((value >> bits) & maxValue);
|
||
}
|
||
}
|
||
|
||
if (pad) {
|
||
if (bits > 0) {
|
||
result.push((value << (toBits - bits)) & maxValue);
|
||
}
|
||
} else if (bits >= fromBits || ((value << (toBits - bits)) & maxValue) !== 0) {
|
||
throw new Error("Excess padding in bech32 payload.");
|
||
}
|
||
|
||
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") {
|
||
throw new Error("Only bech32 LNURL encodings are supported.");
|
||
}
|
||
|
||
const bytes = convertWords(words, 5, 8, { pad: false });
|
||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||
return decoder.decode(Uint8Array.from(bytes));
|
||
}
|
||
|
||
function ensureFetchFunction(fetcher) {
|
||
if (typeof fetcher === "function") {
|
||
return fetcher;
|
||
}
|
||
|
||
if (typeof fetch === "function") {
|
||
return fetch;
|
||
}
|
||
|
||
if (typeof globalThis.fetch === "function") {
|
||
return globalThis.fetch.bind(globalThis);
|
||
}
|
||
|
||
throw new Error("Fetch API is not available in this environment.");
|
||
}
|
||
|
||
function sanitizeUrl(url) {
|
||
const trimmed = typeof url === "string" ? url.trim() : "";
|
||
if (!trimmed) {
|
||
throw new Error("LNURL endpoint is missing.");
|
||
}
|
||
return trimmed;
|
||
}
|
||
|
||
export function resolveLightningAddress(value) {
|
||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||
if (!trimmed) {
|
||
throw new Error("Lightning address is required.");
|
||
}
|
||
|
||
if (trimmed.toLowerCase().startsWith("lnurl")) {
|
||
const decoded = decodeLnurlBech32(trimmed);
|
||
return {
|
||
type: "lud06",
|
||
url: sanitizeUrl(decoded),
|
||
address: trimmed,
|
||
};
|
||
}
|
||
|
||
if (trimmed.includes("@")) {
|
||
const [namePart, domainPart] = trimmed.split("@");
|
||
const name = (namePart || "").trim();
|
||
const domain = (domainPart || "").trim();
|
||
if (!name || !domain) {
|
||
throw new Error("Invalid Lightning address format.");
|
||
}
|
||
const url = `https://${domain}/.well-known/lnurlp/${encodeURIComponent(
|
||
name.toLowerCase()
|
||
)}`;
|
||
return {
|
||
type: "lud16",
|
||
url,
|
||
address: `${name}@${domain}`,
|
||
};
|
||
}
|
||
|
||
if (/^https?:\/\//i.test(trimmed)) {
|
||
return {
|
||
type: "url",
|
||
url: trimmed,
|
||
address: trimmed,
|
||
};
|
||
}
|
||
|
||
throw new Error("Unsupported Lightning address format.");
|
||
}
|
||
|
||
function normalizeNumeric(value, fallback = 0) {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) {
|
||
return fallback;
|
||
}
|
||
return numeric;
|
||
}
|
||
|
||
export async function fetchPayServiceData(url, { fetcher } = {}) {
|
||
const targetUrl = sanitizeUrl(url);
|
||
const fetchFn = ensureFetchFunction(fetcher);
|
||
|
||
const response = await fetchFn(targetUrl, {
|
||
method: "GET",
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to load LNURL metadata (${response.status}).`);
|
||
}
|
||
|
||
let payload;
|
||
try {
|
||
payload = await response.json();
|
||
} catch (error) {
|
||
throw new Error("LNURL endpoint did not return JSON.");
|
||
}
|
||
|
||
if (!payload || typeof payload !== "object") {
|
||
throw new Error("LNURL metadata response was invalid.");
|
||
}
|
||
|
||
if (typeof payload.status === "string" && payload.status.toUpperCase() === "ERROR") {
|
||
const reason =
|
||
typeof payload.reason === "string" && payload.reason.trim()
|
||
? payload.reason.trim()
|
||
: "LNURL endpoint returned an error.";
|
||
throw new Error(reason);
|
||
}
|
||
|
||
const callback = sanitizeUrl(payload.callback);
|
||
const minSendable = Math.max(0, normalizeNumeric(payload.minSendable, 0));
|
||
const maxSendable = Math.max(minSendable, normalizeNumeric(payload.maxSendable, minSendable));
|
||
const commentAllowed = Math.max(0, Math.round(normalizeNumeric(payload.commentAllowed, DEFAULT_COMMENT_MAX)));
|
||
const allowsNostr = payload.allowsNostr === true;
|
||
const nostrPubkey =
|
||
typeof payload.nostrPubkey === "string" ? payload.nostrPubkey.trim() : "";
|
||
|
||
let metadata = [];
|
||
if (typeof payload.metadata === "string" && payload.metadata.trim()) {
|
||
try {
|
||
const parsed = JSON.parse(payload.metadata);
|
||
if (Array.isArray(parsed)) {
|
||
metadata = parsed;
|
||
}
|
||
} catch (error) {
|
||
// Ignore metadata parse errors – it is optional.
|
||
}
|
||
}
|
||
|
||
return {
|
||
tag: typeof payload.tag === "string" ? payload.tag : "payRequest",
|
||
callback,
|
||
minSendable,
|
||
maxSendable,
|
||
metadata,
|
||
commentAllowed,
|
||
allowsNostr,
|
||
nostrPubkey,
|
||
raw: payload,
|
||
};
|
||
}
|
||
|
||
export function validateInvoiceAmount(metadata, amountSats) {
|
||
if (!metadata || typeof metadata !== "object") {
|
||
throw new Error("LNURL metadata is required to validate invoice amounts.");
|
||
}
|
||
|
||
const amount = Math.round(Number(amountSats));
|
||
if (!Number.isFinite(amount) || amount <= 0) {
|
||
throw new Error("Zap amount must be a positive integer.");
|
||
}
|
||
|
||
const amountMsats = amount * 1000;
|
||
const min = Math.max(0, Math.round(Number(metadata.minSendable || 0)));
|
||
const max = Math.max(min, Math.round(Number(metadata.maxSendable || amountMsats)));
|
||
|
||
if (min && amountMsats < min) {
|
||
throw new Error(`Amount is below the minimum sendable value (${min / 1000} sats).`);
|
||
}
|
||
|
||
if (max && amountMsats > max) {
|
||
throw new Error(`Amount exceeds the maximum sendable value (${max / 1000} sats).`);
|
||
}
|
||
|
||
return { amountMsats };
|
||
}
|
||
|
||
function truncateComment(comment, limit) {
|
||
const trimmed = typeof comment === "string" ? comment.trim() : "";
|
||
if (!limit || limit <= 0) {
|
||
return "";
|
||
}
|
||
if (trimmed.length <= limit) {
|
||
return trimmed;
|
||
}
|
||
return trimmed.slice(0, limit);
|
||
}
|
||
|
||
export async function requestInvoice(
|
||
metadata,
|
||
{ amountSats, amountMsats, comment, zapRequest, lnurl, fetcher } = {}
|
||
) {
|
||
if (!metadata || typeof metadata !== "object") {
|
||
throw new Error("LNURL metadata is required to request an invoice.");
|
||
}
|
||
|
||
const fetchFn = ensureFetchFunction(fetcher);
|
||
const targetAmount = Number.isFinite(amountMsats)
|
||
? Math.round(amountMsats)
|
||
: validateInvoiceAmount(metadata, amountSats).amountMsats;
|
||
|
||
const callbackUrl = new URL(metadata.callback);
|
||
callbackUrl.searchParams.set("amount", String(targetAmount));
|
||
|
||
if (comment && metadata.commentAllowed > 0) {
|
||
const truncated = truncateComment(comment, metadata.commentAllowed);
|
||
if (truncated) {
|
||
callbackUrl.searchParams.set("comment", truncated);
|
||
}
|
||
}
|
||
|
||
if (zapRequest) {
|
||
callbackUrl.searchParams.set("nostr", zapRequest);
|
||
}
|
||
|
||
const originalLnurl = typeof lnurl === "string" ? lnurl.trim() : "";
|
||
if (originalLnurl) {
|
||
callbackUrl.searchParams.set("lnurl", originalLnurl);
|
||
}
|
||
|
||
const response = await fetchFn(callbackUrl.toString(), {
|
||
method: "GET",
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch LNURL invoice (${response.status}).`);
|
||
}
|
||
|
||
let payload;
|
||
try {
|
||
payload = await response.json();
|
||
} catch (error) {
|
||
throw new Error("LNURL invoice response was not JSON.");
|
||
}
|
||
|
||
if (!payload || typeof payload !== "object") {
|
||
throw new Error("LNURL invoice response was invalid.");
|
||
}
|
||
|
||
if (typeof payload.status === "string" && payload.status.toUpperCase() === "ERROR") {
|
||
const reason =
|
||
typeof payload.reason === "string" && payload.reason.trim()
|
||
? payload.reason.trim()
|
||
: "LNURL invoice request failed.";
|
||
throw new Error(reason);
|
||
}
|
||
|
||
const invoice = typeof payload.pr === "string" ? payload.pr.trim() : "";
|
||
if (!invoice) {
|
||
throw new Error("LNURL endpoint did not return an invoice.");
|
||
}
|
||
|
||
return {
|
||
invoice,
|
||
raw: payload,
|
||
};
|
||
}
|
||
|
||
export const __TESTING__ = Object.freeze({
|
||
bech32Encode,
|
||
bech32Decode,
|
||
decodeLnurlBech32,
|
||
convertWords,
|
||
createChecksum,
|
||
});
|