From 8879c3a46d486faeb18d464f8fc6f75db6c6d7f4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 27 Oct 2025 21:41:44 -0400 Subject: [PATCH] Handle zap allowance exhaustion --- js/channelProfile.js | 68 ++++++++++++++---- js/payments/zapSharedState.js | 94 ++++++++++++++++++++++++ js/ui/zapController.js | 127 ++++++++++++++++++++++----------- tests/video-modal-zap.test.mjs | 74 +++++++++++++++++++ 4 files changed, 305 insertions(+), 58 deletions(-) diff --git a/js/channelProfile.js b/js/channelProfile.js index 1f64d855..c280050e 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -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; diff --git a/js/payments/zapSharedState.js b/js/payments/zapSharedState.js index 21cc7746..f804d1a1 100644 --- a/js/payments/zapSharedState.js +++ b/js/payments/zapSharedState.js @@ -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}`; +} diff --git a/js/ui/zapController.js b/js/ui/zapController.js index b490e2a9..1b27377a 100644 --- a/js/ui/zapController.js +++ b/js/ui/zapController.js @@ -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); + } } } diff --git a/tests/video-modal-zap.test.mjs b/tests/video-modal-zap.test.mjs index bd21c978..4481ae77 100644 --- a/tests/video-modal-zap.test.mjs +++ b/tests/video-modal-zap.test.mjs @@ -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);