mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
- NWC connection was fully broken: parseNwcUri passed a hex secret to nostr-tools getPublicKey (needs Uint8Array) and threw. Convert hex->bytes. - A successful zap was shown as a red error when the kind-9735 receipt couldn't be validated. The NWC preimage already proves payment, so a paid-but-unconfirmed share is now a success-with-note, not a failure. - Receipt discovery: query by the reliably-indexed #e/#a/#p tags instead of #bolt11 (which most relays don't index), and poll a few times since the receipt is published shortly after payment. - In-flight "Sending..." status no longer mis-toned as a warning; success path fully resets the form; an in-flight-open click no longer cancels the open (which made the popover need many clicks); fee fallback verified no-bypass. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1210 lines
39 KiB
JavaScript
1210 lines
39 KiB
JavaScript
import { isDevMode } from "../config.js";
|
|
import { LOGIN_TO_ZAP_MESSAGE } from "../payments/zapMessages.js";
|
|
import {
|
|
calculateZapShares,
|
|
fetchLightningMetadata,
|
|
formatMinRequirement,
|
|
getCachedLightningEntry,
|
|
getCachedMetadataByUrl,
|
|
getCachedPlatformLightningAddress,
|
|
isZapAllowanceExhaustedError,
|
|
isMetadataEntryFresh,
|
|
normalizeLightningAddressKey,
|
|
rememberLightningMetadata,
|
|
setCachedPlatformLightningAddress,
|
|
buildZapAllowanceExhaustedMessage,
|
|
validateInvoiceAmount,
|
|
} from "../payments/zapSharedState.js";
|
|
import {
|
|
resolveLightningAddress,
|
|
fetchPayServiceData,
|
|
requestInvoice,
|
|
} from "../payments/lnurl.js";
|
|
import { getPlatformLightningAddress } from "../payments/platformAddress.js";
|
|
import { devLogger, userLogger } from "../utils/logger.js";
|
|
import {
|
|
ensureWallet as ensureWalletDefault,
|
|
sendPayment as sendPaymentDefault,
|
|
resetWalletClient as resetWalletClientDefault,
|
|
} from "../payments/nwcClient.js";
|
|
|
|
const DEFAULT_SPLIT_SUMMARY = "Enter an amount to view the split.";
|
|
|
|
export default class ZapController {
|
|
constructor({
|
|
videoModal,
|
|
getCurrentVideo,
|
|
nwcSettings,
|
|
getActiveNwcSettings,
|
|
isUserLoggedIn,
|
|
hasSessionActor,
|
|
hasActiveWalletConnection,
|
|
splitAndZap,
|
|
notifyLoginRequired,
|
|
payments = {},
|
|
callbacks = {},
|
|
requestWalletPane,
|
|
} = {}) {
|
|
this.videoModal = videoModal || null;
|
|
this.getCurrentVideo =
|
|
typeof getCurrentVideo === "function" ? getCurrentVideo : () => null;
|
|
this.nwcSettings =
|
|
nwcSettings &&
|
|
typeof nwcSettings === "object" &&
|
|
typeof nwcSettings.getActiveNwcSettings === "function"
|
|
? nwcSettings
|
|
: null;
|
|
if (this.nwcSettings) {
|
|
this.getActiveNwcSettings = () =>
|
|
this.nwcSettings.getActiveNwcSettings();
|
|
} else if (typeof getActiveNwcSettings === "function") {
|
|
this.getActiveNwcSettings = getActiveNwcSettings;
|
|
} else {
|
|
this.getActiveNwcSettings = () => ({});
|
|
}
|
|
this.isUserLoggedIn =
|
|
typeof isUserLoggedIn === "function" ? isUserLoggedIn : () => false;
|
|
this.hasSessionActor =
|
|
typeof hasSessionActor === "function" ? hasSessionActor : () => false;
|
|
if (
|
|
this.nwcSettings &&
|
|
typeof this.nwcSettings.hasActiveWalletConnection === "function"
|
|
) {
|
|
this.hasActiveWalletConnection = () =>
|
|
this.nwcSettings.hasActiveWalletConnection();
|
|
} else if (typeof hasActiveWalletConnection === "function") {
|
|
this.hasActiveWalletConnection = hasActiveWalletConnection;
|
|
} else {
|
|
this.hasActiveWalletConnection = () => false;
|
|
}
|
|
this.splitAndZap =
|
|
typeof splitAndZap === "function" ? splitAndZap : () => Promise.resolve();
|
|
this.notifyLoginRequired =
|
|
typeof notifyLoginRequired === "function" ? notifyLoginRequired : null;
|
|
this.payments = payments || {};
|
|
this.callbacks = {
|
|
onSuccess: typeof callbacks.onSuccess === "function" ? callbacks.onSuccess : null,
|
|
onError: typeof callbacks.onError === "function" ? callbacks.onError : null,
|
|
};
|
|
this.requestWalletPane =
|
|
typeof requestWalletPane === "function" ? requestWalletPane : null;
|
|
|
|
this.modalZapInFlight = false;
|
|
this.modalZapRetryState = null;
|
|
this.modalZapAmountValue = 0;
|
|
this.modalZapCommentValue = "";
|
|
}
|
|
|
|
/** Public API **/
|
|
|
|
setVisibility(visible) {
|
|
const lightningVisible = !!visible;
|
|
const loggedIn = this.isUserLoggedIn();
|
|
const shouldShow = lightningVisible;
|
|
const requiresLogin = shouldShow && !loggedIn;
|
|
const hasWallet = this.hasActiveWalletConnection();
|
|
if (this.videoModal) {
|
|
this.videoModal.setZapVisibility({
|
|
visible: shouldShow,
|
|
requiresLogin,
|
|
});
|
|
this.videoModal.setWalletPromptVisible(
|
|
shouldShow && !requiresLogin && !hasWallet,
|
|
);
|
|
}
|
|
}
|
|
|
|
resetState({ preserveAmount = false } = {}) {
|
|
if (!preserveAmount) {
|
|
this.modalZapAmountValue = 0;
|
|
}
|
|
this.modalZapCommentValue = "";
|
|
this.modalZapInFlight = false;
|
|
this.resetRetryState();
|
|
if (this.videoModal) {
|
|
this.videoModal.setZapCompleted(false);
|
|
this.videoModal.resetZapForm({
|
|
amount: preserveAmount ? this.modalZapAmountValue || "" : "",
|
|
comment: "",
|
|
});
|
|
this.videoModal.setZapPending(false);
|
|
this.videoModal.setZapSplitSummary(DEFAULT_SPLIT_SUMMARY);
|
|
}
|
|
}
|
|
|
|
open(options = {}) {
|
|
const config =
|
|
options && typeof options === "object" ? options : Object.create(null);
|
|
const requiresLogin = Boolean(config.requiresLogin);
|
|
this.resetFeedback();
|
|
if (requiresLogin) {
|
|
if (this.notifyLoginRequired) {
|
|
this.notifyLoginRequired();
|
|
} else {
|
|
this.notifyError(LOGIN_TO_ZAP_MESSAGE);
|
|
}
|
|
return false;
|
|
}
|
|
if (!this.isUserLoggedIn()) {
|
|
if (this.notifyLoginRequired) {
|
|
this.notifyLoginRequired();
|
|
} else {
|
|
this.notifyError(LOGIN_TO_ZAP_MESSAGE);
|
|
}
|
|
return false;
|
|
}
|
|
this.applyDefaultAmount();
|
|
this.preloadLightningMetadata().catch((error) => {
|
|
devLogger.warn("[zap] Preload metadata error:", error);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
close() {
|
|
this.resetFeedback();
|
|
}
|
|
|
|
setAmount(amount) {
|
|
const rawAmount = Number(amount);
|
|
const fallback = this.videoModal?.getZapAmountValue?.() || 0;
|
|
const value = Number.isFinite(rawAmount) ? rawAmount : fallback;
|
|
this.modalZapAmountValue = Math.max(0, Math.round(value || 0));
|
|
this.resetFeedback();
|
|
this.updateSplitSummary();
|
|
}
|
|
|
|
setComment(comment) {
|
|
if (typeof comment === "string") {
|
|
this.modalZapCommentValue = comment.trim();
|
|
return;
|
|
}
|
|
const fallback = this.videoModal?.getZapCommentValue?.();
|
|
this.modalZapCommentValue = typeof fallback === "string" ? fallback : "";
|
|
}
|
|
|
|
async sendZap({ amount, comment } = {}) {
|
|
if (this.modalZapInFlight) {
|
|
return;
|
|
}
|
|
|
|
this.videoModal?.setZapCompleted(false);
|
|
|
|
const numericAmount = Number.isFinite(Number(amount))
|
|
? Number(amount)
|
|
: this.modalZapAmountValue;
|
|
const roundedAmount = Math.max(0, Math.round(numericAmount || 0));
|
|
this.modalZapAmountValue = roundedAmount;
|
|
|
|
const trimmedComment =
|
|
typeof comment === "string" ? comment.trim() : this.modalZapCommentValue;
|
|
this.modalZapCommentValue = trimmedComment;
|
|
|
|
if (!roundedAmount) {
|
|
const message = "Enter a zap amount greater than zero.";
|
|
this.videoModal?.setZapStatus(message, "error");
|
|
this.notifyError(message);
|
|
return;
|
|
}
|
|
|
|
const walletSettings = this.getWalletSettingsOrPrompt();
|
|
if (!walletSettings) {
|
|
return;
|
|
}
|
|
|
|
this.modalZapInFlight = true;
|
|
this.videoModal?.setZapPending(true);
|
|
// In-flight/pending status is informational, not a warning — "warning" colored
|
|
// it alarmingly (text-warning-strong) and counted as a warning in the flow.
|
|
this.videoModal?.setZapStatus(`Sending ${roundedAmount} sats…`, "neutral");
|
|
this.videoModal?.clearZapReceipts();
|
|
|
|
try {
|
|
if (
|
|
Array.isArray(this.modalZapRetryState?.shares) &&
|
|
this.modalZapRetryState.shares.length
|
|
) {
|
|
const retrySuccess = await this.executeRetry({
|
|
walletSettings,
|
|
comment: trimmedComment,
|
|
});
|
|
if (retrySuccess) {
|
|
this.modalZapCommentValue = "";
|
|
this.videoModal?.resetZapForm({ amount: "", comment: "" });
|
|
this.videoModal?.setZapCompleted(true);
|
|
}
|
|
this.applyDefaultAmount();
|
|
return;
|
|
}
|
|
|
|
const attempt = await this.runZapAttempt({
|
|
amount: roundedAmount,
|
|
walletSettings,
|
|
comment: trimmedComment,
|
|
});
|
|
if (!attempt) {
|
|
this.videoModal?.setZapStatus("", "neutral");
|
|
return;
|
|
}
|
|
|
|
const { context, result } = attempt;
|
|
const receipts = Array.isArray(result?.receipts) ? result.receipts : [];
|
|
|
|
const failureReceipts = receipts.filter((receipt) => {
|
|
if (!receipt || typeof receipt !== "object") {
|
|
return false;
|
|
}
|
|
if (typeof receipt.status === "string" && receipt.status !== "success") {
|
|
return true;
|
|
}
|
|
return Boolean(receipt.error);
|
|
});
|
|
|
|
if (failureReceipts.length) {
|
|
this.videoModal?.renderZapReceipts(receipts, { partial: true });
|
|
|
|
const failureShares = failureReceipts
|
|
.map((receipt) => {
|
|
const amount = Math.max(0, Math.round(Number(receipt.amount) || 0));
|
|
return {
|
|
type: receipt.recipientType || receipt.type || "creator",
|
|
amount,
|
|
address:
|
|
typeof receipt.address === "string" && receipt.address
|
|
? receipt.address
|
|
: undefined,
|
|
error: receipt.error,
|
|
};
|
|
})
|
|
.filter((share) => share.amount > 0);
|
|
|
|
if (failureShares.length) {
|
|
this.markRetryPending(failureShares, trimmedComment);
|
|
}
|
|
|
|
const shareSummary = failureShares
|
|
.map((share) => {
|
|
const label =
|
|
share.type === "platform"
|
|
? "Platform"
|
|
: share.type === "creator"
|
|
? "Creator"
|
|
: "Lightning";
|
|
return `${label} ${share.amount} sats`;
|
|
})
|
|
.join(", ");
|
|
|
|
const failureMessage = failureReceipts
|
|
.map((receipt) => {
|
|
if (typeof receipt?.error?.message === "string") {
|
|
return receipt.error.message;
|
|
}
|
|
if (typeof receipt?.error === "string") {
|
|
return receipt.error;
|
|
}
|
|
return "";
|
|
})
|
|
.find((message) => message);
|
|
|
|
const budgetReceipt = failureReceipts.find((receipt) =>
|
|
isZapAllowanceExhaustedError(receipt?.error)
|
|
);
|
|
const isBudgetExceeded =
|
|
Boolean(budgetReceipt) ||
|
|
(typeof failureMessage === "string" &&
|
|
/budget exceeded/i.test(failureMessage));
|
|
|
|
let warningMessage;
|
|
if (isBudgetExceeded) {
|
|
warningMessage =
|
|
"Budget exceeded. Increase your wallet zap limit or reduce the platform fee, then retry the remaining shares.";
|
|
} else if (failureMessage) {
|
|
warningMessage = failureMessage;
|
|
} else {
|
|
warningMessage =
|
|
"Some zap shares failed. Press Send again to retry the remaining shares.";
|
|
}
|
|
|
|
if (shareSummary) {
|
|
const normalized = warningMessage.replace(/\.*$/, "");
|
|
warningMessage = `${normalized}. Remaining share(s): ${shareSummary}.`;
|
|
}
|
|
|
|
this.videoModal?.setZapStatus(warningMessage, "warning");
|
|
this.notifyError(warningMessage);
|
|
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";
|
|
});
|
|
|
|
// NOTE: `unvalidatedReceipts` are shares whose PAYMENT SUCCEEDED but whose
|
|
// on-relay zap receipt (kind 9735) couldn't be validated. This is NOT a
|
|
// failure: the NWC payment already returned a preimage (proof the invoice
|
|
// was paid), and the 9735 is supplementary — the recipient's service may
|
|
// publish it to relays we don't watch, or late, or not at all. Treating it
|
|
// as an error wrongly told users a successful zap had failed. So we report
|
|
// SUCCESS and only append a soft, non-alarming note that the receipt
|
|
// couldn't be confirmed on relays.
|
|
this.videoModal?.renderZapReceipts(receipts, {
|
|
partial: unvalidatedReceipts.length > 0,
|
|
});
|
|
|
|
const creatorShare = context.shares.creatorShare;
|
|
const platformShare = context.shares.platformShare;
|
|
let summary = platformShare
|
|
? `Sent ${context.shares.total} sats (creator ${creatorShare}, platform ${platformShare}).`
|
|
: `Sent ${context.shares.total} sats to the creator.`;
|
|
if (unvalidatedReceipts.length) {
|
|
summary +=
|
|
" Payment went through; couldn't confirm the zap receipt on relays.";
|
|
}
|
|
this.videoModal?.setZapStatus(summary, "success");
|
|
this.notifySuccess("Zap sent successfully!");
|
|
|
|
this.resetRetryState();
|
|
this.modalZapCommentValue = "";
|
|
// Reset the whole form (amount + comment) after a successful send, matching
|
|
// the validation-warning terminal path above. Previously this only cleared
|
|
// the comment via setZapComment, leaving the form in a half-reset state
|
|
// inconsistent with the other post-send paths and the modal's reset API.
|
|
this.videoModal?.resetZapForm({ amount: "", comment: "" });
|
|
this.videoModal?.setZapCompleted(true);
|
|
this.applyDefaultAmount();
|
|
} catch (error) {
|
|
this.handleZapError({ error, amount: roundedAmount, comment: trimmedComment, walletSettings });
|
|
} finally {
|
|
this.modalZapInFlight = false;
|
|
this.videoModal?.setZapPending(false);
|
|
}
|
|
}
|
|
|
|
handleWalletLink() {
|
|
if (this.requestWalletPane) {
|
|
Promise.resolve()
|
|
.then(() => this.requestWalletPane())
|
|
.catch((error) => {
|
|
userLogger.error("Failed to open wallet pane:", error);
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Internal helpers **/
|
|
|
|
resetFeedback() {
|
|
if (!this.videoModal) {
|
|
return;
|
|
}
|
|
this.videoModal.setZapCompleted(false);
|
|
this.videoModal.setZapStatus("", "neutral");
|
|
this.videoModal.clearZapReceipts();
|
|
this.videoModal.setZapRetryPending(false);
|
|
this.videoModal.setZapPending(false);
|
|
}
|
|
|
|
applyDefaultAmount() {
|
|
const settings = this.getActiveNwcSettings();
|
|
let defaultAmount = 0;
|
|
if (Number.isFinite(settings?.defaultZap) && settings.defaultZap > 0) {
|
|
defaultAmount = Math.max(0, Math.round(settings.defaultZap));
|
|
}
|
|
this.modalZapAmountValue = defaultAmount;
|
|
if (this.videoModal) {
|
|
this.videoModal.setZapAmount(defaultAmount || "");
|
|
}
|
|
this.updateSplitSummary();
|
|
}
|
|
|
|
updateSplitSummary() {
|
|
if (!this.videoModal) {
|
|
return;
|
|
}
|
|
|
|
const amount = Math.max(0, Math.round(Number(this.modalZapAmountValue || 0)));
|
|
if (!amount) {
|
|
this.videoModal.setZapSplitSummary(DEFAULT_SPLIT_SUMMARY);
|
|
return;
|
|
}
|
|
|
|
const shares = calculateZapShares(amount);
|
|
const parts = [];
|
|
const lightningAddress = this.getCurrentVideo()?.lightningAddress || "";
|
|
const creatorEntry = getCachedLightningEntry(lightningAddress);
|
|
const creatorMin = formatMinRequirement(creatorEntry?.metadata);
|
|
let creatorText = `Creator: ${shares.creatorShare} sats`;
|
|
if (Number.isFinite(creatorMin) && creatorMin > 0) {
|
|
creatorText += ` (min ${creatorMin})`;
|
|
}
|
|
parts.push(creatorText);
|
|
|
|
if (shares.platformShare > 0) {
|
|
const platformEntry = getCachedLightningEntry(
|
|
getCachedPlatformLightningAddress()
|
|
);
|
|
const platformMin = formatMinRequirement(platformEntry?.metadata);
|
|
let platformText = `Platform: ${shares.platformShare} sats`;
|
|
if (Number.isFinite(platformMin) && platformMin > 0) {
|
|
platformText += ` (min ${platformMin})`;
|
|
}
|
|
parts.push(platformText);
|
|
}
|
|
|
|
this.videoModal.setZapSplitSummary(parts.join(" • "));
|
|
}
|
|
|
|
async preloadLightningMetadata() {
|
|
const lightningAddress = this.getCurrentVideo()?.lightningAddress || "";
|
|
if (lightningAddress) {
|
|
try {
|
|
const entry = await fetchLightningMetadata(lightningAddress);
|
|
// Reachable from the browser. If the host doesn't advertise Nostr zaps,
|
|
// a payment may still go through but won't yield a zap receipt — note it.
|
|
if (entry?.metadata && entry.metadata.allowsNostr === false) {
|
|
this.videoModal?.setZapStatus(
|
|
"This creator's Lightning host doesn't advertise Nostr zaps — a payment may not produce a zap receipt.",
|
|
"warning",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
devLogger.warn("[zap] Failed to preload creator metadata:", error);
|
|
// Passive heads-up to the zapper: if WE can't reach the creator's LNURL
|
|
// host from the browser, the actual zap will fail the same way. Surface it
|
|
// up front so they aren't surprised mid-send. (sendZap overwrites this with
|
|
// its own status once they try.)
|
|
if (error?.code === "lnurl-unreachable") {
|
|
this.videoModal?.setZapStatus(
|
|
"This creator's Lightning address can't be reached from a browser (its host may be " +
|
|
"offline or blocking browser requests), so zapping them on bitvid may fail.",
|
|
"warning",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const amount = Math.max(0, Math.round(Number(this.modalZapAmountValue || 0)));
|
|
if (!amount) {
|
|
return;
|
|
}
|
|
|
|
const shares = calculateZapShares(amount);
|
|
if (shares.platformShare > 0) {
|
|
let platformAddress = getCachedPlatformLightningAddress();
|
|
if (!platformAddress) {
|
|
try {
|
|
platformAddress = await getPlatformLightningAddress({
|
|
forceRefresh: false,
|
|
});
|
|
setCachedPlatformLightningAddress(platformAddress || "");
|
|
} catch (error) {
|
|
devLogger.warn("[zap] Failed to load platform Lightning address:", error);
|
|
}
|
|
}
|
|
if (platformAddress) {
|
|
try {
|
|
await fetchLightningMetadata(platformAddress);
|
|
} catch (error) {
|
|
devLogger.warn("[zap] Failed to preload platform metadata:", error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
getWalletSettingsOrPrompt() {
|
|
const settings = this.getActiveNwcSettings();
|
|
const normalizedUri =
|
|
typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : "";
|
|
if (!normalizedUri) {
|
|
this.notifyError("Connect a Lightning wallet to send zaps.");
|
|
if (this.requestWalletPane) {
|
|
this.handleWalletLink();
|
|
}
|
|
return null;
|
|
}
|
|
return {
|
|
...settings,
|
|
nwcUri: normalizedUri,
|
|
};
|
|
}
|
|
|
|
createDependencies({ creatorEntry, platformEntry, shares, shareTracker }) {
|
|
const summarizeTracker = (tracker) =>
|
|
Array.isArray(tracker)
|
|
? tracker.map((entry) => ({
|
|
type: entry?.type || "unknown",
|
|
status: entry?.status || "unknown",
|
|
amount:
|
|
Number.isFinite(entry?.amount) && entry.amount >= 0
|
|
? entry.amount
|
|
: undefined,
|
|
address: entry?.address || undefined,
|
|
hasPayment: Boolean(entry?.payment),
|
|
errorMessage:
|
|
typeof entry?.error?.message === "string"
|
|
? entry.error.message
|
|
: entry?.error
|
|
? String(entry.error)
|
|
: undefined,
|
|
}))
|
|
: undefined;
|
|
const logZapError = (stage, details, error) => {
|
|
const summary = {
|
|
stage,
|
|
shareType: details?.shareType,
|
|
address: details?.address,
|
|
amount:
|
|
Number.isFinite(details?.amount) && details.amount >= 0
|
|
? details.amount
|
|
: undefined,
|
|
overrideFee:
|
|
typeof details?.overrideFee === "number"
|
|
? details.overrideFee
|
|
: undefined,
|
|
commentLength:
|
|
typeof details?.comment === "string"
|
|
? details.comment.length
|
|
: undefined,
|
|
wallet:
|
|
details?.walletSettings
|
|
? {
|
|
hasUri: Boolean(details.walletSettings.nwcUri),
|
|
type:
|
|
details.walletSettings.type ||
|
|
details.walletSettings.name ||
|
|
details.walletSettings.client ||
|
|
undefined,
|
|
}
|
|
: undefined,
|
|
shares: details?.context?.shares
|
|
? {
|
|
total: details.context.shares.total,
|
|
creator: details.context.shares.creatorShare,
|
|
platform: details.context.shares.platformShare,
|
|
}
|
|
: undefined,
|
|
tracker: summarizeTracker(details?.tracker),
|
|
retryAttempt:
|
|
Number.isInteger(details?.retryAttempt) && details.retryAttempt >= 0
|
|
? details.retryAttempt
|
|
: undefined,
|
|
};
|
|
userLogger.error("[zap] Modal zap failure", summary, error);
|
|
};
|
|
|
|
const lightningAddress = this.getCurrentVideo()?.lightningAddress || "";
|
|
const creatorKey = normalizeLightningAddressKey(
|
|
creatorEntry?.address || lightningAddress
|
|
);
|
|
const platformKey = normalizeLightningAddressKey(
|
|
platformEntry?.address || getCachedPlatformLightningAddress()
|
|
);
|
|
|
|
const ensureWalletFn =
|
|
typeof this.payments?.ensureWallet === "function"
|
|
? (options) => this.payments.ensureWallet(options)
|
|
: (options) => ensureWalletDefault(options);
|
|
const sendPaymentFn =
|
|
typeof this.payments?.sendPayment === "function"
|
|
? (bolt11, params) => this.payments.sendPayment(bolt11, params)
|
|
: (bolt11, params) => sendPaymentDefault(bolt11, params);
|
|
const resetWalletClientFn =
|
|
typeof this.payments?.resetWalletClient === "function"
|
|
? () => this.payments.resetWalletClient()
|
|
: () => resetWalletClientDefault();
|
|
|
|
let activeShare = null;
|
|
|
|
return {
|
|
lnurl: {
|
|
resolveLightningAddress: (value) => {
|
|
const normalized = normalizeLightningAddressKey(value);
|
|
if (normalized && normalized === creatorKey) {
|
|
activeShare = "creator";
|
|
if (creatorEntry?.resolved) {
|
|
return { ...creatorEntry.resolved };
|
|
}
|
|
} else if (normalized && normalized === platformKey) {
|
|
activeShare = "platform";
|
|
if (platformEntry?.resolved) {
|
|
return { ...platformEntry.resolved };
|
|
}
|
|
} else {
|
|
activeShare = null;
|
|
}
|
|
|
|
const resolved = resolveLightningAddress(value);
|
|
if (normalized) {
|
|
rememberLightningMetadata({
|
|
key: normalized,
|
|
address: resolved.address || value,
|
|
resolved,
|
|
fetchedAt: Date.now(),
|
|
});
|
|
}
|
|
return resolved;
|
|
},
|
|
fetchPayServiceData: async (url) => {
|
|
const cached = getCachedMetadataByUrl(url);
|
|
if (cached?.metadata && isMetadataEntryFresh(cached)) {
|
|
return cached.metadata;
|
|
}
|
|
|
|
const metadata = await fetchPayServiceData(url);
|
|
if (cached) {
|
|
rememberLightningMetadata({
|
|
...cached,
|
|
metadata,
|
|
fetchedAt: Date.now(),
|
|
});
|
|
}
|
|
return metadata;
|
|
},
|
|
validateInvoiceAmount,
|
|
requestInvoice,
|
|
},
|
|
wallet: {
|
|
ensureWallet: async (options) => {
|
|
try {
|
|
return await ensureWalletFn(options);
|
|
} catch (error) {
|
|
logZapError(
|
|
"wallet.ensureWallet",
|
|
{
|
|
tracker: shareTracker,
|
|
context: { shares },
|
|
walletSettings: options?.settings,
|
|
},
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
},
|
|
sendPayment: async (bolt11, params) => {
|
|
const shareType = activeShare || "unknown";
|
|
const amount =
|
|
shareType === "platform"
|
|
? shares.platformShare
|
|
: shares.creatorShare;
|
|
const address =
|
|
shareType === "platform"
|
|
? platformEntry?.address || getCachedPlatformLightningAddress()
|
|
: creatorEntry?.address || lightningAddress;
|
|
try {
|
|
const payment = await sendPaymentFn(bolt11, params);
|
|
if (Array.isArray(shareTracker)) {
|
|
shareTracker.push({
|
|
type: shareType,
|
|
status: "success",
|
|
amount,
|
|
address,
|
|
payment,
|
|
});
|
|
}
|
|
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();
|
|
} catch (resetError) {
|
|
devLogger.warn(
|
|
"[zap] Failed to reset wallet client after timeout",
|
|
resetError
|
|
);
|
|
}
|
|
}
|
|
logZapError(
|
|
"wallet.sendPayment",
|
|
{
|
|
shareType,
|
|
amount,
|
|
address,
|
|
tracker: shareTracker,
|
|
context: { shares },
|
|
},
|
|
error
|
|
);
|
|
throw error;
|
|
} finally {
|
|
activeShare = null;
|
|
}
|
|
},
|
|
},
|
|
platformAddress: {
|
|
getPlatformLightningAddress: async () => {
|
|
if (platformEntry?.address) {
|
|
return platformEntry.address;
|
|
}
|
|
const cachedAddress = getCachedPlatformLightningAddress();
|
|
if (cachedAddress) {
|
|
return cachedAddress;
|
|
}
|
|
const fallback = await getPlatformLightningAddress({
|
|
forceRefresh: false,
|
|
});
|
|
setCachedPlatformLightningAddress(fallback || "");
|
|
return fallback;
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async prepareLightningContext({ amount, overrideFee = null }) {
|
|
const lightningAddress = this.getCurrentVideo()?.lightningAddress || "";
|
|
if (!lightningAddress) {
|
|
throw new Error("This creator has not configured a Lightning address yet.");
|
|
}
|
|
|
|
const shares = calculateZapShares(amount, overrideFee);
|
|
if (!shares.total) {
|
|
throw new Error("Enter a zap amount greater than zero.");
|
|
}
|
|
|
|
const creatorEntry = await fetchLightningMetadata(lightningAddress);
|
|
if (shares.creatorShare > 0) {
|
|
try {
|
|
validateInvoiceAmount(creatorEntry.metadata, shares.creatorShare);
|
|
} catch (error) {
|
|
const detail = error?.message || "Unable to validate creator share.";
|
|
throw new Error(`Creator share error: ${detail}`);
|
|
}
|
|
}
|
|
|
|
let platformEntry = null;
|
|
let platformAddress = "";
|
|
if (shares.platformShare > 0) {
|
|
platformAddress = getCachedPlatformLightningAddress();
|
|
if (!platformAddress) {
|
|
platformAddress = await getPlatformLightningAddress({
|
|
forceRefresh: false,
|
|
});
|
|
setCachedPlatformLightningAddress(platformAddress || "");
|
|
}
|
|
if (!platformAddress) {
|
|
throw new Error("Platform Lightning address is unavailable.");
|
|
}
|
|
|
|
platformEntry = await fetchLightningMetadata(platformAddress);
|
|
try {
|
|
validateInvoiceAmount(platformEntry.metadata, shares.platformShare);
|
|
} catch (error) {
|
|
const detail = error?.message || "Unable to validate platform share.";
|
|
throw new Error(`Platform share error: ${detail}`);
|
|
}
|
|
}
|
|
|
|
this.updateSplitSummary();
|
|
|
|
return {
|
|
shares,
|
|
creatorEntry,
|
|
platformEntry,
|
|
platformAddress,
|
|
};
|
|
}
|
|
|
|
getActiveVideoEvent() {
|
|
const video = this.getCurrentVideo() || {};
|
|
const lightningAddress = video.lightningAddress || "";
|
|
return {
|
|
kind: typeof video.kind === "number" ? video.kind : 0,
|
|
id: typeof video.id === "string" ? video.id : "",
|
|
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
|
|
tags: Array.isArray(video.tags) ? [...video.tags] : [],
|
|
content: typeof video.content === "string" ? video.content : "",
|
|
created_at: Number.isFinite(video.created_at)
|
|
? video.created_at
|
|
: Math.floor(Date.now() / 1000),
|
|
lightningAddress,
|
|
};
|
|
}
|
|
|
|
async runZapAttempt({ amount, overrideFee = null, walletSettings, comment }) {
|
|
const settings = walletSettings || this.getWalletSettingsOrPrompt();
|
|
if (!settings) {
|
|
return null;
|
|
}
|
|
|
|
let context;
|
|
try {
|
|
context = await this.prepareLightningContext({
|
|
amount,
|
|
overrideFee,
|
|
});
|
|
} catch (error) {
|
|
userLogger.error(
|
|
"[zap] Modal lightning context failed",
|
|
{
|
|
amount,
|
|
overrideFee,
|
|
commentLength: typeof comment === "string" ? comment.length : undefined,
|
|
wallet: {
|
|
hasUri: Boolean(settings?.nwcUri),
|
|
type:
|
|
settings?.type || settings?.name || settings?.client || undefined,
|
|
},
|
|
},
|
|
error
|
|
);
|
|
throw error;
|
|
}
|
|
const shareTracker = [];
|
|
const dependencies = this.createDependencies({
|
|
...context,
|
|
shareTracker,
|
|
});
|
|
const videoEvent = this.getActiveVideoEvent();
|
|
|
|
let previousOverride;
|
|
const hasGlobal = typeof globalThis !== "undefined";
|
|
if (hasGlobal && typeof overrideFee === "number" && Number.isFinite(overrideFee)) {
|
|
previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
|
|
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = overrideFee;
|
|
}
|
|
|
|
try {
|
|
const result = await this.splitAndZap(
|
|
{
|
|
videoEvent,
|
|
amountSats: context.shares.total,
|
|
comment: typeof comment === "string" ? comment : "",
|
|
walletSettings: settings,
|
|
},
|
|
dependencies
|
|
);
|
|
return { context, result, shareTracker };
|
|
} catch (error) {
|
|
if (Array.isArray(shareTracker) && shareTracker.length) {
|
|
error.__zapShareTracker = shareTracker;
|
|
}
|
|
userLogger.error(
|
|
"[zap] splitAndZap failed",
|
|
{
|
|
amount,
|
|
overrideFee,
|
|
commentLength: typeof comment === "string" ? comment.length : undefined,
|
|
wallet: {
|
|
hasUri: Boolean(settings?.nwcUri),
|
|
type:
|
|
settings?.type || settings?.name || settings?.client || undefined,
|
|
},
|
|
shares: {
|
|
total: context?.shares?.total,
|
|
creator: context?.shares?.creatorShare,
|
|
platform: context?.shares?.platformShare,
|
|
},
|
|
tracker: Array.isArray(shareTracker)
|
|
? shareTracker.map((entry) => ({
|
|
type: entry?.type || "unknown",
|
|
status: entry?.status || "unknown",
|
|
amount: entry?.amount,
|
|
address: entry?.address,
|
|
errorMessage:
|
|
typeof entry?.error?.message === "string"
|
|
? entry.error.message
|
|
: entry?.error
|
|
? String(entry.error)
|
|
: undefined,
|
|
}))
|
|
: undefined,
|
|
},
|
|
error
|
|
);
|
|
throw error;
|
|
} finally {
|
|
if (hasGlobal && typeof overrideFee === "number" && Number.isFinite(overrideFee)) {
|
|
if (typeof previousOverride === "number") {
|
|
globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride;
|
|
} else if (
|
|
globalThis &&
|
|
Object.prototype.hasOwnProperty.call(globalThis, "__BITVID_PLATFORM_FEE_OVERRIDE__")
|
|
) {
|
|
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async executeRetry({ walletSettings, comment }) {
|
|
const retryState = this.modalZapRetryState;
|
|
const shares = Array.isArray(retryState?.shares) ? retryState.shares : [];
|
|
if (!shares.length) {
|
|
this.resetRetryState();
|
|
return null;
|
|
}
|
|
|
|
const summary = shares
|
|
.map((share) => {
|
|
const label =
|
|
share.type === "platform"
|
|
? "Platform"
|
|
: share.type === "creator"
|
|
? "Creator"
|
|
: "Lightning";
|
|
return `${label} ${share.amount} sats`;
|
|
})
|
|
.join(", ");
|
|
this.videoModal?.setZapStatus(
|
|
`Retrying failed share(s): ${summary}`,
|
|
"warning"
|
|
);
|
|
this.videoModal?.clearZapReceipts();
|
|
|
|
const aggregatedReceipts = [];
|
|
const aggregatedTracker = [];
|
|
|
|
for (const share of shares) {
|
|
const overrideFee = share.type === "platform" ? 100 : 0;
|
|
try {
|
|
const attempt = await this.runZapAttempt({
|
|
amount: share.amount,
|
|
overrideFee,
|
|
walletSettings,
|
|
comment: retryState?.comment || comment || "",
|
|
});
|
|
if (!attempt) {
|
|
this.videoModal?.setZapStatus("", "neutral");
|
|
return null;
|
|
}
|
|
if (Array.isArray(attempt?.result?.receipts)) {
|
|
aggregatedReceipts.push(...attempt.result.receipts);
|
|
} else if (Array.isArray(attempt?.shareTracker)) {
|
|
aggregatedTracker.push(...attempt.shareTracker);
|
|
}
|
|
} catch (error) {
|
|
const tracker = Array.isArray(error?.__zapShareTracker)
|
|
? error.__zapShareTracker
|
|
: [];
|
|
userLogger.error(
|
|
"[zap] Modal zap retry failed",
|
|
{
|
|
shareType: share?.type || "unknown",
|
|
amount: share?.amount,
|
|
address: share?.address,
|
|
commentLength:
|
|
typeof (retryState?.comment || comment) === "string"
|
|
? (retryState?.comment || comment).length
|
|
: undefined,
|
|
tracker: tracker.map((entry) => ({
|
|
type: entry?.type || "unknown",
|
|
status: entry?.status || "unknown",
|
|
amount: entry?.amount,
|
|
address: entry?.address,
|
|
errorMessage:
|
|
typeof entry?.error?.message === "string"
|
|
? entry.error.message
|
|
: entry?.error
|
|
? String(entry.error)
|
|
: undefined,
|
|
})),
|
|
},
|
|
error
|
|
);
|
|
if (tracker.length) {
|
|
aggregatedTracker.push(...tracker);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (aggregatedReceipts.length) {
|
|
this.videoModal?.renderZapReceipts(aggregatedReceipts, { partial: false });
|
|
} else if (aggregatedTracker.length) {
|
|
this.videoModal?.renderZapReceipts(aggregatedTracker, { partial: true });
|
|
}
|
|
|
|
const total = shares.reduce((sum, share) => {
|
|
const value = Math.max(0, Math.round(Number(share.amount) || 0));
|
|
return sum + value;
|
|
}, 0);
|
|
const successMessage =
|
|
total > 0
|
|
? `Retried ${total} sats successfully.`
|
|
: "Retried zap shares successfully.";
|
|
this.videoModal?.setZapStatus(successMessage, "success");
|
|
this.notifySuccess("Zap shares retried successfully!");
|
|
this.resetRetryState();
|
|
return true;
|
|
}
|
|
|
|
markRetryPending(shares, comment = "") {
|
|
const validShares = Array.isArray(shares)
|
|
? shares.filter((share) => share && share.amount > 0)
|
|
: [];
|
|
if (!validShares.length) {
|
|
this.resetRetryState();
|
|
return;
|
|
}
|
|
|
|
this.modalZapRetryState = {
|
|
shares: validShares,
|
|
comment: typeof comment === "string" ? comment : this.modalZapCommentValue,
|
|
createdAt: Date.now(),
|
|
};
|
|
|
|
const summary = validShares
|
|
.map((share) => {
|
|
const label =
|
|
share.type === "platform"
|
|
? "Platform"
|
|
: share.type === "creator"
|
|
? "Creator"
|
|
: "Lightning";
|
|
return `${label} ${share.amount} sats`;
|
|
})
|
|
.join(", ");
|
|
this.videoModal?.setZapRetryPending(true, { summary });
|
|
}
|
|
|
|
resetRetryState() {
|
|
this.modalZapRetryState = null;
|
|
this.videoModal?.setZapRetryPending(false);
|
|
}
|
|
|
|
handleZapError({ error, amount, comment, walletSettings }) {
|
|
const tracker = Array.isArray(error?.__zapShareTracker)
|
|
? error.__zapShareTracker
|
|
: [];
|
|
userLogger.error(
|
|
"[zap] Modal zap attempt failed",
|
|
{
|
|
amount,
|
|
commentLength: typeof comment === "string" ? comment.length : undefined,
|
|
wallet: {
|
|
hasUri: Boolean(walletSettings?.nwcUri),
|
|
type:
|
|
walletSettings?.type ||
|
|
walletSettings?.name ||
|
|
walletSettings?.client ||
|
|
undefined,
|
|
},
|
|
tracker: tracker.map((entry) => ({
|
|
type: entry?.type || "unknown",
|
|
status: entry?.status || "unknown",
|
|
amount: entry?.amount,
|
|
address: entry?.address,
|
|
errorMessage:
|
|
typeof entry?.error?.message === "string"
|
|
? entry.error.message
|
|
: entry?.error
|
|
? String(entry.error)
|
|
: undefined,
|
|
})),
|
|
retryPending: Array.isArray(this.modalZapRetryState?.shares)
|
|
? this.modalZapRetryState.shares.length
|
|
: 0,
|
|
},
|
|
error
|
|
);
|
|
if (tracker.length) {
|
|
this.videoModal?.renderZapReceipts(tracker, { partial: true });
|
|
}
|
|
|
|
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) {
|
|
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();
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
notifySuccess(message) {
|
|
if (this.callbacks.onSuccess) {
|
|
this.callbacks.onSuccess(message);
|
|
}
|
|
}
|
|
|
|
notifyError(message) {
|
|
if (this.callbacks.onError) {
|
|
this.callbacks.onError(message);
|
|
}
|
|
}
|
|
}
|