zaps: publish preimage-verified tallies on successful zap (#47)

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-04 23:38:44 -04:00
1 parent 025ab3487d
commit 41e533e675
5 files changed
+272 -3

No files matched your search

+9
View File
@@ -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",
};
+87
View File
@@ -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 };
}
+26 -3
View File
@@ -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(),
+21
View File
@@ -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);
+129
View File
@@ -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);
});