Files
bitvid/js/payments/zapSplit.js
T

465 lines
11 KiB
JavaScript

// js/payments/zapSplit.js
import { ADMIN_SUPER_NPUB } from "../config.js";
import { nostrToolsReady } from "../nostrToolsBootstrap.js";
import {
resolveLightningAddress,
fetchPayServiceData,
validateInvoiceAmount,
requestInvoice,
} from "./lnurl.js";
import { ensureWallet, sendPayment } from "./nwcClient.js";
import { getPlatformLightningAddress } from "./platformAddress.js";
import {
clampPercent,
parsePercentValue,
resolvePlatformFeePercent,
} from "./platformFee.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 }),
});
const globalScope =
typeof window !== "undefined"
? window
: typeof globalThis !== "undefined"
? globalThis
: null;
const nostrToolsReadySource =
globalScope &&
globalScope.nostrToolsReady &&
typeof globalScope.nostrToolsReady.then === "function"
? globalScope.nostrToolsReady
: nostrToolsReady;
function normalizeToolkitCandidate(candidate) {
if (
candidate &&
typeof candidate === "object" &&
candidate.ok !== false &&
typeof candidate.then !== "function"
) {
return candidate;
}
return null;
}
function readToolkitFromScope(scope = globalScope) {
if (!scope || typeof scope !== "object") {
return null;
}
const candidates = [];
const canonical = scope.__BITVID_CANONICAL_NOSTR_TOOLS__;
if (canonical) {
candidates.push(canonical);
}
const direct = scope.NostrTools;
if (direct) {
candidates.push(direct);
}
const nestedWindow =
scope.window && scope.window !== scope && typeof scope.window === "object"
? scope.window
: null;
if (nestedWindow) {
if (nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__) {
candidates.push(nestedWindow.__BITVID_CANONICAL_NOSTR_TOOLS__);
}
if (nestedWindow.NostrTools) {
candidates.push(nestedWindow.NostrTools);
}
}
for (const candidate of candidates) {
const normalized = normalizeToolkitCandidate(candidate);
if (normalized) {
return normalized;
}
}
return null;
}
const __zapNostrToolsBootstrap = await (async () => {
try {
const result = await nostrToolsReadySource;
if (result && typeof result === "object" && result.ok === false) {
return {
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
failure: result,
};
}
const normalized = normalizeToolkitCandidate(result);
if (normalized) {
return { toolkit: normalized, failure: null };
}
return {
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
failure: null,
};
} catch (error) {
return {
toolkit: normalizeToolkitCandidate(readToolkitFromScope()),
failure: error,
};
}
})();
let cachedNostrTools = __zapNostrToolsBootstrap.toolkit || null;
const nostrToolsInitializationFailure = __zapNostrToolsBootstrap.failure || null;
if (!cachedNostrTools && nostrToolsInitializationFailure) {
console.warn(
"[zapSplit] nostr-tools helpers unavailable after bootstrap.",
nostrToolsInitializationFailure
);
}
function rememberNostrTools(candidate) {
const normalized = normalizeToolkitCandidate(candidate);
if (normalized) {
cachedNostrTools = normalized;
}
}
function getCachedNostrTools() {
const fallback = readToolkitFromScope();
if (cachedNostrTools && fallback && fallback !== cachedNostrTools) {
rememberNostrTools(fallback);
} else if (!cachedNostrTools && fallback) {
rememberNostrTools(fallback);
}
return cachedNostrTools || fallback || null;
}
async function ensureNostrTools() {
if (cachedNostrTools) {
return cachedNostrTools;
}
try {
const result = await nostrToolsReadySource;
rememberNostrTools(result);
} catch (error) {
console.warn("[zapSplit] Failed to resolve nostr-tools helpers.", error);
}
if (!cachedNostrTools) {
rememberNostrTools(readToolkitFromScope());
}
return cachedNostrTools || null;
}
function getOverridePlatformFee() {
return resolvePlatformFeePercent(globalThis?.__BITVID_PLATFORM_FEE_OVERRIDE__);
}
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() {
const tools = getCachedNostrTools();
if (tools?.nip04) {
return tools;
}
const scope = globalScope || (typeof window !== "undefined" ? window : globalThis);
const canonical = scope?.__BITVID_CANONICAL_NOSTR_TOOLS__ || null;
if (canonical && tools && !tools.nip04 && canonical.nip04) {
try {
tools.nip04 = canonical.nip04;
return tools;
} catch (error) {
return { ...canonical, ...tools, nip04: canonical.nip04 };
}
}
return tools || canonical || 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,
parsePercentValue,
clampPercent,
});