mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 18:02:27 +00:00
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:
1 parent
025ab3487d
commit
41e533e675
5 files changed
+272
-3
No files matched your search
@@ -486,6 +486,15 @@ async function processShare({
|
|||||||
metadata,
|
metadata,
|
||||||
invoice,
|
invoice,
|
||||||
payment,
|
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,
|
zapRequest,
|
||||||
status: "success",
|
status: "success",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -14,6 +14,8 @@ import initDeleteModal from "./initDeleteModal.js";
|
|||||||
import { EventDetailsModal } from "./components/EventDetailsModal.js";
|
import { EventDetailsModal } from "./components/EventDetailsModal.js";
|
||||||
import ZapController from "./zapController.js";
|
import ZapController from "./zapController.js";
|
||||||
import { ingestLocalVideoZap } from "../zapTotals.js";
|
import { ingestLocalVideoZap } from "../zapTotals.js";
|
||||||
|
import { publishZapTallies } from "../payments/zapTallyPublisher.js";
|
||||||
|
import { FEATURE_ZAP_TALLY } from "../constants.js";
|
||||||
import { nostrClient } from "../nostrClientFacade.js";
|
import { nostrClient } from "../nostrClientFacade.js";
|
||||||
|
|
||||||
export default class ModalManager {
|
export default class ModalManager {
|
||||||
@@ -272,9 +274,12 @@ export default class ModalManager {
|
|||||||
callbacks: {
|
callbacks: {
|
||||||
onSuccess: (message) => app.showSuccess(message),
|
onSuccess: (message) => app.showSuccess(message),
|
||||||
onError: (message) => app.showError(message),
|
onError: (message) => app.showError(message),
|
||||||
// Optimistically bump the zapped video's total using the SAME pointer
|
// (1) Optimistically bump the zapped video's total using the SAME
|
||||||
// the cards/modal badge subscribe with, so the badge updates instantly.
|
// pointer the cards/modal badge subscribe with (instant), and
|
||||||
onZapSuccess: ({ video, sats } = {}) => {
|
// (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 {
|
try {
|
||||||
const info =
|
const info =
|
||||||
video && typeof app.deriveVideoPointerInfo === "function"
|
video && typeof app.deriveVideoPointerInfo === "function"
|
||||||
@@ -286,6 +291,24 @@ export default class ModalManager {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
/* best-effort optimistic update */
|
/* 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(),
|
requestWalletPane: () => app.openWalletPane(),
|
||||||
|
|||||||
@@ -384,11 +384,32 @@ export default class ZapController {
|
|||||||
// Optimistically bump the video's zap total by the amount sent (both the
|
// 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
|
// creator and platform shares are tagged to the video, so the eventual
|
||||||
// relay total equals context.shares.total). Badge updates instantly.
|
// 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) {
|
if (this.callbacks.onZapSuccess) {
|
||||||
try {
|
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({
|
this.callbacks.onZapSuccess({
|
||||||
video: this.getCurrentVideo(),
|
video: this.getCurrentVideo(),
|
||||||
sats: Math.max(0, Math.round(Number(context?.shares?.total) || 0)),
|
sats: Math.max(0, Math.round(Number(context?.shares?.total) || 0)),
|
||||||
|
shares,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
userLogger.warn("[zap] onZapSuccess hook failed:", error);
|
userLogger.warn("[zap] onZapSuccess hook failed:", error);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user