mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +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
@@ -0,0 +1,447 @@
|
||||
// js/payments/zapReceiptValidator.js
|
||||
|
||||
import { nostrToolsReady } from "../nostrToolsBootstrap.js";
|
||||
import { normalizeToolkitCandidate, readToolkitFromScope } from "../nostr/toolkit.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import { bech32 } from "@scure/base";
|
||||
import { sha256 } from "@noble/hashes/sha256";
|
||||
import { bytesToHex } from "@noble/hashes/utils";
|
||||
|
||||
const ZAP_RECEIPT_KIND = 9735;
|
||||
const BOLT11_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
const globalScope =
|
||||
typeof window !== "undefined"
|
||||
? window
|
||||
: typeof globalThis !== "undefined"
|
||||
? globalThis
|
||||
: null;
|
||||
|
||||
const nostrToolsReadySource =
|
||||
globalScope &&
|
||||
globalScope.nostrToolsReady &&
|
||||
typeof globalScope.nostrToolsReady.then === "function"
|
||||
? globalScope.nostrToolsReady
|
||||
: nostrToolsReady;
|
||||
|
||||
let cachedTools = null;
|
||||
|
||||
function rememberTools(candidate) {
|
||||
const normalized = normalizeToolkitCandidate(candidate);
|
||||
if (normalized) {
|
||||
cachedTools = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureNostrTools() {
|
||||
if (cachedTools) {
|
||||
return cachedTools;
|
||||
}
|
||||
try {
|
||||
const result = await nostrToolsReadySource;
|
||||
rememberTools(result);
|
||||
} catch (error) {
|
||||
userLogger.warn("[zapReceiptValidator] Failed to await nostr tools bootstrap.", error);
|
||||
}
|
||||
if (!cachedTools) {
|
||||
rememberTools(readToolkitFromScope(globalScope));
|
||||
}
|
||||
return cachedTools;
|
||||
}
|
||||
|
||||
function computeZapRequestHash(zapRequest) {
|
||||
const payload = typeof zapRequest === "string" ? zapRequest : "";
|
||||
const data = textEncoder.encode(payload);
|
||||
return bytesToHex(sha256(data));
|
||||
}
|
||||
|
||||
function normalizeRelayList(relays) {
|
||||
if (!Array.isArray(relays)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set();
|
||||
const normalized = [];
|
||||
for (const relay of relays) {
|
||||
if (typeof relay !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = relay.trim();
|
||||
if (!trimmed || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseZapRequest(zapRequest) {
|
||||
if (typeof zapRequest !== "string" || !zapRequest.trim()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(zapRequest);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRelayUrls(zapEvent) {
|
||||
if (!zapEvent || typeof zapEvent !== "object" || !Array.isArray(zapEvent.tags)) {
|
||||
return [];
|
||||
}
|
||||
const relaysTag = zapEvent.tags.find((tag) => Array.isArray(tag) && tag[0] === "relays");
|
||||
if (!relaysTag) {
|
||||
return [];
|
||||
}
|
||||
return normalizeRelayList(relaysTag.slice(1));
|
||||
}
|
||||
|
||||
function normalizeInvoiceValue(invoice) {
|
||||
if (typeof invoice !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = invoice.trim();
|
||||
return trimmed ? trimmed : "";
|
||||
}
|
||||
|
||||
function normalizePubkey(pubkey) {
|
||||
if (typeof pubkey !== "string") {
|
||||
return "";
|
||||
}
|
||||
const trimmed = pubkey.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : "";
|
||||
}
|
||||
|
||||
function extractDescriptionHashFromBolt11(bolt11) {
|
||||
const normalized = normalizeInvoiceValue(bolt11).toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = bech32.decode(normalized, 2000);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const words = Array.isArray(decoded?.words) ? decoded.words.slice() : [];
|
||||
if (!words.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (words.length <= 104) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataWords = words.slice(0, -104);
|
||||
if (dataWords.length <= 7) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 null;
|
||||
}
|
||||
if (index + 1 >= dataWords.length) {
|
||||
return null;
|
||||
}
|
||||
const length = (dataWords[index] << 5) + dataWords[index + 1];
|
||||
index += 2;
|
||||
if (length < 0 || index + length > dataWords.length) {
|
||||
return null;
|
||||
}
|
||||
const dataSlice = dataWords.slice(index, index + length);
|
||||
index += length;
|
||||
const tag = BOLT11_CHARSET[tagCode];
|
||||
if (tag === "h") {
|
||||
try {
|
||||
const bytes = bech32.fromWords(dataSlice);
|
||||
return bytesToHex(Uint8Array.from(bytes));
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getInvoiceAmountSats(bolt11, tools, override) {
|
||||
if (typeof override === "function") {
|
||||
try {
|
||||
const overrideResult = override(bolt11);
|
||||
const normalized = Number(overrideResult);
|
||||
if (Number.isFinite(normalized)) {
|
||||
return Math.max(0, Math.round(normalized));
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn("[zapReceiptValidator] Custom bolt11 amount parser failed.", error);
|
||||
}
|
||||
}
|
||||
const fn = tools?.nip57?.getSatoshisAmountFromBolt11;
|
||||
if (typeof fn !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const result = Number(fn(bolt11));
|
||||
if (!Number.isFinite(result)) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, Math.round(result));
|
||||
} catch (error) {
|
||||
userLogger.warn("[zapReceiptValidator] Failed to parse bolt11 amount via nostr-tools.", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildValidationResult({
|
||||
status = "skipped",
|
||||
reason = null,
|
||||
event = null,
|
||||
relays = [],
|
||||
} = {}) {
|
||||
return {
|
||||
status,
|
||||
reason,
|
||||
event,
|
||||
checkedRelays: Array.isArray(relays) ? relays.slice() : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateZapReceipt(context = {}, overrides = {}) {
|
||||
const {
|
||||
zapRequest,
|
||||
amountSats,
|
||||
metadata,
|
||||
invoice,
|
||||
payment,
|
||||
} = context || {};
|
||||
|
||||
const zapRequestString = typeof zapRequest === "string" ? zapRequest : "";
|
||||
if (!zapRequestString) {
|
||||
return buildValidationResult({
|
||||
status: "skipped",
|
||||
reason: "Zap request was not provided.",
|
||||
});
|
||||
}
|
||||
|
||||
const parsedZapRequest = parseZapRequest(zapRequestString);
|
||||
if (!parsedZapRequest) {
|
||||
return buildValidationResult({
|
||||
status: "failed",
|
||||
reason: "Zap request JSON could not be parsed.",
|
||||
});
|
||||
}
|
||||
|
||||
const relayUrls = resolveRelayUrls(parsedZapRequest);
|
||||
if (!relayUrls.length) {
|
||||
return buildValidationResult({
|
||||
status: "failed",
|
||||
reason: "Zap request did not specify any relays.",
|
||||
});
|
||||
}
|
||||
|
||||
const invoiceCandidate =
|
||||
normalizeInvoiceValue(invoice?.invoice ?? invoice) ||
|
||||
normalizeInvoiceValue(payment?.invoice ?? payment?.bolt11 ?? "");
|
||||
if (!invoiceCandidate) {
|
||||
return buildValidationResult({
|
||||
status: "failed",
|
||||
reason: "Zap invoice was not available for validation.",
|
||||
relays: relayUrls,
|
||||
});
|
||||
}
|
||||
|
||||
const expectedHash = computeZapRequestHash(zapRequestString);
|
||||
const decodeHash = overrides.decodeDescriptionHash || extractDescriptionHashFromBolt11;
|
||||
let invoiceHash = null;
|
||||
try {
|
||||
invoiceHash = decodeHash(invoiceCandidate);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[zapReceiptValidator] Custom description hash decoder threw an error.",
|
||||
error
|
||||
);
|
||||
invoiceHash = null;
|
||||
}
|
||||
|
||||
if (!invoiceHash) {
|
||||
return buildValidationResult({
|
||||
status: "failed",
|
||||
reason: "Zap invoice did not include a description hash.",
|
||||
relays: relayUrls,
|
||||
});
|
||||
}
|
||||
|
||||
if (invoiceHash !== expectedHash) {
|
||||
return buildValidationResult({
|
||||
status: "failed",
|
||||
reason: "Zap invoice description hash did not match the zap request.",
|
||||
relays: relayUrls,
|
||||
});
|
||||
}
|
||||
|
||||
const tools = overrides.nostrTools || (await ensureNostrTools());
|
||||
if (!tools || typeof tools !== "object") {
|
||||
return buildValidationResult({
|
||||
status: "failed",
|
||||
reason: "nostr-tools helpers are unavailable.",
|
||||
relays: relayUrls,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof tools.validateEvent !== "function" || typeof tools.verifyEvent !== "function") {
|
||||
return buildValidationResult({
|
||||
status: "failed",
|
||||
reason: "nostr-tools validation helpers are missing.",
|
||||
relays: relayUrls,
|
||||
});
|
||||
}
|
||||
|
||||
const expectedAmount = Math.max(0, Math.round(Number(amountSats || 0)));
|
||||
if (expectedAmount > 0) {
|
||||
const amount = getInvoiceAmountSats(invoiceCandidate, tools, overrides.getAmountFromBolt11);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
return buildValidationResult({
|
||||
status: "failed",
|
||||
reason: "Zap invoice amount could not be determined.",
|
||||
relays: relayUrls,
|
||||
});
|
||||
}
|
||||
if (amount !== expectedAmount) {
|
||||
return buildValidationResult({
|
||||
status: "failed",
|
||||
reason: "Zap invoice amount did not match the expected share.",
|
||||
relays: relayUrls,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const expectedPubkey = normalizePubkey(metadata?.nostrPubkey);
|
||||
if (!expectedPubkey) {
|
||||
return buildValidationResult({
|
||||
status: "failed",
|
||||
reason: "LNURL metadata is missing a nostrPubkey for receipt validation.",
|
||||
relays: relayUrls,
|
||||
});
|
||||
}
|
||||
|
||||
let pool;
|
||||
try {
|
||||
if (typeof overrides.createPool === "function") {
|
||||
pool = overrides.createPool(tools);
|
||||
} else if (typeof tools.SimplePool === "function") {
|
||||
pool = new tools.SimplePool();
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn("[zapReceiptValidator] Failed to create SimplePool instance.", error);
|
||||
pool = null;
|
||||
}
|
||||
|
||||
if (!pool || typeof pool.list !== "function") {
|
||||
return buildValidationResult({
|
||||
status: "failed",
|
||||
reason: "Unable to initialize a relay pool for receipt validation.",
|
||||
relays: relayUrls,
|
||||
});
|
||||
}
|
||||
|
||||
const filters = [
|
||||
{
|
||||
kinds: [ZAP_RECEIPT_KIND],
|
||||
"#bolt11": [invoiceCandidate.toLowerCase()],
|
||||
limit: 10,
|
||||
},
|
||||
];
|
||||
|
||||
let events = [];
|
||||
try {
|
||||
if (typeof overrides.listEvents === "function") {
|
||||
events = await overrides.listEvents(pool, relayUrls, filters);
|
||||
} else {
|
||||
events = await pool.list(relayUrls, filters);
|
||||
}
|
||||
} catch (error) {
|
||||
userLogger.warn("[zapReceiptValidator] Failed to fetch zap receipts from relays.", error);
|
||||
events = [];
|
||||
} finally {
|
||||
try {
|
||||
if (typeof pool.close === "function") {
|
||||
pool.close(relayUrls);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore close errors.
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(events) || !events.length) {
|
||||
return buildValidationResult({
|
||||
status: "failed",
|
||||
reason: "No zap receipt was published on the advertised relays.",
|
||||
relays: relayUrls,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedBolt = invoiceCandidate.toLowerCase();
|
||||
const successfulEvent = events.find((event) => {
|
||||
if (!event || typeof event !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (event.kind !== ZAP_RECEIPT_KIND) {
|
||||
return false;
|
||||
}
|
||||
if (!tools.validateEvent(event) || !tools.verifyEvent(event)) {
|
||||
return false;
|
||||
}
|
||||
const pubkey = normalizePubkey(event.pubkey);
|
||||
if (!pubkey || pubkey !== expectedPubkey) {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(event.tags)) {
|
||||
return false;
|
||||
}
|
||||
const boltTag = event.tags.find(
|
||||
(tag) =>
|
||||
Array.isArray(tag) &&
|
||||
tag[0] === "bolt11" &&
|
||||
typeof tag[1] === "string" &&
|
||||
tag[1].trim().toLowerCase() === normalizedBolt
|
||||
);
|
||||
if (!boltTag) {
|
||||
return false;
|
||||
}
|
||||
const descriptionTag = event.tags.find(
|
||||
(tag) => Array.isArray(tag) && tag[0] === "description" && typeof tag[1] === "string"
|
||||
);
|
||||
if (!descriptionTag) {
|
||||
return false;
|
||||
}
|
||||
const descriptionValue = descriptionTag[1];
|
||||
return descriptionValue === zapRequestString;
|
||||
});
|
||||
|
||||
if (!successfulEvent) {
|
||||
return buildValidationResult({
|
||||
status: "failed",
|
||||
reason: "No compliant zap receipt matched the zap request.",
|
||||
relays: relayUrls,
|
||||
});
|
||||
}
|
||||
|
||||
return buildValidationResult({
|
||||
status: "passed",
|
||||
event: successfulEvent,
|
||||
relays: relayUrls,
|
||||
});
|
||||
}
|
||||
|
||||
export const __TESTING__ = Object.freeze({
|
||||
computeZapRequestHash,
|
||||
extractDescriptionHashFromBolt11,
|
||||
});
|
||||
+53
-2
@@ -13,6 +13,7 @@ import { ensureWallet, sendPayment } from "./nwcClient.js";
|
||||
import { getPlatformLightningAddress } from "./platformAddress.js";
|
||||
import { RELAY_URLS } from "../nostr/toolkit.js";
|
||||
import { userLogger } from "../utils/logger.js";
|
||||
import { validateZapReceipt } from "./zapReceiptValidator.js";
|
||||
import {
|
||||
clampPercent,
|
||||
parsePercentValue,
|
||||
@@ -31,6 +32,7 @@ const DEFAULT_DEPS = Object.freeze({
|
||||
}),
|
||||
wallet: Object.freeze({ ensureWallet, sendPayment }),
|
||||
platformAddress: Object.freeze({ getPlatformLightningAddress }),
|
||||
validator: Object.freeze({ validateZapReceipt }),
|
||||
});
|
||||
|
||||
const globalScope =
|
||||
@@ -209,7 +211,17 @@ function mergeDependencies(overrides = {}) {
|
||||
...(overrides.platformAddress || {}),
|
||||
};
|
||||
|
||||
return { lnurl: lnurlDeps, wallet: walletDeps, platformAddress: platformDeps };
|
||||
const validatorDeps = {
|
||||
...DEFAULT_DEPS.validator,
|
||||
...(overrides.validator || {}),
|
||||
};
|
||||
|
||||
return {
|
||||
lnurl: lnurlDeps,
|
||||
wallet: walletDeps,
|
||||
platformAddress: platformDeps,
|
||||
validator: validatorDeps,
|
||||
};
|
||||
}
|
||||
|
||||
function getNostrTools() {
|
||||
@@ -443,7 +455,35 @@ async function processShare({
|
||||
lnurl: lnurlTag,
|
||||
});
|
||||
|
||||
return {
|
||||
let validation = null;
|
||||
if (typeof deps.validator.validateZapReceipt === "function") {
|
||||
try {
|
||||
validation = await deps.validator.validateZapReceipt(
|
||||
{
|
||||
zapRequest,
|
||||
amountSats,
|
||||
metadata,
|
||||
invoice,
|
||||
payment,
|
||||
lnurl: resolved,
|
||||
recipientType,
|
||||
address,
|
||||
}
|
||||
);
|
||||
} catch (validationError) {
|
||||
userLogger.warn(
|
||||
`[zapSplit] Zap receipt validation threw for ${recipientType} share.`,
|
||||
validationError
|
||||
);
|
||||
validation = {
|
||||
status: "failed",
|
||||
reason: "Zap receipt validation encountered an unexpected error.",
|
||||
event: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const receipt = {
|
||||
recipientType,
|
||||
address,
|
||||
amount: amountSats,
|
||||
@@ -454,6 +494,17 @@ async function processShare({
|
||||
zapRequest,
|
||||
status: "success",
|
||||
};
|
||||
|
||||
if (validation && typeof validation === "object") {
|
||||
receipt.validation = validation;
|
||||
if (validation.status === "passed" && validation.event) {
|
||||
receipt.validatedEvent = validation.event;
|
||||
} else if (validation.status === "failed") {
|
||||
receipt.validationFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return receipt;
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
`[zapSplit] Failed to send ${recipientType} zap share.`,
|
||||
|
||||
+103
-14
@@ -3506,7 +3506,43 @@ export class VideoModal {
|
||||
return;
|
||||
}
|
||||
|
||||
receipts.forEach((receipt) => {
|
||||
const normalized = receipts.filter(
|
||||
(receipt) => receipt && typeof receipt === "object"
|
||||
);
|
||||
|
||||
const validatedReceipts = normalized.filter(
|
||||
(receipt) => receipt?.validation?.status === "passed" && receipt.validation.event
|
||||
);
|
||||
|
||||
const paymentFailures = normalized.filter((receipt) => {
|
||||
if (!receipt) {
|
||||
return false;
|
||||
}
|
||||
const status =
|
||||
typeof receipt.status === "string" ? receipt.status.toLowerCase() : "";
|
||||
return status && status !== "success";
|
||||
});
|
||||
|
||||
const unvalidatedReceipts = normalized.filter((receipt) => {
|
||||
if (!receipt) {
|
||||
return false;
|
||||
}
|
||||
const status =
|
||||
typeof receipt.status === "string" ? receipt.status.toLowerCase() : "success";
|
||||
if (status !== "success" && status !== "") {
|
||||
return false;
|
||||
}
|
||||
const validationStatus =
|
||||
typeof receipt.validation?.status === "string"
|
||||
? receipt.validation.status.toLowerCase()
|
||||
: "";
|
||||
if (!validationStatus || validationStatus === "skipped") {
|
||||
return false;
|
||||
}
|
||||
return validationStatus !== "passed";
|
||||
});
|
||||
|
||||
const renderReceiptItem = ({ receipt, tone }) => {
|
||||
const li = this.document.createElement("li");
|
||||
li.className = "rounded border border-border p-3 bg-overlay-panel-soft";
|
||||
|
||||
@@ -3528,11 +3564,16 @@ export class VideoModal {
|
||||
)} sats`;
|
||||
|
||||
const status = this.document.createElement("span");
|
||||
const isSuccess = receipt.status
|
||||
? receipt.status === "success"
|
||||
: !receipt.error;
|
||||
status.textContent = isSuccess ? "Success" : "Failed";
|
||||
status.className = isSuccess ? "text-info" : "text-critical";
|
||||
if (tone === "validated") {
|
||||
status.textContent = "Validated";
|
||||
status.className = "text-info";
|
||||
} else if (tone === "failed") {
|
||||
status.textContent = "Failed";
|
||||
status.className = "text-critical";
|
||||
} else {
|
||||
status.textContent = "Pending";
|
||||
status.className = "text-muted";
|
||||
}
|
||||
|
||||
header.appendChild(shareLabel);
|
||||
header.appendChild(status);
|
||||
@@ -3547,7 +3588,14 @@ export class VideoModal {
|
||||
|
||||
const detail = this.document.createElement("p");
|
||||
detail.className = "mt-2 text-xs text-muted";
|
||||
if (isSuccess) {
|
||||
if (tone === "failed") {
|
||||
const errorMessage =
|
||||
(receipt.error && receipt.error.message) ||
|
||||
(typeof receipt.error === "string"
|
||||
? receipt.error
|
||||
: "Payment failed.");
|
||||
detail.textContent = errorMessage;
|
||||
} else {
|
||||
let detailMessage = "Invoice settled.";
|
||||
const preimage = receipt.payment?.result?.preimage;
|
||||
if (typeof preimage === "string" && preimage) {
|
||||
@@ -3556,18 +3604,59 @@ export class VideoModal {
|
||||
}`;
|
||||
}
|
||||
detail.textContent = detailMessage;
|
||||
} else {
|
||||
const errorMessage =
|
||||
(receipt.error && receipt.error.message) ||
|
||||
(typeof receipt.error === "string"
|
||||
? receipt.error
|
||||
: "Payment failed.");
|
||||
detail.textContent = errorMessage;
|
||||
}
|
||||
li.appendChild(detail);
|
||||
|
||||
this.modalZapReceipts.appendChild(li);
|
||||
};
|
||||
|
||||
validatedReceipts.forEach((receipt) => {
|
||||
renderReceiptItem({ receipt, tone: "validated" });
|
||||
});
|
||||
|
||||
paymentFailures.forEach((receipt) => {
|
||||
renderReceiptItem({ receipt, tone: "failed" });
|
||||
});
|
||||
|
||||
if (!validatedReceipts.length && !paymentFailures.length && !unvalidatedReceipts.length) {
|
||||
const empty = this.document.createElement("li");
|
||||
empty.className = "text-sm text-text";
|
||||
empty.textContent = partial
|
||||
? "No zap receipts available."
|
||||
: "No zap receipts published yet.";
|
||||
this.modalZapReceipts.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
if (unvalidatedReceipts.length) {
|
||||
const warning = this.document.createElement("li");
|
||||
warning.className =
|
||||
"rounded border border-border p-3 bg-overlay-panel-soft text-xs text-warning-strong";
|
||||
const summaries = unvalidatedReceipts.map((receipt) => {
|
||||
const shareType = receipt.recipientType || receipt.type || "creator";
|
||||
const label =
|
||||
shareType === "platform"
|
||||
? "Platform"
|
||||
: shareType === "creator"
|
||||
? "Creator"
|
||||
: "Lightning";
|
||||
const address =
|
||||
typeof receipt.address === "string" && receipt.address
|
||||
? ` (${receipt.address})`
|
||||
: "";
|
||||
const reason =
|
||||
typeof receipt.validation?.reason === "string" && receipt.validation.reason
|
||||
? ` — ${receipt.validation.reason}`
|
||||
: " — Awaiting compliant receipt.";
|
||||
return `${label}${address}${reason}`;
|
||||
});
|
||||
|
||||
const intro = validatedReceipts.length
|
||||
? "Awaiting validated zap receipt for remaining share(s)."
|
||||
: "No validated zap receipts yet.";
|
||||
warning.textContent = `${intro} ${summaries.join(" ")}`.trim();
|
||||
this.modalZapReceipts.appendChild(warning);
|
||||
}
|
||||
}
|
||||
|
||||
setZapPending(pending) {
|
||||
|
||||
@@ -332,6 +332,60 @@ export default class ZapController {
|
||||
return;
|
||||
}
|
||||
|
||||
const unvalidatedReceipts = receipts.filter((receipt) => {
|
||||
if (!receipt || typeof receipt !== "object") {
|
||||
return false;
|
||||
}
|
||||
const status =
|
||||
typeof receipt.status === "string" ? receipt.status.toLowerCase() : "success";
|
||||
if (status !== "success" && status !== "") {
|
||||
return false;
|
||||
}
|
||||
const validationStatus =
|
||||
typeof receipt.validation?.status === "string"
|
||||
? receipt.validation.status.toLowerCase()
|
||||
: "";
|
||||
if (!validationStatus || validationStatus === "skipped") {
|
||||
return false;
|
||||
}
|
||||
return validationStatus !== "passed";
|
||||
});
|
||||
|
||||
if (unvalidatedReceipts.length) {
|
||||
this.videoModal?.renderZapReceipts(receipts, { partial: true });
|
||||
|
||||
const warningSummary = unvalidatedReceipts
|
||||
.map((receipt) => {
|
||||
const type = receipt.recipientType || receipt.type || "creator";
|
||||
const label =
|
||||
type === "platform" ? "Platform" : type === "creator" ? "Creator" : "Lightning";
|
||||
const address =
|
||||
typeof receipt.address === "string" && receipt.address
|
||||
? ` (${receipt.address})`
|
||||
: "";
|
||||
const reason =
|
||||
typeof receipt.validation?.reason === "string" && receipt.validation.reason
|
||||
? ` — ${receipt.validation.reason}`
|
||||
: " — Awaiting compliant receipt.";
|
||||
return `${label}${address}${reason}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
const warningMessage = warningSummary
|
||||
? `Awaiting validated zap receipt. ${warningSummary}`
|
||||
: "Awaiting validated zap receipt from the recipient.";
|
||||
|
||||
this.videoModal?.setZapStatus(warningMessage, "warning");
|
||||
this.notifyError(warningMessage);
|
||||
|
||||
this.resetRetryState();
|
||||
this.modalZapCommentValue = "";
|
||||
this.videoModal?.resetZapForm({ amount: "", comment: "" });
|
||||
this.videoModal?.setZapCompleted(true);
|
||||
this.applyDefaultAmount();
|
||||
return;
|
||||
}
|
||||
|
||||
this.videoModal?.renderZapReceipts(receipts, { partial: false });
|
||||
|
||||
const creatorShare = context.shares.creatorShare;
|
||||
|
||||
@@ -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