Handle zap allowance exhaustion

This commit is contained in:
thePR0M3TH3AN committed 2025-10-27 21:41:44 -04:00
1 parent 2c5213aa17
commit 8879c3a46d
4 files changed
+305 -58

No files matched your search

+53 -15
View File
@@ -37,10 +37,12 @@ import {
getCachedLightningEntry,
getCachedMetadataByUrl,
getCachedPlatformLightningAddress,
isZapAllowanceExhaustedError,
isMetadataEntryFresh,
normalizeLightningAddressKey,
rememberLightningMetadata,
setCachedPlatformLightningAddress,
buildZapAllowanceExhaustedMessage,
validateInvoiceAmount
} from "./payments/zapSharedState.js";
import { splitAndZap } from "./payments/zapSplit.js";
@@ -2570,13 +2572,18 @@ function createZapDependencies({
}
return payment;
} catch (error) {
const allowanceExhausted = isZapAllowanceExhaustedError(error);
if (allowanceExhausted) {
error.__zapAllowanceExhausted = true;
}
if (Array.isArray(shareTracker)) {
shareTracker.push({
type: shareType,
status: "error",
amount: shareAmount,
address,
error
error,
allowanceExhausted
});
}
if (
@@ -2993,23 +3000,54 @@ async function handleZapSend(event) {
const failureShares = tracker.filter(
(entry) => entry && entry.status !== "success" && entry.amount > 0
);
const allowanceExhausted =
isZapAllowanceExhaustedError(error) ||
failureShares.some((share) =>
isZapAllowanceExhaustedError(share?.error)
);
if (allowanceExhausted) {
resetZapRetryState();
}
if (failureShares.length) {
markZapRetryPending(failureShares);
const summary = failureShares
.map((share) => `${describeShareType(share.type)} ${share.amount} sats`)
.join(", ");
const tone = tracker.length > failureShares.length ? "warning" : "error";
const statusMessage =
tracker.length > failureShares.length
? `Partial zap failure. Press Send again to retry: ${summary}.`
: `Zap failed. Press Send again to retry: ${summary}.`;
setZapStatus(statusMessage, tone);
app?.showError?.(error?.message || statusMessage);
if (allowanceExhausted) {
const allowanceShare = failureShares.find((share) =>
isZapAllowanceExhaustedError(share?.error)
);
const allowanceMessage = buildZapAllowanceExhaustedMessage(
isZapAllowanceExhaustedError(error) ? error : allowanceShare?.error
);
const partialSuccess = tracker.length > failureShares.length;
const tone = partialSuccess ? "warning" : "error";
const statusMessage = partialSuccess
? `Some zap shares succeeded before your wallet allowance was exhausted. ${allowanceMessage}`
: allowanceMessage;
setZapStatus(statusMessage, tone);
app?.showError?.(statusMessage);
} else {
markZapRetryPending(failureShares);
const summary = failureShares
.map((share) => `${describeShareType(share.type)} ${share.amount} sats`)
.join(", ");
const tone = tracker.length > failureShares.length ? "warning" : "error";
const statusMessage =
tracker.length > failureShares.length
? `Partial zap failure. Press Send again to retry: ${summary}.`
: `Zap failed. Press Send again to retry: ${summary}.`;
setZapStatus(statusMessage, tone);
app?.showError?.(error?.message || statusMessage);
}
} else {
resetZapRetryState();
const message = error?.message || "Zap failed. Please try again.";
setZapStatus(message, "error");
app?.showError?.(message);
if (allowanceExhausted) {
const allowanceMessage = buildZapAllowanceExhaustedMessage(error);
setZapStatus(allowanceMessage, "error");
app?.showError?.(allowanceMessage);
} else {
const message = error?.message || "Zap failed. Please try again.";
setZapStatus(message, "error");
app?.showError?.(message);
}
}
} finally {
zapInFlight = false;
+94
View File
@@ -162,3 +162,97 @@ export function clearZapCaches() {
}
export { validateInvoiceAmount };
function extractErrorMessage(error) {
if (!error) {
return "";
}
if (typeof error === "string") {
return error;
}
if (typeof error?.message === "string") {
return error.message;
}
if (typeof error?.reason === "string") {
return error.reason;
}
if (typeof error?.error?.message === "string") {
return error.error.message;
}
if (typeof error?.data?.error === "string") {
return error.data.error;
}
if (typeof error?.data?.error?.message === "string") {
return error.data.error.message;
}
return "";
}
const ALLOWANCE_ERROR_PATTERNS = [
"budget exceeded",
"allowance exceeded",
"allowance exhausted",
"allowance spent",
"allowance depleted",
"allowance limit reached",
"spending allowance exceeded",
"spending allowance reached",
"spending limit reached",
"quota exceeded",
];
export function isZapAllowanceExhaustedError(error) {
const message = extractErrorMessage(error);
if (!message) {
return false;
}
const normalized = message.toLowerCase();
if (ALLOWANCE_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern))) {
return true;
}
if (normalized.includes("allowance") && normalized.includes("zap")) {
if (
normalized.includes("exceed") ||
normalized.includes("exhaust") ||
normalized.includes("spent") ||
normalized.includes("deplet")
) {
return true;
}
}
if (normalized.includes("budget") && normalized.includes("zap")) {
if (
normalized.includes("exceed") ||
normalized.includes("spent") ||
normalized.includes("deplet")
) {
return true;
}
}
return false;
}
const ALLOWANCE_GUIDANCE_SUFFIX =
"Increase your wallet zap limit or reduce the platform split to continue.";
const DEFAULT_ALLOWANCE_MESSAGE =
"Your wallet's zap allowance has been exhausted. " + ALLOWANCE_GUIDANCE_SUFFIX;
export function buildZapAllowanceExhaustedMessage(error) {
const baseMessage = extractErrorMessage(error).trim();
if (!baseMessage) {
return DEFAULT_ALLOWANCE_MESSAGE;
}
const normalized = baseMessage.toLowerCase();
const punctuation = /[.!?]$/.test(baseMessage) ? "" : ".";
if (
normalized.includes("zap allowance") &&
normalized.includes("increase") &&
normalized.includes("platform split")
) {
return baseMessage;
}
if (normalized.includes("zap") && normalized.includes("allowance")) {
return `${baseMessage}${punctuation} ${ALLOWANCE_GUIDANCE_SUFFIX}`;
}
return `${baseMessage}${punctuation} ${DEFAULT_ALLOWANCE_MESSAGE}`;
}
+84 -43
View File
@@ -7,10 +7,12 @@ import {
getCachedLightningEntry,
getCachedMetadataByUrl,
getCachedPlatformLightningAddress,
isZapAllowanceExhaustedError,
isMetadataEntryFresh,
normalizeLightningAddressKey,
rememberLightningMetadata,
setCachedPlatformLightningAddress,
buildZapAllowanceExhaustedMessage,
validateInvoiceAmount,
} from "../payments/zapSharedState.js";
import {
@@ -557,31 +559,36 @@ export default class ZapController {
shareType === "platform"
? platformEntry?.address || getCachedPlatformLightningAddress()
: creatorEntry?.address || lightningAddress;
try {
const payment = await sendPaymentFn(bolt11, params);
if (Array.isArray(shareTracker)) {
shareTracker.push({
type: shareType,
try {
const payment = await sendPaymentFn(bolt11, params);
if (Array.isArray(shareTracker)) {
shareTracker.push({
type: shareType,
status: "success",
amount,
address,
payment,
});
}
return payment;
} catch (error) {
if (Array.isArray(shareTracker)) {
shareTracker.push({
type: shareType,
status: "error",
amount,
address,
error,
});
}
if (
typeof error?.message === "string" &&
error.message.toLowerCase().includes("timed out")
});
}
return payment;
} catch (error) {
const allowanceExhausted = isZapAllowanceExhaustedError(error);
if (allowanceExhausted) {
error.__zapAllowanceExhausted = true;
}
if (Array.isArray(shareTracker)) {
shareTracker.push({
type: shareType,
status: "error",
amount,
address,
error,
allowanceExhausted,
});
}
if (
typeof error?.message === "string" &&
error.message.toLowerCase().includes("timed out")
) {
try {
resetWalletClientFn();
@@ -982,31 +989,65 @@ export default class ZapController {
const failureShares = tracker.filter(
(entry) => entry && entry.status !== "success" && entry.amount > 0
);
const allowanceExhausted =
isZapAllowanceExhaustedError(error) ||
failureShares.some((share) =>
isZapAllowanceExhaustedError(share?.error)
);
if (allowanceExhausted) {
this.resetRetryState();
}
if (failureShares.length) {
this.markRetryPending(failureShares, comment);
const summary = failureShares
.map((share) => {
const label =
share.type === "platform"
? "Platform"
: share.type === "creator"
? "Creator"
: "Lightning";
return `${label} ${share.amount} sats`;
})
.join(", ");
const tone = tracker.length > failureShares.length ? "warning" : "error";
const statusMessage =
tracker.length > failureShares.length
? `Partial zap failure. Press Send again to retry: ${summary}.`
: `Zap failed. Press Send again to retry: ${summary}.`;
this.videoModal?.setZapStatus(statusMessage, tone);
this.notifyError(error?.message || statusMessage);
if (allowanceExhausted) {
const allowanceShare = failureShares.find((share) =>
isZapAllowanceExhaustedError(share?.error)
);
const allowanceSource = isZapAllowanceExhaustedError(error)
? error
: allowanceShare?.error;
const allowanceMessage = buildZapAllowanceExhaustedMessage(
allowanceSource
);
const partialSuccess = tracker.length > failureShares.length;
const tone = partialSuccess ? "warning" : "error";
const statusMessage = partialSuccess
? `Some zap shares succeeded before your wallet allowance was exhausted. ${allowanceMessage}`
: allowanceMessage;
this.videoModal?.setZapStatus(statusMessage, tone);
this.notifyError(statusMessage);
} else {
this.markRetryPending(failureShares, comment);
const summary = failureShares
.map((share) => {
const label =
share.type === "platform"
? "Platform"
: share.type === "creator"
? "Creator"
: "Lightning";
return `${label} ${share.amount} sats`;
})
.join(", ");
const tone = tracker.length > failureShares.length ? "warning" : "error";
const statusMessage =
tracker.length > failureShares.length
? `Partial zap failure. Press Send again to retry: ${summary}.`
: `Zap failed. Press Send again to retry: ${summary}.`;
this.videoModal?.setZapStatus(statusMessage, tone);
this.notifyError(error?.message || statusMessage);
}
} else {
this.resetRetryState();
const message = error?.message || "Zap failed. Please try again.";
this.videoModal?.setZapStatus(message, "error");
this.notifyError(message);
if (allowanceExhausted) {
const allowanceMessage = buildZapAllowanceExhaustedMessage(error);
this.videoModal?.setZapStatus(allowanceMessage, "error");
this.notifyError(allowanceMessage);
} else {
const message = error?.message || "Zap failed. Please try again.";
this.videoModal?.setZapStatus(message, "error");
this.notifyError(message);
}
}
}
+74
View File
@@ -543,4 +543,78 @@ await (async () => {
app.destroy();
})();
await (async () => {
// Test: allowance exhaustion surfaces guidance and skips retry queue
const { app, modalStub, creatorAddress, platformAddress } = await createApp({
splitAndZap: async () => ({ receipts: [] }),
});
const shareError = new Error("Budget exceeded");
const tracker = [
{
type: "creator",
status: "success",
amount: 900,
address: creatorAddress,
payment: { preimage: "aa".repeat(16) },
},
{
type: "platform",
status: "error",
amount: 100,
address: platformAddress,
error: shareError,
},
];
const walletError = new Error("Budget exceeded");
walletError.__zapShareTracker = tracker;
modalStub.statusMessages = [];
modalStub.retryState = undefined;
app.zapController.handleZapError({
error: walletError,
amount: 1000,
comment: "",
walletSettings: { nwcUri: "nostr+walletconnect://example" },
});
const lastStatus =
modalStub.statusMessages[modalStub.statusMessages.length - 1];
assert.ok(lastStatus, "status message should be recorded");
assert.equal(
lastStatus.tone,
"warning",
"partial allowance exhaustion should produce a warning tone",
);
assert.ok(
/zap allowance/i.test(lastStatus.message),
"status message should mention zap allowance exhaustion",
);
assert.ok(
/increase your wallet zap limit or reduce the platform split/i.test(
lastStatus.message,
),
"status message should advise adjusting allowance or platform split",
);
assert.equal(
lastStatus.message.includes("Press Send again"),
false,
"allowance exhaustion should not prompt a retry",
);
assert.equal(
app.zapController.modalZapRetryState,
null,
"controller should clear retry state when allowance exhausted",
);
assert.equal(
modalStub.retryState?.pending,
false,
"modal should not mark retry pending when allowance exhausted",
);
app.destroy();
})();
process.exit(0);