mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
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:
5 files changed
+1603
No files matched your search
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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");
|
||||
Reference in new issue
Block a user