Merge pull request #294 from PR0M3TH3AN/codex/implement-payments-module-with-helper-functions

Add Lightning payment utilities and zap split tests
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-04 22:16:41 -04:00
commit dbc8a5278a
5 files changed
+1603

No files matched your search

+372
View File
@@ -0,0 +1,372 @@
// 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 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, 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 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({
bech32Decode,
decodeLnurlBech32,
convertWords,
});
+478
View File
@@ -0,0 +1,478 @@
// js/payments/nwcClient.js
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const URI_SCHEMES = [
"nostr+walletconnect://",
"walletconnect://",
"nwc://",
];
const REQUEST_KIND = 23194;
const RESPONSE_KIND = 23195;
const DEFAULT_REQUEST_TIMEOUT_MS = 25_000;
let activeState = null;
let pendingRequests = new Map();
let socket = null;
let subscriptionId = null;
let connectionPromise = null;
let requestCounter = 0;
function getGlobalWindow() {
if (typeof window !== "undefined") {
return window;
}
if (typeof globalThis !== "undefined") {
return globalThis;
}
return {};
}
function getNostrTools() {
const win = getGlobalWindow();
return win?.NostrTools || null;
}
function assertNostrTools(methods = []) {
const tools = getNostrTools();
if (!tools) {
throw new Error("NostrTools is required for NWC operations.");
}
for (const method of methods) {
if (typeof tools?.[method] !== "function") {
throw new Error(`NostrTools.${method} is unavailable.`);
}
}
return tools;
}
function decodePubkey(value) {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
throw new Error("Wallet pubkey is missing from the NWC URI.");
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
const tools = getNostrTools();
const decoder = tools?.nip19?.decode;
if (typeof decoder === "function") {
try {
const decoded = decoder(trimmed);
if (decoded?.type === "npub" && typeof decoded.data === "string") {
const hex = decoded.data.trim();
if (HEX64_REGEX.test(hex)) {
return hex.toLowerCase();
}
}
} catch (error) {
// Ignore decode errors and fall through to failure.
}
}
throw new Error("Wallet pubkey in the NWC URI is invalid.");
}
function parseNwcUri(uri) {
const trimmed = typeof uri === "string" ? uri.trim() : "";
if (!trimmed) {
throw new Error("Wallet URI is required.");
}
let stripped = trimmed;
let scheme = "";
for (const candidate of URI_SCHEMES) {
if (trimmed.toLowerCase().startsWith(candidate)) {
stripped = trimmed.slice(candidate.length);
scheme = candidate;
break;
}
}
if (!scheme) {
throw new Error("Unsupported NWC URI scheme.");
}
const [identifierPart, queryPart] = stripped.split("?");
const walletPubkey = decodePubkey(identifierPart);
const params = new URLSearchParams(queryPart || "");
const relay = params.get("relay") || params.get("r");
const secret = params.get("secret") || params.get("s");
if (!relay) {
throw new Error("NWC URI is missing a relay parameter.");
}
if (!secret || !HEX64_REGEX.test(secret)) {
throw new Error("NWC URI secret must be a 64 character hex string.");
}
const relayUrl = decodeURIComponent(relay);
const secretKey = secret.toLowerCase();
const tools = assertNostrTools(["getPublicKey"]);
const clientPubkey = tools.getPublicKey(secretKey);
return {
normalizedUri: `nostr+walletconnect://${walletPubkey}?relay=${encodeURIComponent(
relayUrl
)}&secret=${secretKey}`,
relayUrl,
walletPubkey,
secretKey,
clientPubkey,
};
}
function closeSocket({ keepState = false } = {}) {
if (socket) {
try {
socket.close();
} catch (error) {
// Ignore close errors.
}
}
socket = null;
connectionPromise = null;
subscriptionId = null;
if (!keepState) {
activeState = null;
}
for (const [, entry] of pendingRequests.entries()) {
try {
entry.reject(new Error("Wallet connection closed."));
} catch (error) {
// ignore
}
}
pendingRequests.clear();
}
function isSocketOpen() {
return socket && socket.readyState === socket.OPEN;
}
function handleSocketError(error) {
console.warn("[nwcClient] WebSocket error", error);
closeSocket({ keepState: true });
}
function handleSocketClose() {
console.warn("[nwcClient] Wallet connection closed.");
closeSocket({ keepState: true });
}
function resolveWebSocketImplementation() {
if (typeof WebSocket !== "undefined") {
return WebSocket;
}
if (typeof globalThis !== "undefined" && typeof globalThis.WebSocket !== "undefined") {
return globalThis.WebSocket;
}
throw new Error("WebSocket is not available in this environment.");
}
function subscribeToResponses(context) {
if (!socket) {
return;
}
subscriptionId = `nwc-${Math.random().toString(36).slice(2, 10)}`;
const filters = {
kinds: [RESPONSE_KIND],
authors: [context.walletPubkey],
"#p": [context.clientPubkey],
};
socket.send(JSON.stringify(["REQ", subscriptionId, filters]));
}
async function decryptResponse(event) {
const tools = assertNostrTools(["nip04"]);
if (typeof tools.nip04?.decrypt !== "function") {
throw new Error("NostrTools.nip04.decrypt is not available.");
}
const context = activeState?.context;
if (!context) {
throw new Error("Wallet context is unavailable for decrypting responses.");
}
const plaintext = await tools.nip04.decrypt(
context.secretKey,
context.walletPubkey,
event.content
);
return JSON.parse(plaintext);
}
async function handleSocketMessage(messageEvent) {
let payload;
try {
payload = JSON.parse(messageEvent.data);
} catch (error) {
console.warn("[nwcClient] Failed to parse relay message", error);
return;
}
if (!Array.isArray(payload) || payload.length < 2) {
return;
}
const [type] = payload;
if (type === "EVENT" && payload.length >= 3) {
const event = payload[2];
if (event?.kind !== RESPONSE_KIND || !Array.isArray(event.tags)) {
return;
}
const eTag = event.tags.find((tag) => Array.isArray(tag) && tag[0] === "e");
const requestId = eTag?.[1];
if (!requestId || !pendingRequests.has(requestId)) {
return;
}
const pending = pendingRequests.get(requestId);
pendingRequests.delete(requestId);
clearTimeout(pending.timeoutId);
try {
const response = await decryptResponse(event);
if (response?.error) {
const message =
typeof response.error.message === "string" && response.error.message.trim()
? response.error.message.trim()
: "Wallet reported an error.";
const error = new Error(message);
error.code = response.error.code || null;
pending.reject(error);
return;
}
pending.resolve({
requestId,
result: response?.result || null,
response,
event,
});
} catch (error) {
pending.reject(error);
}
return;
}
if (type === "NOTICE" && payload.length >= 2) {
console.warn("[nwcClient] Relay notice:", payload[1]);
return;
}
}
function connectSocket(context) {
if (connectionPromise) {
return connectionPromise;
}
const WebSocketImpl = resolveWebSocketImplementation();
const url = context.relayUrl;
connectionPromise = new Promise((resolve, reject) => {
try {
socket = new WebSocketImpl(url);
} catch (error) {
connectionPromise = null;
reject(new Error("Failed to open wallet WebSocket."));
return;
}
const handleOpen = () => {
socket.removeEventListener("open", handleOpen);
socket.removeEventListener("error", handleError);
socket.removeEventListener("close", handleCloseDuringConnect);
socket.addEventListener("message", handleSocketMessage);
socket.addEventListener("error", handleSocketError);
socket.addEventListener("close", handleSocketClose);
subscribeToResponses(context);
resolve();
};
const handleError = (event) => {
socket.removeEventListener("open", handleOpen);
socket.removeEventListener("error", handleError);
socket.removeEventListener("close", handleCloseDuringConnect);
connectionPromise = null;
reject(event instanceof Error ? event : new Error("Wallet WebSocket failed."));
};
const handleCloseDuringConnect = () => {
socket.removeEventListener("open", handleOpen);
socket.removeEventListener("error", handleError);
socket.removeEventListener("close", handleCloseDuringConnect);
connectionPromise = null;
reject(new Error("Wallet WebSocket closed before opening."));
};
socket.addEventListener("open", handleOpen);
socket.addEventListener("error", handleError);
socket.addEventListener("close", handleCloseDuringConnect);
});
return connectionPromise;
}
function ensureActiveState(settings) {
if (!settings || typeof settings !== "object") {
throw new Error("Wallet settings are required.");
}
const uri =
typeof settings.nwcUri === "string" && settings.nwcUri.trim()
? settings.nwcUri.trim()
: "";
if (!uri) {
throw new Error("Wallet URI is required.");
}
const parsed = parseNwcUri(uri);
if (activeState && activeState.normalizedUri === parsed.normalizedUri) {
activeState.settings = { ...settings, nwcUri: parsed.normalizedUri };
return activeState.context;
}
closeSocket();
activeState = {
normalizedUri: parsed.normalizedUri,
settings: { ...settings, nwcUri: parsed.normalizedUri },
context: {
relayUrl: parsed.relayUrl,
walletPubkey: parsed.walletPubkey,
secretKey: parsed.secretKey,
clientPubkey: parsed.clientPubkey,
uri: parsed.normalizedUri,
},
};
pendingRequests = new Map();
return activeState.context;
}
export async function ensureWallet({ settings } = {}) {
const candidateSettings =
settings || activeState?.settings || activeState?.context?.settings || null;
const context = ensureActiveState(candidateSettings);
if (!isSocketOpen()) {
await connectSocket(context);
}
return context;
}
async function encryptRequestPayload(context, payload) {
const tools = assertNostrTools(["nip04", "getEventHash", "signEvent"]);
if (typeof tools.nip04?.encrypt !== "function") {
throw new Error("NostrTools.nip04.encrypt is not available.");
}
const plaintext = JSON.stringify(payload);
const encrypted = await tools.nip04.encrypt(
context.secretKey,
context.walletPubkey,
plaintext
);
const event = {
kind: REQUEST_KIND,
created_at: Math.floor(Date.now() / 1000),
content: encrypted,
pubkey: context.clientPubkey,
tags: [["p", context.walletPubkey]],
};
event.id = tools.getEventHash(event);
event.tags.push(["e", event.id]);
event.sig = tools.signEvent(event, context.secretKey);
return event;
}
function registerPendingRequest(eventId, { resolve, reject, timeoutMs }) {
const timeoutId = setTimeout(() => {
if (pendingRequests.has(eventId)) {
pendingRequests.delete(eventId);
reject(new Error("Wallet request timed out."));
}
}, timeoutMs || DEFAULT_REQUEST_TIMEOUT_MS);
pendingRequests.set(eventId, { resolve, reject, timeoutId });
}
async function sendWalletRequest(context, payload, { timeoutMs } = {}) {
if (!isSocketOpen()) {
await connectSocket(context);
}
const event = await encryptRequestPayload(context, payload);
const promise = new Promise((resolve, reject) => {
registerPendingRequest(event.id, { resolve, reject, timeoutMs });
});
socket.send(JSON.stringify(["EVENT", event]));
return promise;
}
function sanitizeInvoice(value) {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
throw new Error("Invoice is required to send a payment.");
}
return trimmed;
}
function sanitizeAmount(amount) {
if (!Number.isFinite(amount)) {
return null;
}
const rounded = Math.round(amount);
return Math.max(0, rounded);
}
export async function sendPayment(bolt11, { settings, amountSats, zapRequest, timeoutMs } = {}) {
const context = await ensureWallet({ settings });
const invoice = sanitizeInvoice(bolt11);
const payload = {
id: `req-${Date.now()}-${++requestCounter}`,
method: "pay_invoice",
params: {
invoice,
},
};
const amount = sanitizeAmount(amountSats);
if (amount && amount > 0) {
payload.params.amount = amount;
}
if (zapRequest) {
payload.params.zap_request = zapRequest;
}
const response = await sendWalletRequest(context, payload, { timeoutMs });
return response;
}
export function getActiveWalletContext() {
return activeState?.context || null;
}
export function resetWalletClient() {
closeSocket();
}
export const __TESTING__ = Object.freeze({
parseNwcUri,
closeSocket,
pendingRequests,
ensureActiveState,
});
+158
View File
@@ -0,0 +1,158 @@
// js/payments/platformAddress.js
import { PLATFORM_LUD16_OVERRIDE, ADMIN_SUPER_NPUB } from "../config.js";
import { nostrClient } from "../nostr.js";
import { setProfileCacheEntry } from "../state/cache.js";
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const CACHE_TTL_MS = 10 * 60 * 1000;
let cachedAddress = null;
function getGlobalWindow() {
if (typeof window !== "undefined") {
return window;
}
if (typeof globalThis !== "undefined") {
return globalThis;
}
return {};
}
function getNostrTools() {
const win = getGlobalWindow();
return win?.NostrTools || null;
}
function decodeAdminPubkey() {
const trimmed = typeof ADMIN_SUPER_NPUB === "string" ? ADMIN_SUPER_NPUB.trim() : "";
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
const tools = getNostrTools();
const decoder = tools?.nip19?.decode;
if (typeof decoder === "function") {
try {
const decoded = decoder(trimmed);
if (decoded?.type === "npub" && typeof decoded.data === "string") {
const hex = decoded.data.trim();
if (HEX64_REGEX.test(hex)) {
return hex.toLowerCase();
}
}
} catch (error) {
console.warn("[platformAddress] Failed to decode ADMIN_SUPER_NPUB", error);
}
}
return null;
}
function shouldUseOverride() {
return typeof PLATFORM_LUD16_OVERRIDE === "string" && PLATFORM_LUD16_OVERRIDE.trim();
}
function getOverrideAddress() {
const trimmed = PLATFORM_LUD16_OVERRIDE.trim();
return trimmed || null;
}
function isCacheFresh(entry) {
if (!entry) {
return false;
}
if (typeof entry.expiresAt !== "number") {
return false;
}
return entry.expiresAt > Date.now();
}
async function fetchAdminMetadata(adminPubkey) {
if (!adminPubkey) {
return null;
}
if (!nostrClient?.pool || !Array.isArray(nostrClient?.relays)) {
console.warn("[platformAddress] Nostr client is not ready.");
return null;
}
try {
const events = await nostrClient.pool.list(nostrClient.relays, [
{ kinds: [0], authors: [adminPubkey], limit: 1 },
]);
if (!Array.isArray(events) || events.length === 0) {
return null;
}
let newest = null;
for (const event of events) {
if (!event || event.pubkey !== adminPubkey || !event.content) {
continue;
}
if (!newest || event.created_at > newest.created_at) {
newest = event;
}
}
if (!newest?.content) {
return null;
}
const data = JSON.parse(newest.content);
const lightningAddress =
typeof data.lud16 === "string" && data.lud16.trim()
? data.lud16.trim()
: typeof data.lud06 === "string" && data.lud06.trim()
? data.lud06.trim()
: "";
if (lightningAddress) {
setProfileCacheEntry(adminPubkey, data, { persist: false });
}
return lightningAddress || null;
} catch (error) {
console.warn("[platformAddress] Failed to fetch admin metadata", error);
return null;
}
}
export async function getPlatformLightningAddress({ forceRefresh = false } = {}) {
if (shouldUseOverride()) {
return getOverrideAddress();
}
if (!forceRefresh && isCacheFresh(cachedAddress)) {
return cachedAddress.value;
}
const adminPubkey = decodeAdminPubkey();
if (!adminPubkey) {
return null;
}
if (forceRefresh !== true && cachedAddress?.value) {
return cachedAddress.value;
}
const lightningAddress = await fetchAdminMetadata(adminPubkey);
if (lightningAddress) {
cachedAddress = {
value: lightningAddress,
expiresAt: Date.now() + CACHE_TTL_MS,
};
}
return lightningAddress || cachedAddress?.value || null;
}
export function __resetPlatformAddressCache() {
cachedAddress = null;
}
+326
View File
@@ -0,0 +1,326 @@
// js/payments/zapSplit.js
import { PLATFORM_FEE_PERCENT, ADMIN_SUPER_NPUB } from "../config.js";
import {
resolveLightningAddress,
fetchPayServiceData,
validateInvoiceAmount,
requestInvoice,
} from "./lnurl.js";
import { ensureWallet, sendPayment } from "./nwcClient.js";
import { getPlatformLightningAddress } from "./platformAddress.js";
const HEX64_REGEX = /^[0-9a-f]{64}$/i;
const ZAP_KIND = 9734;
const DEFAULT_DEPS = Object.freeze({
lnurl: Object.freeze({
resolveLightningAddress,
fetchPayServiceData,
validateInvoiceAmount,
requestInvoice,
}),
wallet: Object.freeze({ ensureWallet, sendPayment }),
platformAddress: Object.freeze({ getPlatformLightningAddress }),
});
function getOverridePlatformFee() {
const override = globalThis?.__BITVID_PLATFORM_FEE_OVERRIDE__;
if (Number.isFinite(override)) {
return override;
}
return PLATFORM_FEE_PERCENT;
}
function clampPercent(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return 0;
}
return Math.min(100, Math.max(0, numeric));
}
function sanitizeAmount(amount) {
const numeric = Number(amount);
if (!Number.isFinite(numeric)) {
throw new Error("Zap amount must be a positive integer.");
}
const rounded = Math.round(numeric);
if (rounded <= 0) {
throw new Error("Zap amount must be greater than zero.");
}
return rounded;
}
function sanitizeComment(comment) {
if (typeof comment !== "string") {
return "";
}
return comment.trim();
}
function sanitizeAddress(address) {
const trimmed = typeof address === "string" ? address.trim() : "";
return trimmed || "";
}
function mergeDependencies(overrides = {}) {
const lnurlDeps = {
...DEFAULT_DEPS.lnurl,
...(overrides.lnurl || {}),
};
const walletDeps = {
...DEFAULT_DEPS.wallet,
...(overrides.wallet || {}),
};
const platformDeps = {
...DEFAULT_DEPS.platformAddress,
...(overrides.platformAddress || {}),
};
return { lnurl: lnurlDeps, wallet: walletDeps, platformAddress: platformDeps };
}
function getNostrTools() {
if (typeof window !== "undefined" && window?.NostrTools) {
return window.NostrTools;
}
if (typeof globalThis !== "undefined" && globalThis?.NostrTools) {
return globalThis.NostrTools;
}
return null;
}
function decodeAdminPubkey() {
const trimmed = typeof ADMIN_SUPER_NPUB === "string" ? ADMIN_SUPER_NPUB.trim() : "";
if (!trimmed) {
return null;
}
if (HEX64_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
const tools = getNostrTools();
const decoder = tools?.nip19?.decode;
if (typeof decoder === "function") {
try {
const decoded = decoder(trimmed);
if (decoded?.type === "npub" && typeof decoded.data === "string") {
const hex = decoded.data.trim();
if (HEX64_REGEX.test(hex)) {
return hex.toLowerCase();
}
}
} catch (error) {
return null;
}
}
return null;
}
function derivePointerTag(videoEvent) {
if (!videoEvent || typeof videoEvent !== "object") {
return null;
}
if (!Array.isArray(videoEvent.tags)) {
return null;
}
const dTag = videoEvent.tags.find((tag) => Array.isArray(tag) && tag[0] === "d" && tag[1]);
if (!dTag) {
return null;
}
if (!Number.isFinite(videoEvent.kind) || typeof videoEvent.pubkey !== "string") {
return null;
}
const value = `${videoEvent.kind}:${videoEvent.pubkey}:${dTag[1]}`;
return ["a", value];
}
function buildZapRequest({
wallet,
recipientPubkey,
videoEvent,
amountSats,
comment,
lnurl,
}) {
const tools = getNostrTools();
if (!tools?.getEventHash || !tools?.signEvent) {
throw new Error("NostrTools is required to sign zap requests.");
}
const tags = [];
if (recipientPubkey) {
tags.push(["p", recipientPubkey]);
}
if (videoEvent?.id) {
tags.push(["e", videoEvent.id]);
}
const pointerTag = derivePointerTag(videoEvent);
if (pointerTag) {
tags.push(pointerTag);
}
if (lnurl) {
tags.push(["lnurl", lnurl]);
}
if (Number.isFinite(amountSats)) {
tags.push(["amount", String(Math.max(0, Math.round(amountSats)) * 1000)]);
}
const event = {
kind: ZAP_KIND,
content: comment || "",
created_at: Math.floor(Date.now() / 1000),
tags,
pubkey: wallet?.clientPubkey || "",
};
event.id = tools.getEventHash(event);
event.sig = tools.signEvent(event, wallet?.secretKey);
return JSON.stringify(event);
}
function determineRecipientPubkey({ recipientType, metadata, videoEvent }) {
if (metadata?.nostrPubkey && HEX64_REGEX.test(metadata.nostrPubkey)) {
return metadata.nostrPubkey.toLowerCase();
}
if (recipientType === "creator" && typeof videoEvent?.pubkey === "string") {
return videoEvent.pubkey;
}
if (recipientType === "platform") {
return decodeAdminPubkey();
}
return null;
}
async function processShare({
recipientType,
address,
amountSats,
comment,
videoEvent,
wallet,
deps,
}) {
const resolved = deps.lnurl.resolveLightningAddress(address);
const metadata = await deps.lnurl.fetchPayServiceData(resolved.url);
const { amountMsats } = deps.lnurl.validateInvoiceAmount(metadata, amountSats);
let zapRequest = null;
if (metadata.allowsNostr === true || metadata.nostrPubkey) {
const recipientPubkey = determineRecipientPubkey({
recipientType,
metadata,
videoEvent,
});
if (recipientPubkey) {
zapRequest = buildZapRequest({
wallet,
recipientPubkey,
videoEvent,
amountSats,
comment,
lnurl: resolved.url,
});
}
}
const invoice = await deps.lnurl.requestInvoice(metadata, {
amountMsats,
comment,
zapRequest,
});
const payment = await deps.wallet.sendPayment(invoice.invoice, {
amountSats,
zapRequest,
});
return {
recipientType,
address,
amount: amountSats,
lnurl: resolved,
metadata,
invoice,
payment,
zapRequest,
};
}
export async function splitAndZap(
{ videoEvent, amountSats, comment = "", walletSettings } = {},
dependencies = {}
) {
if (!videoEvent || typeof videoEvent !== "object") {
throw new Error("A video event is required to zap.");
}
const amount = sanitizeAmount(amountSats);
const note = sanitizeComment(comment);
const deps = mergeDependencies(dependencies);
const wallet = await deps.wallet.ensureWallet({ settings: walletSettings });
const creatorAddress = sanitizeAddress(videoEvent.lightningAddress);
if (!creatorAddress) {
throw new Error("This creator has not configured a Lightning address yet.");
}
const platformFee = clampPercent(getOverridePlatformFee());
const platformShare = Math.floor((amount * platformFee) / 100);
const creatorShare = amount - platformShare;
let platformAddress = null;
if (platformShare > 0) {
platformAddress = sanitizeAddress(
await deps.platformAddress.getPlatformLightningAddress({ forceRefresh: false })
);
if (!platformAddress) {
throw new Error("Platform Lightning address is unavailable.");
}
}
const receipts = [];
if (creatorShare > 0) {
receipts.push(
await processShare({
recipientType: "creator",
address: creatorAddress,
amountSats: creatorShare,
comment: note,
videoEvent,
wallet,
deps,
})
);
}
if (platformShare > 0) {
receipts.push(
await processShare({
recipientType: "platform",
address: platformAddress,
amountSats: platformShare,
comment: note,
videoEvent,
wallet,
deps,
})
);
}
return {
totalAmount: amount,
creatorShare,
platformShare,
receipts,
};
}
export const __TESTING__ = Object.freeze({
buildZapRequest,
determineRecipientPubkey,
derivePointerTag,
mergeDependencies,
});
+269
View File
@@ -0,0 +1,269 @@
import assert from "node:assert/strict";
if (typeof globalThis.window === "undefined") {
globalThis.window = {};
}
if (typeof globalThis.localStorage === "undefined") {
const storage = new Map();
globalThis.localStorage = {
getItem(key) {
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
storage.set(String(key), String(value));
},
removeItem(key) {
storage.delete(String(key));
},
clear() {
storage.clear();
},
};
}
if (typeof window.localStorage === "undefined") {
window.localStorage = globalThis.localStorage;
}
const nostrToolsStub = {
getEventHash(event) {
const base = JSON.stringify(event);
let hash = 0;
for (let i = 0; i < base.length; i += 1) {
hash = (hash * 33 + base.charCodeAt(i)) % 0xffffffff;
}
return hash.toString(16).padStart(8, "0");
},
signEvent(event, secret) {
return `sig-${(secret || "").slice(0, 8)}-${event.kind}`;
},
nip19: {
decode(value) {
if (typeof value !== "string") {
throw new Error("Invalid nip19 input");
}
if (value.startsWith("npub")) {
return { type: "npub", data: "f".repeat(64) };
}
return null;
},
},
};
window.NostrTools = nostrToolsStub;
globalThis.NostrTools = nostrToolsStub;
globalThis.fetch = async () => {
throw new Error("Unexpected fetch call during zap split tests.");
};
const { splitAndZap } = await import("../js/payments/zapSplit.js");
function createDeps({
commentAllowed = 120,
platformAddress = "[email protected]",
minSendable = 1000,
maxSendable = 2_000_000,
allowsNostr = true,
} = {}) {
let ensureWalletCalls = 0;
const sendCalls = [];
return {
deps: {
lnurl: {
resolveLightningAddress(address) {
return {
type: address.includes("platform") ? "platform" : "creator",
url: `https://lnurl.test/${address}`,
address,
};
},
async fetchPayServiceData(url) {
const isPlatform = url.includes("platform");
return {
callback: `${url}/callback`,
minSendable,
maxSendable,
commentAllowed,
allowsNostr,
nostrPubkey: isPlatform ? "b".repeat(64) : "a".repeat(64),
raw: {},
};
},
validateInvoiceAmount(metadata, amount) {
if (amount * 1000 < metadata.minSendable) {
throw new Error("below-minimum");
}
if (amount * 1000 > metadata.maxSendable) {
throw new Error("above-maximum");
}
return { amountMsats: amount * 1000 };
},
async requestInvoice(metadata, { amountMsats, comment, zapRequest }) {
return {
invoice: `bolt11-${metadata.callback}-${amountMsats}-${comment}-${Boolean(
zapRequest
)}`,
raw: {},
};
},
},
wallet: {
async ensureWallet() {
ensureWalletCalls += 1;
return {
clientPubkey: "c".repeat(64),
secretKey: "1".repeat(64),
};
},
async sendPayment(invoice, { amountSats, zapRequest }) {
sendCalls.push({ invoice, amountSats, zapRequest });
return {
invoice,
amountSats,
zapRequest,
};
},
},
platformAddress: {
async getPlatformLightningAddress() {
return platformAddress;
},
},
},
getEnsureWalletCalls() {
return ensureWalletCalls;
},
getSendCalls() {
return sendCalls;
},
};
}
async function testSplitMath() {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 10;
const { deps, getSendCalls, getEnsureWalletCalls } = createDeps();
const videoEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]",
tags: [["d", "pointer"]],
kind: 30078,
};
const result = await splitAndZap(
{ videoEvent, amountSats: 1000, comment: "Great video!" },
deps
);
assert.equal(result.totalAmount, 1000);
assert.equal(result.creatorShare, 900);
assert.equal(result.platformShare, 100);
assert.equal(result.receipts.length, 2);
assert.equal(getEnsureWalletCalls(), 1);
const sendCalls = getSendCalls();
assert.equal(sendCalls.length, 2, "should send two payments");
assert.equal(sendCalls[0].amountSats, 900);
assert.equal(sendCalls[1].amountSats, 100);
const creatorReceipt = result.receipts[0];
assert.equal(creatorReceipt.recipientType, "creator");
assert(creatorReceipt.zapRequest, "creator zap request should be present");
const parsedZap = JSON.parse(creatorReceipt.zapRequest);
assert.equal(parsedZap.kind, 9734);
assert(parsedZap.tags.some((tag) => tag[0] === "amount" && tag[1] === "900000"));
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
}
async function testLnurlBounds() {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0;
const depsWrapper = createDeps({ minSendable: 5_000 });
const videoEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]",
kind: 30078,
tags: [],
};
await assert.rejects(
() => splitAndZap({ videoEvent, amountSats: 4 }, depsWrapper.deps),
/below-minimum/
);
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
}
async function testMissingAddress() {
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 15;
const depsWrapper = createDeps({ platformAddress: null });
const noCreatorEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "",
kind: 30078,
tags: [],
};
await assert.rejects(
() => splitAndZap({ videoEvent: noCreatorEvent, amountSats: 100 }, depsWrapper.deps),
/Lightning address/
);
const creatorEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]",
kind: 30078,
tags: [],
};
await assert.rejects(
() => splitAndZap({ videoEvent: creatorEvent, amountSats: 100 }, depsWrapper.deps),
/Platform Lightning address is unavailable/
);
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
}
async function testWalletFailure() {
const deps = {
lnurl: createDeps().deps.lnurl,
wallet: {
async ensureWallet() {
throw new Error("wallet offline");
},
async sendPayment() {
throw new Error("should not call sendPayment");
},
},
platformAddress: createDeps().deps.platformAddress,
};
const videoEvent = {
id: "event-id",
pubkey: "c".repeat(64),
lightningAddress: "[email protected]",
kind: 30078,
tags: [],
};
await assert.rejects(
() => splitAndZap({ videoEvent, amountSats: 100 }, deps),
/wallet offline/
);
}
await testSplitMath();
await testLnurlBounds();
await testMissingAddress();
await testWalletFailure();
console.log("zap-split tests passed");