mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 02:42:28 +00:00
zaps: foundation for the bitvid-native zap tally (#47) — schema + verify
First slice of docs/zap-tally-plan.md (decisions D1-D5 resolved in the doc): - ZAP_TALLY_KIND = 30081 (addressable, d=payment_hash → one canonical event per real payment) + NOTE_TYPES.ZAP_TALLY schema + buildZapTallyEvent (nostrEventSchemas.js). Video + profile pointers. - FEATURE_ZAP_TALLY flag (constants.js), default ON per D4. - zapReceiptValidator.js: extractBolt11Fields (payment_hash + description_hash from the existing bech32 walker), verifyPaymentPreimage (sha256(preimage)==payment_hash), verifyBitvidZapTally — the security core: proves the invoice was paid AND bound to this zap request (description-hash binding blocks reusing a leaked preimage for another video). Amount is bolt11-derived, never the tag; pointers come from the embedded zap request. - Schema-doc row for kind 30081. Tests: tests/zap-tally-verify.test.mjs (8) — preimage check, bech32 field round-trip, valid/bad-preimage/replay/zero-sats decisions, builder tag shape (video + profile). Lint + build green. No publishing or counting wired yet — next slices per the plan checklist. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
ce4904f1a4
commit
a6b2996f4e
6 files changed
+542
-16
No files matched your search
@@ -196,6 +196,7 @@ Video posts should treat `videoRootId` as the stable series identifier that rema
|
|||||||
| Legacy Direct Message (`NOTE_TYPES.LEGACY_DM`) | `4` | `['p', <recipient pubkey>]` | NIP-04 encrypted ciphertext |
|
| Legacy Direct Message (`NOTE_TYPES.LEGACY_DM`) | `4` | `['p', <recipient pubkey>]` | NIP-04 encrypted ciphertext |
|
||||||
| Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', <recipient pubkey>]` plus optional `['e', <event id>]`, `['a', <coordinate>]`, `['amount', <msats>]`, `['lnurl', <bech32>]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note |
|
| Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', <recipient pubkey>]` plus optional `['e', <event id>]`, `['a', <coordinate>]`, `['amount', <msats>]`, `['lnurl', <bech32>]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note |
|
||||||
| Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', <invoice>]`, `['description', <zap request JSON>]`, `['p', <recipient pubkey>]` plus optional `['e', <event id>]` and `['a', <coordinate>]` | Empty content; receipts are published by the recipient's LNURL server |
|
| Zap receipt (`NOTE_TYPES.ZAP_RECEIPT`) | `9735` | `['bolt11', <invoice>]`, `['description', <zap request JSON>]`, `['p', <recipient pubkey>]` plus optional `['e', <event id>]` and `['a', <coordinate>]` | Empty content; receipts are published by the recipient's LNURL server |
|
||||||
|
| Zap tally (`NOTE_TYPES.ZAP_TALLY`) | `30081` (addressable, `d = payment_hash`) | `['d', <payment_hash>]`, `['p', <recipient>]`, optional `['e', <event id>]`/`['a', <coordinate>]`, `['amount', <msats>]`, `['bolt11', <invoice>]`, `['preimage', <hex>]`, `['description', <zap request JSON>]`, `['client', 'bitvid']`, `['t', 'zap']` | bitvid-native, payer-signed, **preimage-verified** stand-in for a 9735 when the recipient's wallet doesn't publish one. Verified via `sha256(preimage)==payment_hash` + `description_hash==sha256(zapRequest)`. Counted alongside 9735s (deduped by `payment_hash`). See `docs/zap-tally-plan.md`. |
|
||||||
| HTTP Auth (`NOTE_TYPES.HTTP_AUTH`) | `27235` | `['u', <url>]`, `['method', <http method>]`, `['payload', <payload hash>]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) |
|
| HTTP Auth (`NOTE_TYPES.HTTP_AUTH`) | `27235` | `['u', <url>]`, `['method', <http method>]`, `['payload', <payload hash>]` | Optional plaintext content (NIP-98 recommends empty, but implementations may vary) |
|
||||||
| Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', <event id>, <report type>]`, `['p', <pubkey>, <report type>]`, and optional `['t', <report type>]` | Plaintext report reason |
|
| Report (`NOTE_TYPES.REPORT`) | `1984` | `['e', <event id>, <report type>]`, `['p', <pubkey>, <report type>]`, and optional `['t', <report type>]` | Plaintext report reason |
|
||||||
|
|
||||||
|
|||||||
+21
-16
@@ -426,23 +426,28 @@ tallies simply stop being counted. No data migration.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Open decisions (need a call before/at build time)
|
## 10. Decisions (RESOLVED 2026-07-04)
|
||||||
|
|
||||||
- **D1 — event kind.** Proposed `9736` (adjacent to the 9735 receipt, "bitvid zap
|
- **D1 — event kind → ADDRESSABLE `ZAP_TALLY_KIND = 30081`, `d = payment_hash`.**
|
||||||
tally"). Must confirm it's unassigned in the NIP kind registry; if risky, pick a
|
Chosen: a higher, app-specific kind in bitvid's own family (30078 video,
|
||||||
clearly app-specific regular kind and reserve it in `docs/nostr-event-schemas.md`.
|
30079 view, **30081** zap tally). Addressable (30000–39999) with the
|
||||||
Everything references `ZAP_TALLY_KIND` so the number is a one-line change.
|
per-payment `payment_hash` as the `d` tag makes each real payment map to
|
||||||
- **D2 — profile zaps in v1?** Video path is the ask; profile path is the same
|
exactly ONE canonical event — relay-level idempotency; a re-publish replaces
|
||||||
primitives + a channel-page badge. Ship video-only first, profile as a
|
rather than duplicates. Still queried by its `#a`/`#e`/`#p` tags like a 9735.
|
||||||
follow-up? (Recommended: yes, video-only v1.)
|
Reserve the kind in `docs/nostr-event-schemas.md`.
|
||||||
- **D3 — publish relays.** Publish the tally to the user's write relays (same as
|
- **D2 — scope → VIDEO + PROFILE zaps in v1.** Video path drives the badges/Most
|
||||||
video publishing) so bitvid clients querying those relays see it. Confirm the
|
Zapped; profile path reuses the same primitives with a `p`-only pointer and a
|
||||||
store queries a superset (it queries `client.relays`). Likely fine; verify.
|
channel-page total (new small UI surface). Both ship in v1.
|
||||||
- **D4 — privacy default.** Default the feature flag off and add one line of UI
|
- **D3 — publish relays → user's WRITE relays** (same path as video publishing).
|
||||||
copy ("your zap will be publicly attributed on bitvid") before default-on?
|
The store queries `client.relays` (a superset incl. write relays); verify at
|
||||||
- **D5 — chart Y-axis (§5.9).** Independent per-series scaling (each cumulative
|
wiring time.
|
||||||
line filling the height — simplest, trend-focused, **recommended**) vs. a dual
|
- **D4 — publishing → ON BY DEFAULT.** `FEATURE_ZAP_TALLY` still exists for
|
||||||
labeled Y-axis (left=views, right=sats — more precise, busier in a small modal).
|
rollback but defaults **true**; every successful zap publishes a tally. Keep a
|
||||||
|
short one-line disclosure in the zap UI ("zaps are publicly attributed on
|
||||||
|
bitvid") since it's honest and cheap, but no opt-in gate.
|
||||||
|
- **D5 — chart Y-axis → INDEPENDENT PER-SERIES scaling.** Each cumulative line
|
||||||
|
fills the height against its own max; the legend names the two units. No dual
|
||||||
|
labeled Y-axis.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ const DEFAULT_FLAGS = Object.freeze({
|
|||||||
FEATURE_IMPROVED_COMMENT_FETCHING: true, // Default true for comment persistence fixes
|
FEATURE_IMPROVED_COMMENT_FETCHING: true, // Default true for comment persistence fixes
|
||||||
FEATURE_TRENDING_FEED: true, // "Trending" tab: recently-added ranked by view count. Disable to hide the tab.
|
FEATURE_TRENDING_FEED: true, // "Trending" tab: recently-added ranked by view count. Disable to hide the tab.
|
||||||
FEATURE_MOST_ZAPPED_FEED: true, // "Most Zapped" tab: recently-added ranked by zap total. Disable to hide the tab.
|
FEATURE_MOST_ZAPPED_FEED: true, // "Most Zapped" tab: recently-added ranked by zap total. Disable to hide the tab.
|
||||||
|
FEATURE_ZAP_TALLY: true, // bitvid-native preimage-verified zap tally (publish + count). Rollback: set false.
|
||||||
TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||||
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||||
WSS_TRACKERS: DEFAULT_WSS_TRACKERS,
|
WSS_TRACKERS: DEFAULT_WSS_TRACKERS,
|
||||||
@@ -224,6 +225,7 @@ const runtimeFlags = (() => {
|
|||||||
DEFAULT_FLAGS.FEATURE_IMPROVED_COMMENT_FETCHING,
|
DEFAULT_FLAGS.FEATURE_IMPROVED_COMMENT_FETCHING,
|
||||||
FEATURE_TRENDING_FEED: DEFAULT_FLAGS.FEATURE_TRENDING_FEED,
|
FEATURE_TRENDING_FEED: DEFAULT_FLAGS.FEATURE_TRENDING_FEED,
|
||||||
FEATURE_MOST_ZAPPED_FEED: DEFAULT_FLAGS.FEATURE_MOST_ZAPPED_FEED,
|
FEATURE_MOST_ZAPPED_FEED: DEFAULT_FLAGS.FEATURE_MOST_ZAPPED_FEED,
|
||||||
|
FEATURE_ZAP_TALLY: DEFAULT_FLAGS.FEATURE_ZAP_TALLY,
|
||||||
TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD,
|
TRUSTED_MUTE_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_MUTE_HIDE_THRESHOLD,
|
||||||
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD,
|
TRUSTED_SPAM_HIDE_THRESHOLD: DEFAULT_FLAGS.TRUSTED_SPAM_HIDE_THRESHOLD,
|
||||||
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
|
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
|
||||||
@@ -274,6 +276,11 @@ export let FEATURE_MOST_ZAPPED_FEED = coerceBoolean(
|
|||||||
DEFAULT_FLAGS.FEATURE_MOST_ZAPPED_FEED
|
DEFAULT_FLAGS.FEATURE_MOST_ZAPPED_FEED
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export let FEATURE_ZAP_TALLY = coerceBoolean(
|
||||||
|
runtimeFlags.FEATURE_ZAP_TALLY,
|
||||||
|
DEFAULT_FLAGS.FEATURE_ZAP_TALLY
|
||||||
|
);
|
||||||
|
|
||||||
export let FEATURE_TRUST_SEEDS = coerceBoolean(
|
export let FEATURE_TRUST_SEEDS = coerceBoolean(
|
||||||
runtimeFlags.FEATURE_TRUST_SEEDS,
|
runtimeFlags.FEATURE_TRUST_SEEDS,
|
||||||
DEFAULT_FLAGS.FEATURE_TRUST_SEEDS
|
DEFAULT_FLAGS.FEATURE_TRUST_SEEDS
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const NOTE_TYPES = Object.freeze({
|
|||||||
DM_TYPING: "dmTypingIndicator",
|
DM_TYPING: "dmTypingIndicator",
|
||||||
ZAP_REQUEST: "zapRequest",
|
ZAP_REQUEST: "zapRequest",
|
||||||
ZAP_RECEIPT: "zapReceipt",
|
ZAP_RECEIPT: "zapReceipt",
|
||||||
|
ZAP_TALLY: "zapTally",
|
||||||
WATCH_HISTORY: "watchHistory",
|
WATCH_HISTORY: "watchHistory",
|
||||||
SUBSCRIPTION_LIST: "subscriptionList",
|
SUBSCRIPTION_LIST: "subscriptionList",
|
||||||
SUBSCRIPTION_BACKUP: "subscriptionBackup",
|
SUBSCRIPTION_BACKUP: "subscriptionBackup",
|
||||||
@@ -92,6 +93,11 @@ export { ADMIN_COMMUNITY_BLACKLIST_PREFIX };
|
|||||||
|
|
||||||
const DEFAULT_APPEND_TAGS = [];
|
const DEFAULT_APPEND_TAGS = [];
|
||||||
|
|
||||||
|
// bitvid-native zap tally event kind. Addressable (30000–39999) so `d =
|
||||||
|
// payment_hash` yields one canonical event per real payment. In bitvid's family
|
||||||
|
// (30078 video, 30079 view). See docs/zap-tally-plan.md.
|
||||||
|
export const ZAP_TALLY_KIND = 30081;
|
||||||
|
|
||||||
let cachedUtf8Encoder = null;
|
let cachedUtf8Encoder = null;
|
||||||
|
|
||||||
const ADDITIONAL_POINTER_TAGS = new Set(["p", "e"]);
|
const ADDITIONAL_POINTER_TAGS = new Set(["p", "e"]);
|
||||||
@@ -527,6 +533,23 @@ const BASE_SCHEMAS = {
|
|||||||
"Zap receipts are published by lightning wallets to confirm zap requests.",
|
"Zap receipts are published by lightning wallets to confirm zap requests.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
[NOTE_TYPES.ZAP_TALLY]: {
|
||||||
|
// bitvid-native, payer-signed, preimage-verified zap tally (docs/zap-tally-plan.md).
|
||||||
|
// Addressable (d = payment_hash) so each real payment maps to one canonical
|
||||||
|
// event: relay-level idempotency, re-publish replaces rather than duplicates.
|
||||||
|
// Counted alongside NIP-57 9735 receipts (deduped by payment_hash), so a
|
||||||
|
// Most-Zapped tally survives when the recipient's wallet never publishes a 9735.
|
||||||
|
type: NOTE_TYPES.ZAP_TALLY,
|
||||||
|
label: "Zap tally",
|
||||||
|
kind: ZAP_TALLY_KIND,
|
||||||
|
identifierTag: { name: "d" },
|
||||||
|
appendTags: DEFAULT_APPEND_TAGS,
|
||||||
|
content: {
|
||||||
|
format: "text",
|
||||||
|
description:
|
||||||
|
"Optional zap message; the authoritative amount + proof live in the tags.",
|
||||||
|
},
|
||||||
|
},
|
||||||
[NOTE_TYPES.WATCH_HISTORY]: {
|
[NOTE_TYPES.WATCH_HISTORY]: {
|
||||||
type: NOTE_TYPES.WATCH_HISTORY,
|
type: NOTE_TYPES.WATCH_HISTORY,
|
||||||
label: "Watch history month",
|
label: "Watch history month",
|
||||||
@@ -2347,6 +2370,101 @@ export function buildZapRequestEvent(params) {
|
|||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a bitvid-native zap tally event (kind ZAP_TALLY_KIND, addressable by
|
||||||
|
* d=payment_hash). Payer-signed, preimage-verified stand-in for a NIP-57 receipt
|
||||||
|
* when the recipient's LNURL server doesn't publish one. Carries the same
|
||||||
|
* {bolt11, preimage, description(zap request)} a 9735 would, so any client can
|
||||||
|
* verify sha256(preimage)==payment_hash and description_hash==sha256(zapRequest).
|
||||||
|
* See docs/zap-tally-plan.md §3. Returns an UNSIGNED event; the caller signs.
|
||||||
|
*
|
||||||
|
* @param {object} params
|
||||||
|
* @param {string} params.pubkey payer pubkey
|
||||||
|
* @param {number} params.created_at
|
||||||
|
* @param {string} params.paymentHash hex; becomes the `d` tag (idempotency)
|
||||||
|
* @param {string} params.recipientPubkey the zapped party (`p`)
|
||||||
|
* @param {string} [params.eventId] zapped video event id (`e`)
|
||||||
|
* @param {string} [params.coordinate] zapped video coordinate kind:pubkey:d (`a`)
|
||||||
|
* @param {number} [params.amountMsats] echoes the zap request amount tag
|
||||||
|
* @param {string} params.bolt11 paid invoice (carries payment_hash + description_hash)
|
||||||
|
* @param {string} params.preimage NWC pay_invoice preimage (proof of settlement)
|
||||||
|
* @param {string} params.zapRequestJson the signed 9734 JSON (NIP-57 `description`)
|
||||||
|
* @param {string} [params.comment]
|
||||||
|
* @returns {object} Unsigned event.
|
||||||
|
*/
|
||||||
|
export function buildZapTallyEvent(params) {
|
||||||
|
const {
|
||||||
|
pubkey,
|
||||||
|
created_at,
|
||||||
|
paymentHash,
|
||||||
|
recipientPubkey,
|
||||||
|
eventId,
|
||||||
|
coordinate,
|
||||||
|
amountMsats,
|
||||||
|
bolt11,
|
||||||
|
preimage,
|
||||||
|
zapRequestJson,
|
||||||
|
comment = "",
|
||||||
|
} = params || {};
|
||||||
|
|
||||||
|
const schema = getNostrEventSchema(NOTE_TYPES.ZAP_TALLY);
|
||||||
|
const tags = [];
|
||||||
|
|
||||||
|
const normalizedHash = normalizePointerIdentifier(paymentHash);
|
||||||
|
if (normalizedHash) {
|
||||||
|
tags.push(["d", normalizedHash]); // addressable identifier = payment_hash
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRecipient = normalizePointerIdentifier(recipientPubkey);
|
||||||
|
if (normalizedRecipient) {
|
||||||
|
tags.push(["p", normalizedRecipient]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEventId = normalizePointerIdentifier(eventId);
|
||||||
|
if (normalizedEventId) {
|
||||||
|
tags.push(["e", normalizedEventId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof coordinate === "string" && coordinate.trim()) {
|
||||||
|
tags.push(["a", coordinate.trim()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(amountMsats)) {
|
||||||
|
tags.push(["amount", String(Math.max(0, Math.round(amountMsats)))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof bolt11 === "string" && bolt11.trim()) {
|
||||||
|
tags.push(["bolt11", bolt11.trim()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof preimage === "string" && preimage.trim()) {
|
||||||
|
tags.push(["preimage", preimage.trim()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof zapRequestJson === "string" && zapRequestJson.trim()) {
|
||||||
|
tags.push(["description", zapRequestJson.trim()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.push(["client", "bitvid"]);
|
||||||
|
tags.push(["t", "zap"]);
|
||||||
|
|
||||||
|
appendSchemaTags(tags, schema);
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
kind: schema?.kind ?? ZAP_TALLY_KIND,
|
||||||
|
pubkey,
|
||||||
|
created_at,
|
||||||
|
tags,
|
||||||
|
content: ensureValidUtf8Content(typeof comment === "string" ? comment : ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDevMode) {
|
||||||
|
validateEventAgainstSchema(NOTE_TYPES.ZAP_TALLY, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a Reaction event (Kind 7).
|
* Builds a Reaction event (Kind 7).
|
||||||
* E.g., Like ('+'), Dislike ('-'), or emoji.
|
* E.g., Like ('+'), Dislike ('-'), or emoji.
|
||||||
|
|||||||
@@ -58,6 +58,97 @@ function computeZapRequestHash(zapRequest) {
|
|||||||
return bytesToHex(sha256(data));
|
return bytesToHex(sha256(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hexToBytes(hex) {
|
||||||
|
const clean = typeof hex === "string" ? hex.trim().toLowerCase() : "";
|
||||||
|
if (!clean || clean.length % 2 !== 0 || /[^0-9a-f]/.test(clean)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(clean.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proof of settlement: a Lightning payment_hash is sha256(preimage). Anyone can
|
||||||
|
// check it — this is what makes a payer-signed tally as trustworthy as a
|
||||||
|
// recipient-signed 9735 (docs/zap-tally-plan.md §3, step 2).
|
||||||
|
export function verifyPaymentPreimage(preimageHex, paymentHashHex) {
|
||||||
|
const preimage = hexToBytes(preimageHex);
|
||||||
|
const expected =
|
||||||
|
typeof paymentHashHex === "string" ? paymentHashHex.trim().toLowerCase() : "";
|
||||||
|
if (!preimage || preimage.length !== 32 || expected.length !== 64) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return bytesToHex(sha256(preimage)) === expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagValue = (tags, name) => {
|
||||||
|
const list = Array.isArray(tags) ? tags : [];
|
||||||
|
const tag = list.find((t) => Array.isArray(t) && t[0] === name && t[1] != null);
|
||||||
|
return tag ? String(tag[1]) : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify a bitvid zap-tally event (kind ZAP_TALLY_KIND). Returns
|
||||||
|
// { ok, sats, paymentHash, pointerTags } — pointerTags come from the EMBEDDED
|
||||||
|
// zap request (a/e/p), not the outer wrapper, so a mangled wrapper can't
|
||||||
|
// retarget the credit. `getSats` defaults to nostr-tools' bolt11 amount parser;
|
||||||
|
// injectable for tests. See docs/zap-tally-plan.md §3.
|
||||||
|
export function verifyBitvidZapTally(event, { getSats, extractFields } = {}) {
|
||||||
|
const fail = { ok: false, sats: 0, paymentHash: null, pointerTags: [] };
|
||||||
|
if (!event || typeof event !== "object" || !Array.isArray(event.tags)) {
|
||||||
|
return fail;
|
||||||
|
}
|
||||||
|
const bolt11 = tagValue(event.tags, "bolt11");
|
||||||
|
const preimage = tagValue(event.tags, "preimage");
|
||||||
|
const zapRequestJson = tagValue(event.tags, "description");
|
||||||
|
if (!bolt11 || !preimage) {
|
||||||
|
return fail;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decode = typeof extractFields === "function" ? extractFields : extractBolt11Fields;
|
||||||
|
const { paymentHash, descriptionHash } = decode(bolt11);
|
||||||
|
if (!paymentHash) {
|
||||||
|
return fail;
|
||||||
|
}
|
||||||
|
// Step 2: the invoice was actually paid.
|
||||||
|
if (!verifyPaymentPreimage(preimage, paymentHash)) {
|
||||||
|
return fail;
|
||||||
|
}
|
||||||
|
// Step 3: that payment was for THIS zap request (binds pointer + amount;
|
||||||
|
// blocks reusing a leaked preimage against a different video).
|
||||||
|
if (descriptionHash) {
|
||||||
|
if (!zapRequestJson || computeZapRequestHash(zapRequestJson) !== descriptionHash) {
|
||||||
|
return fail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amount is authoritative from the bolt11, never the tag.
|
||||||
|
let sats = 0;
|
||||||
|
try {
|
||||||
|
const fn =
|
||||||
|
typeof getSats === "function"
|
||||||
|
? getSats
|
||||||
|
: cachedTools?.nip57?.getSatoshisAmountFromBolt11;
|
||||||
|
const parsed = typeof fn === "function" ? Number(fn(bolt11)) : NaN;
|
||||||
|
sats = Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0;
|
||||||
|
} catch (error) {
|
||||||
|
sats = 0;
|
||||||
|
}
|
||||||
|
if (sats <= 0) {
|
||||||
|
return fail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer tags from the embedded, hash-bound zap request.
|
||||||
|
const req = parseZapRequest(zapRequestJson);
|
||||||
|
const reqTags = Array.isArray(req?.tags) ? req.tags : event.tags;
|
||||||
|
const pointerTags = reqTags.filter(
|
||||||
|
(t) => Array.isArray(t) && (t[0] === "a" || t[0] === "e" || t[0] === "p"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ok: true, sats, paymentHash, pointerTags };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRelayList(relays) {
|
function normalizeRelayList(relays) {
|
||||||
if (!Array.isArray(relays)) {
|
if (!Array.isArray(relays)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -161,6 +252,74 @@ function normalizePubkey(pubkey) {
|
|||||||
return trimmed ? trimmed.toLowerCase() : "";
|
return trimmed ? trimmed.toLowerCase() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Walk a bolt11's bech32 tagged fields once, returning the fields we care about:
|
||||||
|
// paymentHash — bolt11 tag 'p' (proves settlement when sha256(preimage)===it)
|
||||||
|
// descriptionHash — bolt11 tag 'h' (NIP-57 binds the invoice to the zap request)
|
||||||
|
// Both hex, or null if absent/undecodable. Shared by the 9735 path and the
|
||||||
|
// bitvid zap-tally verifier (docs/zap-tally-plan.md §3).
|
||||||
|
export function extractBolt11Fields(bolt11) {
|
||||||
|
const out = { paymentHash: null, descriptionHash: null };
|
||||||
|
const normalized = normalizeInvoiceValue(bolt11).toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded;
|
||||||
|
try {
|
||||||
|
decoded = bech32.decode(normalized, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = Array.isArray(decoded?.words) ? decoded.words.slice() : [];
|
||||||
|
if (words.length <= 104) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataWords = words.slice(0, -104);
|
||||||
|
if (dataWords.length <= 7) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 7; // skip timestamp words
|
||||||
|
while (index < dataWords.length) {
|
||||||
|
const tagCode = dataWords[index];
|
||||||
|
index += 1;
|
||||||
|
if (typeof tagCode !== "number" || tagCode < 0 || tagCode >= BOLT11_CHARSET.length) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (index + 1 >= dataWords.length) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const length = (dataWords[index] << 5) + dataWords[index + 1];
|
||||||
|
index += 2;
|
||||||
|
if (length < 0 || index + length > dataWords.length) {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const dataSlice = dataWords.slice(index, index + length);
|
||||||
|
index += length;
|
||||||
|
const tag = BOLT11_CHARSET[tagCode];
|
||||||
|
if (tag === "h" && !out.descriptionHash) {
|
||||||
|
try {
|
||||||
|
out.descriptionHash = bytesToHex(Uint8Array.from(bech32.fromWords(dataSlice)));
|
||||||
|
} catch (error) {
|
||||||
|
/* leave null */
|
||||||
|
}
|
||||||
|
} else if (tag === "p" && !out.paymentHash) {
|
||||||
|
try {
|
||||||
|
out.paymentHash = bytesToHex(Uint8Array.from(bech32.fromWords(dataSlice)));
|
||||||
|
} catch (error) {
|
||||||
|
/* leave null */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (out.paymentHash && out.descriptionHash) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function extractDescriptionHashFromBolt11(bolt11) {
|
function extractDescriptionHashFromBolt11(bolt11) {
|
||||||
const normalized = normalizeInvoiceValue(bolt11).toLowerCase();
|
const normalized = normalizeInvoiceValue(bolt11).toLowerCase();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
// Foundation for the bitvid-native zap tally (docs/zap-tally-plan.md §3):
|
||||||
|
// the payer-signed, preimage-verified stand-in for a NIP-57 receipt. These
|
||||||
|
// cover the security core — a tally is only counted if the invoice was really
|
||||||
|
// paid (sha256(preimage)===payment_hash) AND that payment was bound to this
|
||||||
|
// zap request (description_hash===sha256(zapRequest), the replay guard) — plus
|
||||||
|
// the bolt11 field decode and the event builder's tag shape.
|
||||||
|
//
|
||||||
|
// test_integrity_note:
|
||||||
|
// change_type: ["new_tests"]
|
||||||
|
// scenarios:
|
||||||
|
// - id: SCN-zap-tally-verify
|
||||||
|
// given: "a tally event with a bolt11/preimage/zap-request, valid or tampered"
|
||||||
|
// when: "verifyBitvidZapTally / verifyPaymentPreimage / extractBolt11Fields run"
|
||||||
|
// then: "valid → ok+sats+pointers from the embedded request; bad preimage or mismatched description hash (replay) → rejected; amount is bolt11-derived not tag-claimed"
|
||||||
|
// observable_outcomes:
|
||||||
|
// - "verifyPaymentPreimage true only when sha256(preimage)===payment_hash"
|
||||||
|
// - "extractBolt11Fields round-trips a bech32-encoded p/h field to hex"
|
||||||
|
// - "verifyBitvidZapTally ok/sats/pointerTags per tamper case"
|
||||||
|
// - "buildZapTallyEvent emits d(payment_hash)/p/e/a/amount/bolt11/preimage/description"
|
||||||
|
// determinism_controls:
|
||||||
|
// - "sha256 via node:crypto; bech32 vector constructed in-test; no network"
|
||||||
|
// anti_cheat_rationale:
|
||||||
|
// prevents: ["hard-coded return value", "over-mocking internal logic"]
|
||||||
|
// relaxation:
|
||||||
|
// did_relax_any_assertion: false
|
||||||
|
|
||||||
|
import test from "node:test";
|
||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { bech32, bytesToHex } from "../vendor/crypto-helpers.bundle.min.js";
|
||||||
|
import {
|
||||||
|
extractBolt11Fields,
|
||||||
|
verifyPaymentPreimage,
|
||||||
|
verifyBitvidZapTally,
|
||||||
|
} from "../js/payments/zapReceiptValidator.js";
|
||||||
|
import { buildZapTallyEvent, ZAP_TALLY_KIND } from "../js/nostrEventSchemas.js";
|
||||||
|
|
||||||
|
const sha256hex = (input) =>
|
||||||
|
createHash("sha256")
|
||||||
|
.update(typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input))
|
||||||
|
.digest("hex");
|
||||||
|
|
||||||
|
// --- verifyPaymentPreimage ---------------------------------------------------
|
||||||
|
|
||||||
|
test("verifyPaymentPreimage: true only when sha256(preimage)===payment_hash", () => {
|
||||||
|
const preimage = randomBytes(32);
|
||||||
|
const preimageHex = preimage.toString("hex");
|
||||||
|
const paymentHash = sha256hex(preimage);
|
||||||
|
|
||||||
|
assert.equal(verifyPaymentPreimage(preimageHex, paymentHash), true);
|
||||||
|
assert.equal(verifyPaymentPreimage(preimageHex, "0".repeat(64)), false, "wrong hash");
|
||||||
|
assert.equal(verifyPaymentPreimage("ab", paymentHash), false, "preimage not 32 bytes");
|
||||||
|
assert.equal(verifyPaymentPreimage(preimageHex, "not-hex"), false);
|
||||||
|
assert.equal(verifyPaymentPreimage("", paymentHash), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- extractBolt11Fields (bech32 round-trip, no external vector) --------------
|
||||||
|
|
||||||
|
// bech32 charset positions are the bolt11 tagged-field type codes: p=1, h=23.
|
||||||
|
const lenWords = (n) => [n >> 5, n & 31];
|
||||||
|
|
||||||
|
function makeBolt11({ paymentHash, descriptionHash }) {
|
||||||
|
const pWords = bech32.toWords(paymentHash); // 32 bytes -> 52 words
|
||||||
|
const hWords = bech32.toWords(descriptionHash);
|
||||||
|
const words = [
|
||||||
|
0, 0, 0, 0, 0, 0, 0, // 35-bit timestamp
|
||||||
|
1, ...lenWords(pWords.length), ...pWords, // 'p' payment_hash
|
||||||
|
23, ...lenWords(hWords.length), ...hWords, // 'h' description_hash
|
||||||
|
...new Array(104).fill(0), // 520-bit signature placeholder
|
||||||
|
];
|
||||||
|
return bech32.encode("lnbc", words, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("extractBolt11Fields decodes payment_hash + description_hash from a bolt11", () => {
|
||||||
|
const paymentHash = new Uint8Array(32).fill(0xab);
|
||||||
|
const descriptionHash = new Uint8Array(32).fill(0xcd);
|
||||||
|
const invoice = makeBolt11({ paymentHash, descriptionHash });
|
||||||
|
|
||||||
|
const fields = extractBolt11Fields(invoice);
|
||||||
|
assert.equal(fields.paymentHash, bytesToHex(paymentHash));
|
||||||
|
assert.equal(fields.descriptionHash, bytesToHex(descriptionHash));
|
||||||
|
|
||||||
|
assert.deepEqual(extractBolt11Fields(""), { paymentHash: null, descriptionHash: null });
|
||||||
|
assert.deepEqual(extractBolt11Fields("not-an-invoice"), {
|
||||||
|
paymentHash: null,
|
||||||
|
descriptionHash: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- verifyBitvidZapTally (decision logic; fields injected) -------------------
|
||||||
|
|
||||||
|
const PUB = "a".repeat(64);
|
||||||
|
const CREATOR = "b".repeat(64);
|
||||||
|
const EVENT_ID = "e".repeat(64);
|
||||||
|
const COORD = `30078:${CREATOR}:vid-1`;
|
||||||
|
|
||||||
|
function makeTally({ preimageHex, zapRequestJson, bolt11 = "lnbcTEST" }) {
|
||||||
|
return buildZapTallyEvent({
|
||||||
|
pubkey: PUB,
|
||||||
|
created_at: 1000,
|
||||||
|
paymentHash: sha256hex(Buffer.from(preimageHex, "hex")),
|
||||||
|
recipientPubkey: CREATOR,
|
||||||
|
eventId: EVENT_ID,
|
||||||
|
coordinate: COORD,
|
||||||
|
amountMsats: 2100000,
|
||||||
|
bolt11,
|
||||||
|
preimage: preimageHex,
|
||||||
|
zapRequestJson,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function zapRequestJson({ amount = 2100000 } = {}) {
|
||||||
|
return JSON.stringify({
|
||||||
|
kind: 9734,
|
||||||
|
pubkey: PUB,
|
||||||
|
tags: [
|
||||||
|
["p", CREATOR],
|
||||||
|
["e", EVENT_ID],
|
||||||
|
["a", COORD],
|
||||||
|
["amount", String(amount)],
|
||||||
|
],
|
||||||
|
content: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("verifyBitvidZapTally: valid tally → ok, bolt11-derived sats, request pointers", () => {
|
||||||
|
const preimageHex = randomBytes(32).toString("hex");
|
||||||
|
const reqJson = zapRequestJson();
|
||||||
|
const event = makeTally({ preimageHex, zapRequestJson: reqJson });
|
||||||
|
|
||||||
|
const inject = {
|
||||||
|
getSats: () => 2100, // authoritative amount from the bolt11 (2100 sats)
|
||||||
|
extractFields: () => ({
|
||||||
|
paymentHash: sha256hex(Buffer.from(preimageHex, "hex")),
|
||||||
|
descriptionHash: sha256hex(reqJson),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const result = verifyBitvidZapTally(event, inject);
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.sats, 2100, "sats from bolt11, not the amount tag");
|
||||||
|
assert.ok(result.paymentHash);
|
||||||
|
// Pointers come from the embedded (hash-bound) zap request.
|
||||||
|
const kinds = result.pointerTags.map((t) => t[0]).sort();
|
||||||
|
assert.deepEqual(kinds, ["a", "e", "p"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifyBitvidZapTally: bad preimage → rejected", () => {
|
||||||
|
const preimageHex = randomBytes(32).toString("hex");
|
||||||
|
const reqJson = zapRequestJson();
|
||||||
|
const event = makeTally({ preimageHex, zapRequestJson: reqJson });
|
||||||
|
const result = verifyBitvidZapTally(event, {
|
||||||
|
getSats: () => 2100,
|
||||||
|
extractFields: () => ({
|
||||||
|
paymentHash: "f".repeat(64), // does NOT match sha256(preimage)
|
||||||
|
descriptionHash: sha256hex(reqJson),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifyBitvidZapTally: mismatched description hash (replay) → rejected", () => {
|
||||||
|
const preimageHex = randomBytes(32).toString("hex");
|
||||||
|
const reqJson = zapRequestJson();
|
||||||
|
const event = makeTally({ preimageHex, zapRequestJson: reqJson });
|
||||||
|
// A leaked preimage retargeted to a DIFFERENT request: description_hash no
|
||||||
|
// longer matches the embedded zap request → binding check fails.
|
||||||
|
const result = verifyBitvidZapTally(event, {
|
||||||
|
getSats: () => 2100,
|
||||||
|
extractFields: () => ({
|
||||||
|
paymentHash: sha256hex(Buffer.from(preimageHex, "hex")),
|
||||||
|
descriptionHash: sha256hex(zapRequestJson({ amount: 999999 })), // different request
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, false, "cannot reuse a preimage for another zap request");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifyBitvidZapTally: zero/undecodable sats → rejected; missing proof → rejected", () => {
|
||||||
|
const preimageHex = randomBytes(32).toString("hex");
|
||||||
|
const reqJson = zapRequestJson();
|
||||||
|
const event = makeTally({ preimageHex, zapRequestJson: reqJson });
|
||||||
|
assert.equal(
|
||||||
|
verifyBitvidZapTally(event, {
|
||||||
|
getSats: () => 0,
|
||||||
|
extractFields: () => ({
|
||||||
|
paymentHash: sha256hex(Buffer.from(preimageHex, "hex")),
|
||||||
|
descriptionHash: sha256hex(reqJson),
|
||||||
|
}),
|
||||||
|
}).ok,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(verifyBitvidZapTally({ tags: [] }).ok, false, "no bolt11/preimage");
|
||||||
|
assert.equal(verifyBitvidZapTally(null).ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- buildZapTallyEvent ------------------------------------------------------
|
||||||
|
|
||||||
|
test("buildZapTallyEvent: addressable by payment_hash + carries the proof tags", () => {
|
||||||
|
const event = buildZapTallyEvent({
|
||||||
|
pubkey: PUB,
|
||||||
|
created_at: 1000,
|
||||||
|
paymentHash: "ab".repeat(32),
|
||||||
|
recipientPubkey: CREATOR,
|
||||||
|
eventId: EVENT_ID,
|
||||||
|
coordinate: COORD,
|
||||||
|
amountMsats: 2100000,
|
||||||
|
bolt11: "lnbc21u1...",
|
||||||
|
preimage: "cd".repeat(32),
|
||||||
|
zapRequestJson: zapRequestJson(),
|
||||||
|
});
|
||||||
|
assert.equal(event.kind, ZAP_TALLY_KIND);
|
||||||
|
const tag = (name) => event.tags.find((t) => t[0] === name);
|
||||||
|
assert.deepEqual(tag("d"), ["d", "ab".repeat(32)], "d = payment_hash (addressable idempotency)");
|
||||||
|
assert.deepEqual(tag("p"), ["p", CREATOR]);
|
||||||
|
assert.deepEqual(tag("e"), ["e", EVENT_ID]);
|
||||||
|
assert.deepEqual(tag("a"), ["a", COORD]);
|
||||||
|
assert.equal(tag("bolt11")[1], "lnbc21u1...");
|
||||||
|
assert.equal(tag("preimage")[1], "cd".repeat(32));
|
||||||
|
assert.ok(tag("description"), "embeds the zap request");
|
||||||
|
assert.deepEqual(tag("client"), ["client", "bitvid"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildZapTallyEvent: profile-only zap has p but no a/e", () => {
|
||||||
|
const event = buildZapTallyEvent({
|
||||||
|
pubkey: PUB,
|
||||||
|
created_at: 1000,
|
||||||
|
paymentHash: "ab".repeat(32),
|
||||||
|
recipientPubkey: CREATOR,
|
||||||
|
amountMsats: 1000,
|
||||||
|
bolt11: "lnbc10n1...",
|
||||||
|
preimage: "cd".repeat(32),
|
||||||
|
zapRequestJson: JSON.stringify({ kind: 9734, tags: [["p", CREATOR]] }),
|
||||||
|
});
|
||||||
|
assert.ok(event.tags.find((t) => t[0] === "p"));
|
||||||
|
assert.equal(event.tags.find((t) => t[0] === "e"), undefined);
|
||||||
|
assert.equal(event.tags.find((t) => t[0] === "a"), undefined);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user