From 41e533e6750c55bcd5c35001c8c71e2107504ff4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:38:44 -0400 Subject: [PATCH] zaps: publish preimage-verified tallies on successful zap (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third slice of docs/zap-tally-plan.md §5.3-5.5 — the publish side: - zapSplit.js: surface receipt.preimage (NWC pay_invoice result.preimage). - zapController.js: onZapSuccess now forwards per-share proof {recipientType, amountSats, bolt11, preimage, zapRequest} for every settled share with a preimage. - zapTallyPublisher.js (new): buildTallyFromShare (pure — builds the addressable tally from a share's bolt11/preimage/zap-request, d=payment_hash, pointers from the zap request) + publishZapTallies (best-effort, flag-gated, never throws). - ModalManager.js: onZapSuccess keeps the instant optimistic bump AND publishes the tallies via nostrClient.signAndPublishEvent to write relays (an all-timeout publish is a soft success per #49). A publish failure is non-fatal — the durable local ledger still shows the sender their zap. With the counting slice (025ab348), a sent zap now: shows instantly (ledger), publishes a verified tally, and every bitvid client counts it — surviving the recipient never publishing a 9735. Tests: tests/zap-tally-publisher.test.mjs (5). Lint + build green. Co-Authored-By: Claude Fable 5 --- js/payments/zapSplit.js | 9 ++ js/payments/zapTallyPublisher.js | 87 +++++++++++++++++++ js/ui/ModalManager.js | 29 ++++++- js/ui/zapController.js | 21 +++++ tests/zap-tally-publisher.test.mjs | 129 +++++++++++++++++++++++++++++ 5 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 js/payments/zapTallyPublisher.js create mode 100644 tests/zap-tally-publisher.test.mjs diff --git a/js/payments/zapSplit.js b/js/payments/zapSplit.js index d6ce95fb..ee74a449 100644 --- a/js/payments/zapSplit.js +++ b/js/payments/zapSplit.js @@ -486,6 +486,15 @@ async function processShare({ 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", }; diff --git a/js/payments/zapTallyPublisher.js b/js/payments/zapTallyPublisher.js new file mode 100644 index 00000000..a996cfcd --- /dev/null +++ b/js/payments/zapTallyPublisher.js @@ -0,0 +1,87 @@ +// js/payments/zapTallyPublisher.js +// +// Publishes bitvid-native zap tallies (kind ZAP_TALLY_KIND) after a successful +// zap, so the zapped video/profile's total is global + durable even when the +// recipient's LNURL server never publishes a NIP-57 9735 receipt +// (docs/zap-tally-plan.md §5.4-5.5). Each settled share (creator + platform) +// carries its own invoice/preimage, so each becomes one payer-signed, +// preimage-verified tally. Best-effort: a publish failure never affects the +// payment UX (the sats already moved). + +import { buildZapTallyEvent } from "../nostrEventSchemas.js"; +import { extractBolt11Fields } from "./zapReceiptValidator.js"; +import { devLogger, userLogger } from "../utils/logger.js"; + +const tagFromReq = (req, name) => { + const tags = Array.isArray(req?.tags) ? req.tags : []; + const tag = tags.find((t) => Array.isArray(t) && t[0] === name && t[1] != null); + return tag ? String(tag[1]) : ""; +}; + +// Pure: build the unsigned tally event for one settled zap share, or null when +// it can't be made verifiable (no decodable payment_hash / missing proof). +export function buildTallyFromShare({ share, pubkey, now = () => Math.floor(Date.now() / 1000) } = {}) { + if (!share || typeof share !== "object" || !pubkey) { + return null; + } + const bolt11 = typeof share.bolt11 === "string" ? share.bolt11.trim() : ""; + const preimage = typeof share.preimage === "string" ? share.preimage.trim() : ""; + const zapRequestJson = + typeof share.zapRequest === "string" ? share.zapRequest.trim() : ""; + if (!bolt11 || !preimage || !zapRequestJson) { + return null; + } + + const { paymentHash } = extractBolt11Fields(bolt11); + if (!paymentHash) { + return null; // needs payment_hash for the `d` tag + verification + } + + let req = null; + try { + req = JSON.parse(zapRequestJson); + } catch (error) { + return null; + } + + const amountSats = Math.max(0, Math.round(Number(share.amountSats) || 0)); + + return buildZapTallyEvent({ + pubkey, + created_at: now(), + paymentHash, + recipientPubkey: tagFromReq(req, "p"), + eventId: tagFromReq(req, "e"), + coordinate: tagFromReq(req, "a"), + amountMsats: amountSats > 0 ? amountSats * 1000 : undefined, + bolt11, + preimage, + zapRequestJson, + }); +} + +// Publish a tally per settled share. `publish(event)` signs + broadcasts (best +// effort); `enabled` gates the whole thing (FEATURE_ZAP_TALLY). Never throws. +export async function publishZapTallies({ shares, pubkey, publish, enabled = true, now } = {}) { + if (!enabled || typeof publish !== "function" || !pubkey) { + return { published: 0 }; + } + const list = Array.isArray(shares) ? shares : []; + let published = 0; + for (const share of list) { + const event = buildTallyFromShare({ share, pubkey, now }); + if (!event) { + continue; + } + try { + await publish(event); + published += 1; + devLogger.log("[zapTally] Published tally for a settled share."); + } catch (error) { + // The payment already succeeded; a failed tally publish is non-fatal — + // the durable local ledger still shows the zap to the sender. + userLogger.warn("[zapTally] Failed to publish zap tally:", error); + } + } + return { published }; +} diff --git a/js/ui/ModalManager.js b/js/ui/ModalManager.js index 9104e420..8893a5db 100644 --- a/js/ui/ModalManager.js +++ b/js/ui/ModalManager.js @@ -14,6 +14,8 @@ import initDeleteModal from "./initDeleteModal.js"; import { EventDetailsModal } from "./components/EventDetailsModal.js"; import ZapController from "./zapController.js"; import { ingestLocalVideoZap } from "../zapTotals.js"; +import { publishZapTallies } from "../payments/zapTallyPublisher.js"; +import { FEATURE_ZAP_TALLY } from "../constants.js"; import { nostrClient } from "../nostrClientFacade.js"; export default class ModalManager { @@ -272,9 +274,12 @@ export default class ModalManager { callbacks: { onSuccess: (message) => app.showSuccess(message), onError: (message) => app.showError(message), - // Optimistically bump the zapped video's total using the SAME pointer - // the cards/modal badge subscribe with, so the badge updates instantly. - onZapSuccess: ({ video, sats } = {}) => { + // (1) Optimistically bump the zapped video's total using the SAME + // pointer the cards/modal badge subscribe with (instant), and + // (2) publish preimage-verified bitvid zap tallies so the total is + // global + durable even if the recipient never publishes a 9735 + // (docs/zap-tally-plan.md §5.5). Both best-effort. + onZapSuccess: ({ video, sats, shares } = {}) => { try { const info = video && typeof app.deriveVideoPointerInfo === "function" @@ -286,6 +291,24 @@ export default class ModalManager { } catch (error) { /* best-effort optimistic update */ } + if (FEATURE_ZAP_TALLY && Array.isArray(shares) && shares.length) { + publishZapTallies({ + shares, + pubkey: app.pubkey, + enabled: FEATURE_ZAP_TALLY, + publish: (event) => + nostrClient.signAndPublishEvent(event, { + context: "zap tally", + logName: "Zap tally", + devLogLabel: "zap tally", + rejectionLogLevel: "warn", + resolveActiveSigner: (p) => + nostrClient.signerManager.resolveActiveSigner(p), + }), + }).catch(() => { + /* non-fatal: the payment already succeeded */ + }); + } }, }, requestWalletPane: () => app.openWalletPane(), diff --git a/js/ui/zapController.js b/js/ui/zapController.js index 1d266692..f460ef09 100644 --- a/js/ui/zapController.js +++ b/js/ui/zapController.js @@ -384,11 +384,32 @@ export default class ZapController { // Optimistically bump the video's zap total by the amount sent (both the // creator and platform shares are tagged to the video, so the eventual // relay total equals context.shares.total). Badge updates instantly. + // Also forward the per-share proof (bolt11 + preimage + zap request) so + // the app can publish preimage-verified bitvid zap tallies + // (docs/zap-tally-plan.md §5.4). Only settled shares with a preimage. if (this.callbacks.onZapSuccess) { try { + const shares = receipts + .filter( + (r) => + r && + (r.status === "success" || !r.status) && + typeof r.preimage === "string" && + r.preimage, + ) + .map((r) => ({ + recipientType: r.recipientType || "creator", + amountSats: Math.max(0, Math.round(Number(r.amount) || 0)), + bolt11: + typeof r.invoice?.invoice === "string" ? r.invoice.invoice : "", + preimage: r.preimage, + zapRequest: typeof r.zapRequest === "string" ? r.zapRequest : "", + })) + .filter((s) => s.bolt11 && s.zapRequest && s.amountSats > 0); this.callbacks.onZapSuccess({ video: this.getCurrentVideo(), sats: Math.max(0, Math.round(Number(context?.shares?.total) || 0)), + shares, }); } catch (error) { userLogger.warn("[zap] onZapSuccess hook failed:", error); diff --git a/tests/zap-tally-publisher.test.mjs b/tests/zap-tally-publisher.test.mjs new file mode 100644 index 00000000..42b91e08 --- /dev/null +++ b/tests/zap-tally-publisher.test.mjs @@ -0,0 +1,129 @@ +// Publishing side of the bitvid zap tally (docs/zap-tally-plan.md §5.4-5.5): +// each settled zap share becomes one payer-signed tally, built from its own +// invoice/preimage/zap-request. Best-effort — a publish failure must not throw. +// +// test_integrity_note: +// change_type: ["new_tests"] +// scenarios: +// - id: SCN-zap-tally-publish +// given: "settled zap shares (with/without a decodable bolt11 or preimage) and an injected publisher" +// when: "buildTallyFromShare / publishZapTallies run" +// then: "a verifiable tally is built + published per settleable share; unpublishable shares are skipped; a publish throw is swallowed; disabled → nothing published" +// observable_outcomes: +// - "built event kind/tags (d=payment_hash, p/e/a, bolt11, preimage, description)" +// - "published count; publish() called once per settleable share" +// determinism_controls: +// - "in-test bech32 bolt11 vector; injected publish(); no network" +// anti_cheat_rationale: +// prevents: ["over-mocking internal logic", "hard-coded return value"] +// relaxation: +// did_relax_any_assertion: false + +import test from "node:test"; +import { strict as assert } from "node:assert"; +import { bech32 } from "../vendor/crypto-helpers.bundle.min.js"; +import { + buildTallyFromShare, + publishZapTallies, +} from "../js/payments/zapTallyPublisher.js"; +import { ZAP_TALLY_KIND } from "../js/nostrEventSchemas.js"; + +const PUB = "a".repeat(64); +const CREATOR = "b".repeat(64); +const EVENT_ID = "e".repeat(64); +const COORD = `30078:${CREATOR}:vid-1`; + +// Minimal valid bolt11 carrying a known payment_hash (bech32 'p' field = code 1). +const lenWords = (n) => [n >> 5, n & 31]; +function makeBolt11(paymentHashBytes) { + const pWords = bech32.toWords(paymentHashBytes); + const words = [ + 0, 0, 0, 0, 0, 0, 0, + 1, ...lenWords(pWords.length), ...pWords, + ...new Array(104).fill(0), + ]; + return bech32.encode("lnbc", words, 2000); +} +const BOLT11 = makeBolt11(new Uint8Array(32).fill(0x11)); + +const zapRequestJson = JSON.stringify({ + kind: 9734, + pubkey: PUB, + tags: [ + ["p", CREATOR], + ["e", EVENT_ID], + ["a", COORD], + ["amount", "2100000"], + ], + content: "", +}); + +const goodShare = { + recipientType: "creator", + amountSats: 2100, + bolt11: BOLT11, + preimage: "cd".repeat(32), + zapRequest: zapRequestJson, +}; + +test("buildTallyFromShare: builds an addressable, proof-carrying tally", () => { + const event = buildTallyFromShare({ share: goodShare, pubkey: PUB, now: () => 1000 }); + assert.ok(event); + assert.equal(event.kind, ZAP_TALLY_KIND); + const tag = (n) => event.tags.find((t) => t[0] === n); + assert.equal(tag("d")[1], "11".repeat(32), "d = payment_hash from the bolt11"); + assert.deepEqual(tag("p"), ["p", CREATOR]); + assert.deepEqual(tag("e"), ["e", EVENT_ID]); + assert.deepEqual(tag("a"), ["a", COORD]); + assert.equal(tag("bolt11")[1], BOLT11); + assert.equal(tag("preimage")[1], "cd".repeat(32)); + assert.ok(tag("description"), "embeds the zap request"); +}); + +test("buildTallyFromShare: skips shares that can't be made verifiable", () => { + assert.equal(buildTallyFromShare({ share: { ...goodShare, preimage: "" }, pubkey: PUB }), null); + assert.equal(buildTallyFromShare({ share: { ...goodShare, bolt11: "not-bolt11" }, pubkey: PUB }), null, "undecodable bolt11 → no payment_hash"); + assert.equal(buildTallyFromShare({ share: { ...goodShare, zapRequest: "" }, pubkey: PUB }), null); + assert.equal(buildTallyFromShare({ share: goodShare, pubkey: "" }), null); +}); + +test("publishZapTallies: one publish per settleable share; unpublishable skipped", async () => { + const published = []; + const result = await publishZapTallies({ + shares: [goodShare, { ...goodShare, preimage: "" }, { ...goodShare, amountSats: 42 }], + pubkey: PUB, + enabled: true, + publish: async (event) => { + published.push(event); + }, + }); + assert.equal(result.published, 2, "two settleable shares published, the no-preimage one skipped"); + assert.equal(published.length, 2); + assert.ok(published.every((e) => e.kind === ZAP_TALLY_KIND)); +}); + +test("publishZapTallies: a publish failure is swallowed (payment already settled)", async () => { + const result = await publishZapTallies({ + shares: [goodShare], + pubkey: PUB, + enabled: true, + publish: async () => { + throw new Error("relay down"); + }, + }); + assert.equal(result.published, 0, "no throw; count reflects the failure"); +}); + +test("publishZapTallies: disabled → nothing published", async () => { + let calls = 0; + const result = await publishZapTallies({ + shares: [goodShare], + pubkey: PUB, + enabled: false, + publish: async () => { + calls += 1; + }, + }); + assert.equal(result.published, 0); + assert.equal(calls, 0); +});