Files
bitvid/js/payments/zapSplit.js
T
thePR0M3TH3ANandClaude Opus 4.8 96228a451b zaps: fix zap totals counting zero — coordinate used kind 0 instead of 30078
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]>
2026-07-05 09:18:38 -04:00

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,
});