mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +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,
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -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 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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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