mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
466 lines
11 KiB
JavaScript
466 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 { userLogger } from "../utils/logger.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) {
|
|
userLogger.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) {
|
|
userLogger.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,
|
|
});
|