diff --git a/js/payments/zapSplit.js b/js/payments/zapSplit.js index ee74a449..be755b42 100644 --- a/js/payments/zapSplit.js +++ b/js/payments/zapSplit.js @@ -21,6 +21,7 @@ import { resolvePlatformFeePercent, } from "./platformFee.js"; import { HEX64_REGEX } from "../utils/hex.js"; +import { buildVideoAddressPointer } from "../utils/videoPointer.js"; const ZAP_KIND = 9734; @@ -271,21 +272,16 @@ function decodeAdminPubkey() { } 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]; + // 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) { diff --git a/tests/zap-split.test.mjs b/tests/zap-split.test.mjs index 31c5931f..fe00a42a 100644 --- a/tests/zap-split.test.mjs +++ b/tests/zap-split.test.mjs @@ -334,6 +334,53 @@ async function testSplitMath() { delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; } +// Regression: bitvid's internal video objects don't always carry the Nostr +// `kind` — it can be 0 or undefined. The zap request's `a` (coordinate) tag +// MUST still address the video as kind 30078, because the read side +// (zapTotals / mostZappedFeed) fetches totals by querying `30078:pubkey:d`. +// A kind-0 coordinate (`0:pubkey:d`) publishes fine but can never be matched, +// so zaps silently count as zero. This asserts the coordinate defaults to +// 30078 regardless of the source object's kind. +async function testZapPointerDefaultsToVideoKindWhenMissing() { + const previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0; + + const { deps } = createDeps(); + const callbackUrl = "https://lnurl.example/api/callback"; + const bech32Address = encodeLnurlBech32(callbackUrl); + const pubkey = "d".repeat(64); + + for (const brokenKind of [0, undefined, "not-a-number"]) { + const videoEvent = { + id: "event-id", + pubkey, + lightningAddress: bech32Address, + tags: [["d", "1782939097186-elrw1x3fdvg"]], + kind: brokenKind, + }; + + const result = await splitAndZap( + { videoEvent, amountSats: 500, comment: "PointerKind" }, + deps + ); + + const parsedZap = JSON.parse(result.receipts[0].zapRequest); + const aTag = parsedZap.tags.find((tag) => Array.isArray(tag) && tag[0] === "a"); + assert(aTag, `zap request should carry an 'a' coordinate (kind=${brokenKind})`); + assert.equal( + aTag[1], + `30078:${pubkey}:1782939097186-elrw1x3fdvg`, + `coordinate must address the video as kind 30078, not '${brokenKind}'` + ); + } + + if (previousOverride === undefined) { + delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; + } else { + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride; + } +} + async function testBech32LightningAddressZapTag() { const previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 0; @@ -1046,6 +1093,7 @@ async function testSplitRoundingPreservesEverySat() { await testCreatorEqualsPlatformCollapsesToOnePayment(); await testSplitRoundingPreservesEverySat(); await testSplitMath(); +await testZapPointerDefaultsToVideoKindWhenMissing(); await testBech32LightningAddressZapTag(); await testUrlLightningAddressZapTag(); await testRequestInvoiceIncludesLnurlParam();