mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 09: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;
|
||||
|
||||
Reference in new issue
Block a user