mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 17:32:26 +00:00
Merge pull request #997 from PR0M3TH3AN/codex/add-zap-receipt-validation-helper-and-tests
Add zap receipt validation workflow
This commit is contained in:
6 files changed
+1071
-17
No files matched your search
@@ -505,9 +505,14 @@ await (async () => {
|
||||
receipts: [
|
||||
{
|
||||
recipientType: "creator",
|
||||
status: "success",
|
||||
amount: payload.amountSats,
|
||||
address: payload.videoEvent.lightningAddress,
|
||||
payment: { result: { preimage: "ff".repeat(16) } },
|
||||
validation: {
|
||||
status: "passed",
|
||||
event: { kind: 9735, id: "creator-receipt" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -563,6 +568,84 @@ await (async () => {
|
||||
app.destroy();
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
// Test: validation skipped receipts do not trigger warnings
|
||||
const receipts = [
|
||||
{
|
||||
recipientType: "creator",
|
||||
status: "success",
|
||||
amount: 600,
|
||||
address: "[email protected]",
|
||||
payment: { result: { preimage: "dd".repeat(16) } },
|
||||
validation: {
|
||||
status: "skipped",
|
||||
reason: "Zap request unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
recipientType: "platform",
|
||||
status: "success",
|
||||
amount: 100,
|
||||
address: "[email protected]",
|
||||
validation: {
|
||||
status: "skipped",
|
||||
reason: "Zap request unavailable",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const { app, modalStub, creatorAddress } = await createApp({
|
||||
splitAndZap: async () => ({ receipts }),
|
||||
});
|
||||
|
||||
const pubkeyHex = "d".repeat(64);
|
||||
app.pubkey = pubkeyHex;
|
||||
const normalized = app.normalizeHexPubkey(pubkeyHex);
|
||||
app.nwcSettingsService.cache.set(normalized, {
|
||||
nwcUri: "nostr+walletconnect://example",
|
||||
defaultZap: null,
|
||||
lastChecked: null,
|
||||
version: "",
|
||||
});
|
||||
|
||||
app.currentVideo = {
|
||||
id: "event901",
|
||||
pubkey: pubkeyHex,
|
||||
tags: [["d", "video901"]],
|
||||
content: "",
|
||||
created_at: 1_700_000_150,
|
||||
lightningAddress: creatorAddress,
|
||||
};
|
||||
|
||||
const initialWarningCount = modalStub.statusMessages.filter(
|
||||
(entry) => entry.tone === "warning"
|
||||
).length;
|
||||
|
||||
await app.zapController.sendZap({ amount: 700, comment: "No receipt support" });
|
||||
|
||||
const warningsAfter = modalStub.statusMessages.filter(
|
||||
(entry) => entry.tone === "warning"
|
||||
).length;
|
||||
assert.equal(
|
||||
warningsAfter,
|
||||
initialWarningCount,
|
||||
"skipped validation should not emit new warnings",
|
||||
);
|
||||
|
||||
const lastStatus = modalStub.statusMessages[modalStub.statusMessages.length - 1];
|
||||
assert.equal(lastStatus.tone, "success", "zap should report success when validation skipped");
|
||||
assert(!/awaiting validated zap receipt/i.test(lastStatus.message));
|
||||
|
||||
const lastReceiptEntry = modalStub.receipts[modalStub.receipts.length - 1];
|
||||
assert.equal(
|
||||
lastReceiptEntry?.options?.partial,
|
||||
false,
|
||||
"receipt rendering should complete when validation is skipped",
|
||||
);
|
||||
|
||||
app.destroy();
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
// Test: platform receipt failure keeps retry state active and surfaces warning
|
||||
const platformError = new Error("Budget exceeded");
|
||||
@@ -573,6 +656,10 @@ await (async () => {
|
||||
amount: 900,
|
||||
address: "[email protected]",
|
||||
payment: { result: { preimage: "cc".repeat(16) } },
|
||||
validation: {
|
||||
status: "passed",
|
||||
event: { kind: 9735, id: "creator-receipt" },
|
||||
},
|
||||
},
|
||||
{
|
||||
recipientType: "platform",
|
||||
@@ -676,6 +763,84 @@ await (async () => {
|
||||
app.destroy();
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
// Test: validation failure surfaces warning and resets form
|
||||
const validationReceipts = [
|
||||
{
|
||||
recipientType: "creator",
|
||||
status: "success",
|
||||
amount: 750,
|
||||
address: "[email protected]",
|
||||
payment: { result: { preimage: "dd".repeat(16) } },
|
||||
validation: {
|
||||
status: "failed",
|
||||
reason: "No compliant receipt published",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const { app, modalStub, creatorAddress } = await createApp({
|
||||
splitAndZap: async () => ({ receipts: validationReceipts }),
|
||||
});
|
||||
|
||||
const pubkeyHex = "d".repeat(64);
|
||||
app.pubkey = pubkeyHex;
|
||||
const normalized = app.normalizeHexPubkey(pubkeyHex);
|
||||
app.nwcSettingsService.cache.set(normalized, {
|
||||
nwcUri: "nostr+walletconnect://example",
|
||||
defaultZap: null,
|
||||
lastChecked: null,
|
||||
version: "",
|
||||
});
|
||||
|
||||
app.currentVideo = {
|
||||
id: "event990",
|
||||
pubkey: pubkeyHex,
|
||||
tags: [["d", "video990"]],
|
||||
content: "",
|
||||
created_at: 1_700_000_200,
|
||||
lightningAddress: creatorAddress,
|
||||
};
|
||||
|
||||
const initialResetCount = modalStub.resetForms.length;
|
||||
const initialCompleted = modalStub.completedStates.length;
|
||||
|
||||
await app.zapController.sendZap({ amount: 750, comment: "Pending receipt" });
|
||||
|
||||
const lastReceiptEntry = modalStub.receipts[modalStub.receipts.length - 1];
|
||||
assert.equal(
|
||||
lastReceiptEntry.options?.partial,
|
||||
true,
|
||||
"should render zap receipts in partial mode for validation issues",
|
||||
);
|
||||
|
||||
const lastStatus = modalStub.statusMessages[modalStub.statusMessages.length - 1];
|
||||
assert.equal(lastStatus.tone, "warning", "validation failure should emit warning status");
|
||||
assert.match(
|
||||
lastStatus.message,
|
||||
/awaiting validated zap receipt/i,
|
||||
"status message should highlight missing validated receipt",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
modalStub.resetForms.length,
|
||||
initialResetCount + 1,
|
||||
"zap form should reset after validation warning",
|
||||
);
|
||||
assert.equal(
|
||||
modalStub.completedStates.length,
|
||||
initialCompleted + 1,
|
||||
"completion state should record the validation attempt",
|
||||
);
|
||||
assert.equal(
|
||||
modalStub.completedStates[modalStub.completedStates.length - 1],
|
||||
true,
|
||||
"validation warning should mark zap attempt as completed",
|
||||
);
|
||||
|
||||
app.destroy();
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
// Test: allowance exhaustion surfaces guidance and skips retry queue
|
||||
const { app, modalStub, creatorAddress, platformAddress } = await createApp({
|
||||
@@ -690,6 +855,10 @@ await (async () => {
|
||||
amount: 900,
|
||||
address: creatorAddress,
|
||||
payment: { preimage: "aa".repeat(16) },
|
||||
validation: {
|
||||
status: "passed",
|
||||
event: { kind: 9735, id: "creator-receipt" },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "platform",
|
||||
@@ -750,4 +919,63 @@ await (async () => {
|
||||
app.destroy();
|
||||
})();
|
||||
|
||||
const { VideoModal } = await import("../js/ui/components/VideoModal.js");
|
||||
|
||||
(() => {
|
||||
const modal = new VideoModal({ document: documentStub, setGlobalModalState: noop });
|
||||
modal.modalZapReceipts = new DummyElement();
|
||||
|
||||
const receipts = [
|
||||
{
|
||||
recipientType: "creator",
|
||||
amount: 600,
|
||||
address: "[email protected]",
|
||||
payment: { result: { preimage: "aa".repeat(16) } },
|
||||
validation: {
|
||||
status: "passed",
|
||||
event: { kind: 9735, id: "validated" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
modal.renderZapReceipts(receipts, { partial: false });
|
||||
|
||||
assert.equal(modal.modalZapReceipts.children.length, 1, "validated receipt should render one entry");
|
||||
const entry = modal.modalZapReceipts.children[0];
|
||||
const header = entry.children[0];
|
||||
assert.equal(header.children[1].textContent, "Validated", "validated receipt should flag status");
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const modal = new VideoModal({ document: documentStub, setGlobalModalState: noop });
|
||||
modal.modalZapReceipts = new DummyElement();
|
||||
|
||||
const receipts = [
|
||||
{
|
||||
recipientType: "creator",
|
||||
amount: 450,
|
||||
address: "[email protected]",
|
||||
payment: { result: { preimage: "bb".repeat(16) } },
|
||||
validation: {
|
||||
status: "failed",
|
||||
reason: "No compliant receipt",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
modal.renderZapReceipts(receipts, { partial: true });
|
||||
|
||||
assert.equal(
|
||||
modal.modalZapReceipts.children.length,
|
||||
1,
|
||||
"validation failure should render a single warning entry",
|
||||
);
|
||||
const warning = modal.modalZapReceipts.children[0];
|
||||
assert.match(
|
||||
warning.textContent,
|
||||
/no validated zap receipts yet/i,
|
||||
"warning message should note missing validated receipts",
|
||||
);
|
||||
})();
|
||||
|
||||
process.exit(0);
|
||||
+186
-1
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { bech32 } from "@scure/base";
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
globalThis.window = {};
|
||||
@@ -76,6 +77,10 @@ globalThis.fetch = async () => {
|
||||
};
|
||||
|
||||
const { splitAndZap } = await import("../js/payments/zapSplit.js");
|
||||
const {
|
||||
validateZapReceipt,
|
||||
__TESTING__: validatorTesting,
|
||||
} = await import("../js/payments/zapReceiptValidator.js");
|
||||
const platformAddressModule = await import("../js/payments/platformAddress.js");
|
||||
const { __resetPlatformAddressCache } = platformAddressModule;
|
||||
const lnurlModule = await import("../js/payments/lnurl.js");
|
||||
@@ -93,6 +98,8 @@ const DEFAULT_WALLET_RELAYS = [
|
||||
"wss://wallet.primary.example",
|
||||
];
|
||||
|
||||
const BOLT11_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||
|
||||
function createDeps({
|
||||
commentAllowed = 120,
|
||||
platformAddress = "[email protected]",
|
||||
@@ -101,9 +108,11 @@ function createDeps({
|
||||
allowsNostr = true,
|
||||
sendPaymentImplementation,
|
||||
walletRelays = DEFAULT_WALLET_RELAYS,
|
||||
validatorImplementation,
|
||||
} = {}) {
|
||||
let ensureWalletCalls = 0;
|
||||
const sendCalls = [];
|
||||
const validatorCalls = [];
|
||||
|
||||
return {
|
||||
deps: {
|
||||
@@ -178,6 +187,18 @@ function createDeps({
|
||||
return platformAddress;
|
||||
},
|
||||
},
|
||||
validator: {
|
||||
async validateZapReceipt(payload) {
|
||||
validatorCalls.push(payload);
|
||||
if (typeof validatorImplementation === "function") {
|
||||
return validatorImplementation(payload);
|
||||
}
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "Zap receipt validation is disabled in tests.",
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
getEnsureWalletCalls() {
|
||||
return ensureWalletCalls;
|
||||
@@ -185,12 +206,50 @@ function createDeps({
|
||||
getSendCalls() {
|
||||
return sendCalls;
|
||||
},
|
||||
getValidatorCalls() {
|
||||
return validatorCalls;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTestBolt11Invoice({ amountCode = "10u", descriptionHashHex }) {
|
||||
const hrp = `lnbc${amountCode}`;
|
||||
const timestamp = 1_700_000_000;
|
||||
|
||||
const timestampWords = [];
|
||||
let remaining = timestamp;
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
timestampWords.unshift(remaining & 31);
|
||||
remaining >>= 5;
|
||||
}
|
||||
|
||||
const words = [...timestampWords];
|
||||
|
||||
const pushTag = (tag, dataWords) => {
|
||||
const code = BOLT11_CHARSET.indexOf(tag);
|
||||
words.push(code);
|
||||
const length = dataWords.length;
|
||||
words.push((length >> 5) & 31);
|
||||
words.push(length & 31);
|
||||
words.push(...dataWords);
|
||||
};
|
||||
|
||||
const paymentHashHex = "11".repeat(32);
|
||||
pushTag("p", bech32.toWords(Buffer.from(paymentHashHex, "hex")));
|
||||
|
||||
if (descriptionHashHex) {
|
||||
pushTag("h", bech32.toWords(Buffer.from(descriptionHashHex, "hex")));
|
||||
}
|
||||
|
||||
const signatureWords = new Array(104).fill(0);
|
||||
const data = [...words, ...signatureWords];
|
||||
|
||||
return bech32.encode(hrp, data, 2000);
|
||||
}
|
||||
|
||||
async function testSplitMath() {
|
||||
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = 10;
|
||||
const { deps, getSendCalls, getEnsureWalletCalls } = createDeps();
|
||||
const { deps, getSendCalls, getEnsureWalletCalls, getValidatorCalls } = createDeps();
|
||||
|
||||
const videoEvent = {
|
||||
id: "event-id",
|
||||
@@ -213,6 +272,7 @@ async function testSplitMath() {
|
||||
assert.equal(result.platformShare, 100);
|
||||
assert.equal(result.receipts.length, 2);
|
||||
assert.equal(getEnsureWalletCalls(), 1);
|
||||
assert.equal(getValidatorCalls().length, 2, "validator should run for each share");
|
||||
|
||||
const sendCalls = getSendCalls();
|
||||
assert.equal(sendCalls.length, 2, "should send two payments");
|
||||
@@ -222,6 +282,7 @@ async function testSplitMath() {
|
||||
const creatorReceipt = result.receipts[0];
|
||||
assert.equal(creatorReceipt.recipientType, "creator");
|
||||
assert.equal(creatorReceipt.status, "success");
|
||||
assert.equal(creatorReceipt.validation?.status, "skipped");
|
||||
assert(creatorReceipt.zapRequest, "creator zap request should be present");
|
||||
const parsedZap = JSON.parse(creatorReceipt.zapRequest);
|
||||
assert.equal(parsedZap.kind, 9734);
|
||||
@@ -256,6 +317,7 @@ async function testSplitMath() {
|
||||
const platformReceipt = result.receipts[1];
|
||||
assert.equal(platformReceipt.recipientType, "platform");
|
||||
assert.equal(platformReceipt.status, "success");
|
||||
assert.equal(platformReceipt.validation?.status, "skipped");
|
||||
|
||||
const platformZap = JSON.parse(platformReceipt.zapRequest);
|
||||
const platformLnurlTag = platformZap.tags.find(
|
||||
@@ -641,6 +703,126 @@ async function testWalletFailure() {
|
||||
);
|
||||
}
|
||||
|
||||
async function testValidateZapReceiptSuccess() {
|
||||
const relays = ["wss://relay.validation"];
|
||||
const zapRequestEvent = {
|
||||
kind: 9734,
|
||||
pubkey: "f".repeat(64),
|
||||
content: "",
|
||||
created_at: 1_700_000_000,
|
||||
tags: [
|
||||
["p", "a".repeat(64)],
|
||||
["amount", String(900_000)],
|
||||
["relays", ...relays],
|
||||
],
|
||||
};
|
||||
const zapRequestString = JSON.stringify(zapRequestEvent);
|
||||
const descriptionHash = validatorTesting.computeZapRequestHash(zapRequestString);
|
||||
const bolt11 = buildTestBolt11Invoice({ amountCode: "9u", descriptionHashHex: descriptionHash });
|
||||
|
||||
const receiptEvent = {
|
||||
kind: 9735,
|
||||
pubkey: "b".repeat(64),
|
||||
tags: [
|
||||
["bolt11", bolt11],
|
||||
["description", zapRequestString],
|
||||
],
|
||||
};
|
||||
|
||||
const nostrTools = {
|
||||
validateEvent: () => true,
|
||||
verifyEvent: () => true,
|
||||
SimplePool: class {
|
||||
async list(requestedRelays, filters) {
|
||||
assert.deepEqual(requestedRelays, relays);
|
||||
assert.equal(filters[0]["#bolt11"][0], bolt11.toLowerCase());
|
||||
return [receiptEvent];
|
||||
}
|
||||
close() {}
|
||||
},
|
||||
};
|
||||
|
||||
const result = await validateZapReceipt(
|
||||
{
|
||||
zapRequest: zapRequestString,
|
||||
amountSats: 900,
|
||||
metadata: { nostrPubkey: receiptEvent.pubkey },
|
||||
invoice: { invoice: bolt11 },
|
||||
payment: { invoice: bolt11 },
|
||||
},
|
||||
{
|
||||
nostrTools,
|
||||
getAmountFromBolt11: () => 900,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.status, "passed");
|
||||
assert.equal(result.event, receiptEvent);
|
||||
assert.deepEqual(result.checkedRelays, relays);
|
||||
}
|
||||
|
||||
async function testValidateZapReceiptRejectsMismatchedDescriptionHash() {
|
||||
const relays = ["wss://relay.validation"];
|
||||
const zapRequestEvent = {
|
||||
kind: 9734,
|
||||
pubkey: "f".repeat(64),
|
||||
content: "",
|
||||
created_at: 1_700_000_000,
|
||||
tags: [
|
||||
["p", "a".repeat(64)],
|
||||
["amount", String(1_000_000)],
|
||||
["relays", ...relays],
|
||||
],
|
||||
};
|
||||
const zapRequestString = JSON.stringify(zapRequestEvent);
|
||||
const mismatchHash = "aa".repeat(32);
|
||||
const bolt11 = buildTestBolt11Invoice({ amountCode: "10u", descriptionHashHex: mismatchHash });
|
||||
|
||||
const receiptEvent = {
|
||||
kind: 9735,
|
||||
pubkey: "b".repeat(64),
|
||||
tags: [
|
||||
["bolt11", bolt11],
|
||||
["description", zapRequestString],
|
||||
],
|
||||
};
|
||||
|
||||
const nostrTools = {
|
||||
validateEvent: () => true,
|
||||
verifyEvent: () => true,
|
||||
SimplePool: class {
|
||||
async list() {
|
||||
return [receiptEvent];
|
||||
}
|
||||
close() {}
|
||||
},
|
||||
};
|
||||
|
||||
const result = await validateZapReceipt(
|
||||
{
|
||||
zapRequest: zapRequestString,
|
||||
amountSats: 1_000,
|
||||
metadata: { nostrPubkey: receiptEvent.pubkey },
|
||||
invoice: { invoice: bolt11 },
|
||||
payment: { invoice: bolt11 },
|
||||
},
|
||||
{
|
||||
nostrTools,
|
||||
getAmountFromBolt11: () => 1_000,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.status, "failed");
|
||||
assert.match(result.reason || "", /description hash/i);
|
||||
}
|
||||
|
||||
function testExtractDescriptionHashFromBolt11() {
|
||||
const descriptionHashHex = "bb".repeat(32);
|
||||
const invoice = buildTestBolt11Invoice({ amountCode: "1m", descriptionHashHex });
|
||||
const extracted = validatorTesting.extractDescriptionHashFromBolt11(invoice);
|
||||
assert.equal(extracted, descriptionHashHex);
|
||||
}
|
||||
|
||||
await testSplitMath();
|
||||
await testBech32LightningAddressZapTag();
|
||||
await testUrlLightningAddressZapTag();
|
||||
@@ -651,5 +833,8 @@ await testMissingAddress();
|
||||
await testWalletFailure();
|
||||
await testStringFeeOverride();
|
||||
await testPlatformShareFailure();
|
||||
await testValidateZapReceiptSuccess();
|
||||
await testValidateZapReceiptRejectsMismatchedDescriptionHash();
|
||||
testExtractDescriptionHashFromBolt11();
|
||||
|
||||
console.log("zap-split tests passed");
|
||||
Reference in new issue
Block a user