mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
The zap write path (zapSplit.js derivePointerTag) built the `a` coordinate tag straight from `videoEvent.kind`. bitvid's internal video objects don't always carry the Nostr kind — it can be 0 or undefined — and the inline builder only rejected non-finite kinds, so a kind-0 object produced a `0:pubkey:d` coordinate (Number.isFinite(0) === true). This flowed into both the kind-9734 zap request and the kind-30081 bitvid tally. The read side (zapTotals / mostZappedFeed) fetches totals by querying `30078:pubkey:d` via buildVideoAddressPointer, which defaults invalid/zero kinds to 30078. So the writer emitted `0:...` while the reader queried `30078:...` — they never matched and every zap counted as zero, even after a cache clear + refresh. Confirmed against live relays: 4 published bitvid tallies all carried `0:...` coordinates while the real video events are kind 30078. Fix: derivePointerTag now delegates to the same canonical buildVideoAddressPointer the reader uses, keeping writer and reader coordinates in lockstep. Adds a regression test driving splitAndZap with kind 0 / undefined / non-numeric video objects, asserting the zap request's `a` tag defaults to `30078:pubkey:d`. Co-Authored-By: Claude Opus 4.8 <[email protected]>
619 lines
16 KiB
JavaScript
619 lines
16 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";
|
|
import { buildVideoAddressPointer } from "../utils/videoPointer.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) {
|
|
// Delegate to the app's canonical pointer derivation — the SAME one the read
|
|
// side (zapTotals / mostZappedFeed) uses to fetch totals. bitvid's internal
|
|
// video objects don't always carry the Nostr `kind` (it can be 0/undefined),
|
|
// and the old inline builder trusted `videoEvent.kind` verbatim: a kind-0
|
|
// object produced a `0:pubkey:d` coordinate (Number.isFinite(0) === true),
|
|
// which the reader — querying `30078:pubkey:d` — could never match, so zaps
|
|
// silently counted as zero. buildVideoAddressPointer defaults invalid/zero
|
|
// kinds to 30078, keeping writer and reader coordinates in lockstep.
|
|
const value = buildVideoAddressPointer(videoEvent);
|
|
return value ? ["a", value] : null;
|
|
}
|
|
|
|
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,
|
|
// Proof of settlement from NWC pay_invoice (result.preimage), surfaced so
|
|
// the caller can publish a preimage-verified bitvid zap tally
|
|
// (docs/zap-tally-plan.md §5.3). null when the wallet returns none.
|
|
preimage:
|
|
(payment && typeof payment === "object" && payment.result &&
|
|
typeof payment.result.preimage === "string" &&
|
|
payment.result.preimage.trim())
|
|
? payment.result.preimage.trim()
|
|
: null,
|
|
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());
|
|
let platformShare = Math.floor((amount * platformFee) / 100);
|
|
let 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.");
|
|
}
|
|
|
|
// When the platform fee would be paid to the very same Lightning address as
|
|
// the creator (e.g. the creator is the platform operator), splitting just
|
|
// sends two invoices to one wallet — extra routing fees and a confusing
|
|
// duplicate receipt for the same total. Collapse to a single full-amount
|
|
// payment to the creator; the recipient is unchanged and ends up with the
|
|
// same sats.
|
|
if (platformAddress.toLowerCase() === creatorAddress.toLowerCase()) {
|
|
creatorShare = amount;
|
|
platformShare = 0;
|
|
platformAddress = null;
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|