mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Moves the repeatedly defined HEX64_REGEX constant to a shared utility module to improve maintainability and consistency. Updates all consumers to import from the new location. Signed-off-by: Jules <[email protected]> Co-authored-by: PR0M3TH3AN <[email protected]>
602 lines
15 KiB
JavaScript
602 lines
15 KiB
JavaScript
// js/payments/zapSplit.js
|
|
|
|
import { ADMIN_SUPER_NPUB } from "../config.js";
|
|
import { nostrToolsReady } from "../nostrToolsBootstrap.js";
|
|
import {
|
|
resolveLightningAddress,
|
|
fetchPayServiceData,
|
|
validateInvoiceAmount,
|
|
requestInvoice,
|
|
encodeLnurlBech32,
|
|
} from "./lnurl.js";
|
|
import { ensureWallet, sendPayment } from "./nwcClient.js";
|
|
import { getPlatformLightningAddress } from "./platformAddress.js";
|
|
import { RELAY_URLS } from "../nostr/toolkit.js";
|
|
import { signEventWithPrivateKey } from "../nostr/publishHelpers.js";
|
|
import { userLogger } from "../utils/logger.js";
|
|
import { validateZapReceipt } from "./zapReceiptValidator.js";
|
|
import {
|
|
clampPercent,
|
|
parsePercentValue,
|
|
resolvePlatformFeePercent,
|
|
} from "./platformFee.js";
|
|
import { HEX64_REGEX } from "../utils/hex.js";
|
|
|
|
|
|
const ZAP_KIND = 9734;
|
|
const DEFAULT_DEPS = Object.freeze({
|
|
lnurl: Object.freeze({
|
|
resolveLightningAddress,
|
|
fetchPayServiceData,
|
|
validateInvoiceAmount,
|
|
requestInvoice,
|
|
encodeLnurlBech32,
|
|
}),
|
|
wallet: Object.freeze({ ensureWallet, sendPayment }),
|
|
platformAddress: Object.freeze({ getPlatformLightningAddress }),
|
|
validator: Object.freeze({ validateZapReceipt }),
|
|
});
|
|
|
|
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 || {}),
|
|
};
|
|
|
|
const validatorDeps = {
|
|
...DEFAULT_DEPS.validator,
|
|
...(overrides.validator || {}),
|
|
};
|
|
|
|
return {
|
|
lnurl: lnurlDeps,
|
|
wallet: walletDeps,
|
|
platformAddress: platformDeps,
|
|
validator: validatorDeps,
|
|
};
|
|
}
|
|
|
|
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 resolveRelayUrls(wallet) {
|
|
const normalized = [];
|
|
const seen = new Set();
|
|
|
|
const addCandidate = (candidate) => {
|
|
if (!candidate) {
|
|
return;
|
|
}
|
|
if (Array.isArray(candidate)) {
|
|
candidate.forEach(addCandidate);
|
|
return;
|
|
}
|
|
if (typeof candidate === "string") {
|
|
const trimmed = candidate.trim();
|
|
if (trimmed && !seen.has(trimmed)) {
|
|
seen.add(trimmed);
|
|
normalized.push(trimmed);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (wallet && typeof wallet === "object") {
|
|
addCandidate(wallet.relayUrls);
|
|
addCandidate(wallet.relays);
|
|
addCandidate(wallet.relayUrl);
|
|
}
|
|
|
|
if (!normalized.length) {
|
|
addCandidate(Array.isArray(RELAY_URLS) ? RELAY_URLS : []);
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function resolveBech32Lnurl(resolved, encodeFn) {
|
|
const candidate = typeof resolved?.address === "string" ? resolved.address.trim() : "";
|
|
if (candidate && candidate.toLowerCase().startsWith("lnurl")) {
|
|
return candidate.toLowerCase();
|
|
}
|
|
|
|
const callbackUrl = typeof resolved?.url === "string" ? resolved.url.trim() : "";
|
|
if (!callbackUrl) {
|
|
return "";
|
|
}
|
|
|
|
if (typeof encodeFn !== "function") {
|
|
throw new Error("LNURL encoder is unavailable.");
|
|
}
|
|
|
|
return encodeFn(callbackUrl);
|
|
}
|
|
|
|
function buildZapRequest({
|
|
wallet,
|
|
recipientPubkey,
|
|
videoEvent,
|
|
amountSats,
|
|
comment,
|
|
lnurl,
|
|
}) {
|
|
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 (typeof lnurl === "string") {
|
|
const normalizedLnurl = lnurl.trim();
|
|
if (normalizedLnurl) {
|
|
tags.push(["lnurl", normalizedLnurl]);
|
|
}
|
|
}
|
|
if (Number.isFinite(amountSats)) {
|
|
tags.push(["amount", String(Math.max(0, Math.round(amountSats)) * 1000)]);
|
|
}
|
|
|
|
const relayUrls = resolveRelayUrls(wallet);
|
|
if (relayUrls.length) {
|
|
tags.push(["relays", ...relayUrls]);
|
|
}
|
|
|
|
const event = {
|
|
kind: ZAP_KIND,
|
|
content: comment || "",
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags,
|
|
pubkey: wallet?.clientPubkey || "",
|
|
};
|
|
|
|
const signedEvent = signEventWithPrivateKey(event, wallet?.secretKey);
|
|
return JSON.stringify(signedEvent);
|
|
}
|
|
|
|
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);
|
|
|
|
const lnurlTag = resolveBech32Lnurl(resolved, deps.lnurl.encodeLnurlBech32);
|
|
if (!lnurlTag) {
|
|
throw new Error("Unable to resolve LNURL for zap request.");
|
|
}
|
|
|
|
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: lnurlTag,
|
|
});
|
|
}
|
|
}
|
|
|
|
const invoice = await deps.lnurl.requestInvoice(metadata, {
|
|
amountMsats,
|
|
comment,
|
|
zapRequest,
|
|
lnurl: lnurlTag,
|
|
});
|
|
|
|
try {
|
|
const payment = await deps.wallet.sendPayment(invoice.invoice, {
|
|
amountSats,
|
|
zapRequest,
|
|
lnurl: lnurlTag,
|
|
});
|
|
|
|
let validation = null;
|
|
if (typeof deps.validator.validateZapReceipt === "function") {
|
|
try {
|
|
validation = await deps.validator.validateZapReceipt(
|
|
{
|
|
zapRequest,
|
|
amountSats,
|
|
metadata,
|
|
invoice,
|
|
payment,
|
|
lnurl: resolved,
|
|
recipientType,
|
|
address,
|
|
}
|
|
);
|
|
} catch (validationError) {
|
|
userLogger.warn(
|
|
`[zapSplit] Zap receipt validation threw for ${recipientType} share.`,
|
|
validationError
|
|
);
|
|
validation = {
|
|
status: "failed",
|
|
reason: "Zap receipt validation encountered an unexpected error.",
|
|
event: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
const receipt = {
|
|
recipientType,
|
|
address,
|
|
amount: amountSats,
|
|
lnurl: resolved,
|
|
metadata,
|
|
invoice,
|
|
payment,
|
|
zapRequest,
|
|
status: "success",
|
|
};
|
|
|
|
if (validation && typeof validation === "object") {
|
|
receipt.validation = validation;
|
|
if (validation.status === "passed" && validation.event) {
|
|
receipt.validatedEvent = validation.event;
|
|
} else if (validation.status === "failed") {
|
|
receipt.validationFailed = true;
|
|
}
|
|
}
|
|
|
|
return receipt;
|
|
} catch (error) {
|
|
userLogger.warn(
|
|
`[zapSplit] Failed to send ${recipientType} zap share.`,
|
|
error
|
|
);
|
|
return {
|
|
recipientType,
|
|
address,
|
|
amount: amountSats,
|
|
lnurl: resolved,
|
|
metadata,
|
|
invoice,
|
|
payment: null,
|
|
zapRequest,
|
|
status: "error",
|
|
error,
|
|
};
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|