Files
PolyWeather/frontend/components/account/usePaymentFlow.ts
T
2569718930@qq.com 617d3c910e 支付域名识别更新:Vercel → polyweather.top
payment-host.ts 新增 polyweather.top。site-url.ts/wallet.ts/usePaymentFlow/AccountCenter 硬编码域名替换。PRODUCTION_SITE_URL 改为 https://polyweather.top
2026-05-26 00:33:02 +08:00

709 lines
28 KiB
TypeScript

"use client";
import { useCallback, useMemo } from "react";
import type {
AuthMeResponse,
BoundWallet,
ConnectBindOptions,
CreatedIntent,
EvmProvider,
IntentStatusResponse,
PaymentConfig,
PaymentTokenOption,
ProviderMode,
ProviderSelection,
} from "./types";
import {
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
} from "./constants";
import { clearStoredPaymentRecovery, shortAddress } from "./formatters";
import {
buildAllowanceCalldata,
buildApproveCalldata,
buildBalanceOfCalldata,
formatTokenUnits,
normalizePaymentError,
requestWalletWithTimeout,
} from "./payment-utils";
import { trackAppEvent } from "@/lib/app-analytics";
import {
assertExpectedPaymentReceiver,
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
} from "@/lib/payment-receiver";
import type { PaymentTxValidationState } from "./usePaymentState";
// ============================================================
export interface UsePaymentFlowParams {
isEn: boolean;
copy: Record<string, string>;
backend: AuthMeResponse | null;
// Shared payment config state
paymentConfig: PaymentConfig | null;
setPaymentConfig: React.Dispatch<React.SetStateAction<PaymentConfig | null>>;
selectedPlanCode: string;
setSelectedPlanCode: React.Dispatch<React.SetStateAction<string>>;
selectedTokenAddress: string;
setSelectedTokenAddress: React.Dispatch<React.SetStateAction<string>>;
// Shared wallet state (read-only)
boundWallets: BoundWallet[];
selectedWallet: string;
walletAddress: string;
providerMode: ProviderMode;
selectedInjectedProviderKey: string;
// Payment UI state + setters
manualTxHash: string;
manualPayment: CreatedIntent["direct_payment"] | null;
lastIntentId: string;
setPaymentBusy: (v: boolean) => void;
setPaymentInfo: (v: string) => void;
setPaymentError: (v: string) => void;
setLastIntentId: (v: string) => void;
setLastTxHash: (v: string) => void;
setLastPaymentStartedAt: (v: number) => void;
setShowOverlay: (v: boolean) => void;
setManualPayment: (v: CreatedIntent["direct_payment"] | null) => void;
setManualTxHash: (v: string) => void;
setTxValidation: (v: PaymentTxValidationState) => void;
setPaymentMethodTab: (v: "wallet" | "manual") => void;
clearPaymentMessages: () => void;
clearPaymentState: () => void;
// Setters for shared state
setSelectedWallet: React.Dispatch<React.SetStateAction<string>>;
setProviderMode: React.Dispatch<React.SetStateAction<ProviderMode>>;
// Derived values from master
selectedPlan?: { plan_code?: string; amount_usdc?: string };
billing: {
planAmount: number;
pointsEnabled: boolean;
pointsPerUsdc: number;
maxDiscountUsdc: number;
pointsUsed: number;
discountAmount: number;
payAmount: number;
canRedeem: boolean;
};
usePoints: boolean;
currentPaymentHost: string;
paymentHostAllowed: boolean;
allowedPaymentHosts: string[];
authIsAuthenticated: boolean;
hasPayingWallet: boolean;
// Callbacks from master
getValidAccessToken: () => Promise<string>;
buildAuthedHeaders: (withJson?: boolean, requireAuth?: boolean) => Promise<Record<string, string>>;
loadSnapshot: () => Promise<void>;
loadPaymentSnapshot: () => Promise<void>;
waitForReceipt: (txHash: string, provider?: EvmProvider, timeoutMs?: number, pollMs?: number) => Promise<any>;
ensureTargetChain: (eth: EvmProvider, targetChainId: number) => Promise<void>;
// Ref-wrapped cross-hook callbacks
connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode, options?: ConnectBindOptions) => Promise<boolean>) | null>;
handleSubmit409Ref: React.MutableRefObject<((intentId: string, txHashNorm: string, raw: string) => Promise<void>) | null>;
// Wallet provider helpers
resolvePaymentProvider: (mode?: ProviderMode, preferredInjectedKey?: string) => Promise<ProviderSelection>;
}
// ============================================================
export function usePaymentFlow(params: UsePaymentFlowParams) {
const {
isEn,
copy,
backend,
paymentConfig,
setPaymentConfig,
selectedPlanCode,
setSelectedPlanCode,
selectedTokenAddress,
setSelectedTokenAddress,
boundWallets,
selectedWallet,
walletAddress,
providerMode,
selectedInjectedProviderKey,
setPaymentBusy,
setPaymentInfo,
setPaymentError,
setLastIntentId,
setLastTxHash,
setLastPaymentStartedAt,
setShowOverlay,
setManualPayment,
setManualTxHash,
setTxValidation,
manualTxHash,
manualPayment,
lastIntentId,
setPaymentMethodTab,
clearPaymentMessages,
clearPaymentState,
setSelectedWallet,
setProviderMode,
billing,
usePoints,
currentPaymentHost,
paymentHostAllowed,
allowedPaymentHosts,
authIsAuthenticated,
hasPayingWallet,
getValidAccessToken,
buildAuthedHeaders,
loadSnapshot,
loadPaymentSnapshot,
waitForReceipt,
ensureTargetChain,
connectAndBindWalletRef,
handleSubmit409Ref,
resolvePaymentProvider,
} = params;
// ── Derived payment values ──────────────────────────────
const planList = paymentConfig?.plans || [];
const monthlyPlanList = planList.filter(
(plan) => String(plan.plan_code || "").trim().toLowerCase() === "pro_monthly",
);
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList;
const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0];
const availableTokenList: PaymentTokenOption[] = useMemo(() => {
const configured = Array.isArray(paymentConfig?.tokens) ? paymentConfig?.tokens || [] : [];
const clean = configured
.filter((row) => row && typeof row.address === "string" && row.address.startsWith("0x"))
.map((row) => ({
...row,
address: String(row.address).toLowerCase(),
symbol: String(row.symbol || "USDC"),
name: String(row.name || row.symbol || "USDC"),
code: String(row.code || "usdc"),
decimals: Number.isFinite(Number(row.decimals))
? Number(row.decimals)
: Number(paymentConfig?.token_decimals ?? 6),
}));
if (clean.length) return clean;
const fallbackAddress = String(paymentConfig?.token_address || "").toLowerCase();
if (!fallbackAddress.startsWith("0x")) return [];
return [{
code: "usdc", symbol: "USDC", name: "USDC", address: fallbackAddress,
decimals: Number(paymentConfig?.token_decimals ?? 6),
receiver_contract: paymentConfig?.receiver_contract, is_default: true,
}];
}, [paymentConfig]);
const resolvedSelectedTokenAddress = String(
selectedTokenAddress || paymentConfig?.default_token_address ||
availableTokenList.find((row) => row.is_default)?.address ||
availableTokenList[0]?.address || paymentConfig?.token_address || "",
).toLowerCase();
const selectedPaymentToken = availableTokenList.find(
(row) => row.address === resolvedSelectedTokenAddress,
) || availableTokenList[0];
const selectedTokenLabel = selectedPaymentToken?.symbol ||
(resolvedSelectedTokenAddress.startsWith("0x") ? shortAddress(resolvedSelectedTokenAddress) : "USDC");
const paymentReceiverAddress = EXPECTED_PAYMENT_RECEIVER_ADDRESS;
// ── fetchLatestPaymentConfig ────────────────────────────
const fetchLatestPaymentConfig = useCallback(
async (authHeaders?: Record<string, string>, syncState = true): Promise<PaymentConfig> => {
const headers = authHeaders || (await buildAuthedHeaders(false));
const configRes = await fetch("/api/payments/config", { cache: "no-store", headers });
if (!configRes.ok) {
const raw = (await configRes.text()).slice(0, 350);
throw new Error(copy.loadConfigFailed.replace("{raw}", raw));
}
const configJson = (await configRes.json()) as PaymentConfig;
if (syncState) {
setPaymentConfig(configJson);
if (!selectedPlanCode && configJson.plans?.length) {
setSelectedPlanCode(configJson.plans[0].plan_code);
}
const tokenOptions = Array.isArray(configJson.tokens)
? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x"))
: [];
const defaultTokenAddress = String(
configJson.default_token_address ||
tokenOptions.find((row) => row.is_default)?.address ||
tokenOptions[0]?.address || configJson.token_address || "",
).toLowerCase();
if (defaultTokenAddress) {
setSelectedTokenAddress((prev: string) => prev || defaultTokenAddress);
}
}
return configJson;
},
[buildAuthedHeaders, selectedPlanCode],
);
// ── pollIntentUntilConfirmed ────────────────────────────
const pollIntentUntilConfirmed = useCallback(
async (intentId: string, authHeaders: Record<string, string>, txHashHint = "", timeoutMs = 180000, pollMs = 5000) => {
const startedAt = Date.now();
const shortTx = shortAddress(txHashHint);
while (Date.now() - startedAt < timeoutMs) {
const statusRes = await fetch(`/api/payments/intents/${intentId}`, {
method: "GET", headers: authHeaders, cache: "no-store",
});
if (!statusRes.ok) {
if (statusRes.status >= 500 || statusRes.status === 429) {
await new Promise((resolve) => setTimeout(resolve, pollMs));
continue;
}
const raw = (await statusRes.text()).slice(0, 260);
throw new Error(copy.queryIntentFailed.replace("{raw}", raw));
}
const statusJson = (await statusRes.json()) as IntentStatusResponse;
const intent = statusJson.intent || {};
const status = String(intent.status || "").toLowerCase();
const txHash = String(intent.tx_hash || txHashHint || "").toLowerCase();
if (status === "confirmed") {
setPaymentError("");
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHash)));
trackAppEvent("checkout_succeeded", {
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
tx_hash: txHash || null,
});
await loadSnapshot();
await loadPaymentSnapshot();
return;
}
if (status === "failed" || status === "cancelled" || status === "expired") {
throw new Error(copy.paymentStatus.replace("{status}", status));
}
setPaymentInfo(copy.txSubmitted.replace("{txHash}", shortTx).replace("{status}", status || "submitted"));
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error(copy.paymentPendingTimeout);
},
[loadPaymentSnapshot, loadSnapshot, selectedPlan?.plan_code],
);
// ── createIntentAndPay ──────────────────────────────────
const createIntentAndPay = async () => {
clearPaymentMessages();
clearPaymentState();
clearStoredPaymentRecovery();
if (!paymentHostAllowed) {
setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather.top"));
return;
}
if (!authIsAuthenticated) {
setPaymentError(copy.loginBeforePay);
return;
}
if (!paymentConfig?.configured) {
setPaymentError(copy.payNotReady);
return;
}
const fallbackWallet = String(selectedWallet || walletAddress || boundWallets[0]?.address || "").toLowerCase();
if (!fallbackWallet) {
setPaymentError(copy.bindFirstBeforePay);
return;
}
setPaymentBusy(true);
let approvedInThisRun = false;
try {
const providerSelection = await resolvePaymentProvider(providerMode, selectedInjectedProviderKey);
const eth = providerSelection.provider;
const activeAccounts = await requestWalletWithTimeout<string[]>(
eth, { method: "eth_requestAccounts" }, "连接付款钱包",
);
const activeAddress = String(activeAccounts?.[0] || "").toLowerCase();
if (!activeAddress) throw new Error(isEn ? "Wallet account is empty." : "钱包账户为空");
const boundAddrSet = new Set(boundWallets.map((row) => String(row.address || "").toLowerCase()));
if (boundAddrSet.size > 0 && !boundAddrSet.has(activeAddress)) {
throw new Error(`当前连接钱包 ${shortAddress(activeAddress)} 未绑定,请先绑定该地址后支付。`);
}
const payingWallet = boundAddrSet.has(activeAddress) ? activeAddress : fallbackWallet;
setSelectedWallet(payingWallet);
setProviderMode(providerSelection.mode);
let accessToken: string;
try { accessToken = await getValidAccessToken(); }
catch (tokenErr) {
setPaymentError(normalizePaymentError(tokenErr).message);
setPaymentBusy(false);
return;
}
const authHeaders: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
};
const latestConfig = await fetchLatestPaymentConfig(authHeaders, true);
if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady);
const expectedReceiver = String(latestConfig.receiver_contract || "").toLowerCase();
assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract");
if (paymentConfig?.receiver_contract && String(paymentConfig.receiver_contract).toLowerCase() !== expectedReceiver) {
setPaymentInfo(copy.paymentConfigUpdated.replace("{address}", shortAddress(expectedReceiver)));
} else {
setPaymentInfo(copy.currentReceiver.replace("{address}", shortAddress(expectedReceiver)));
}
const targetChainId = Number(latestConfig.chain_id || 137);
await ensureTargetChain(eth, targetChainId);
const createRes = await fetch("/api/payments/intents", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "strict",
allowed_wallet: payingWallet,
token_address: resolvedSelectedTokenAddress || undefined,
use_points: billing.canRedeem && usePoints,
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
metadata: {
source: "account_center",
frontend_host: currentPaymentHost || null,
account_email: backend?.email || null,
},
}),
});
if (!createRes.ok) {
const raw = (await createRes.text()).slice(0, 350);
throw new Error(copy.createIntentFailed.replace("{raw}", raw));
}
const created = (await createRes.json()) as CreatedIntent;
const intentId = String(created.intent?.intent_id || "");
const txPayload = created.tx_payload;
if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error(copy.intentPayloadInvalid);
trackAppEvent("checkout_started", {
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
use_points: billing.canRedeem && usePoints,
pay_amount_usd: billing.payAmount,
});
const intentReceiver = String(txPayload.to || "").toLowerCase();
if (intentReceiver !== expectedReceiver) {
throw new Error(`payment receiver changed: expected ${expectedReceiver}, got ${intentReceiver}. 请刷新页面后重试。`);
}
setLastIntentId(intentId);
const tokenAddress = String(txPayload.token_address || "").toLowerCase();
const amountUnits = BigInt(String(txPayload.amount_units || "0"));
if (!tokenAddress.startsWith("0x") || amountUnits <= 0n) throw new Error(copy.intentTokenInvalid);
const tokenSymbol = String(txPayload.token_symbol || selectedPaymentToken?.symbol || selectedTokenLabel || "USDC");
const tokenDecimals = Number(txPayload.token_decimals ?? selectedPaymentToken?.decimals ?? latestConfig?.token_decimals ?? 6);
const balanceHex = await requestWalletWithTimeout<string>(
eth, { method: "eth_call", params: [{ to: tokenAddress, data: buildBalanceOfCalldata(payingWallet) }, "latest"] },
`读取 ${tokenSymbol} 余额`,
);
const tokenBalance = BigInt(String(balanceHex || "0x0"));
if (tokenBalance < amountUnits) {
const need = formatTokenUnits(amountUnits, tokenDecimals);
const have = formatTokenUnits(tokenBalance, tokenDecimals);
throw new Error(`支付代币余额不足:需要 ${need} ${tokenSymbol},当前 ${have} ${tokenSymbol}。请确认你钱包里持有该支付币种。`);
}
const allowanceHex = await requestWalletWithTimeout<string>(
eth, { method: "eth_call", params: [{ to: tokenAddress, data: buildAllowanceCalldata(payingWallet, txPayload.to) }, "latest"] },
`读取 ${tokenSymbol} 授权额度`,
);
const allowance = BigInt(String(allowanceHex || "0x0"));
if (allowance < amountUnits) {
setPaymentInfo(copy.approvalDetected.replace("{symbol}", tokenSymbol));
const approveParams: Record<string, any> = {
from: payingWallet, to: tokenAddress, data: buildApproveCalldata(txPayload.to, amountUnits),
};
const approveHash = await requestWalletWithTimeout<string>(
eth, { method: "eth_sendTransaction", params: [approveParams] },
`发起 ${tokenSymbol} 授权`, WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
);
await waitForReceipt(String(approveHash || ""), eth);
approvedInThisRun = true;
setPaymentInfo(copy.approvalDone.replace("{symbol}", tokenSymbol));
} else {
setPaymentInfo(copy.approvalSufficient);
}
const payParams: Record<string, any> = { from: payingWallet, to: txPayload.to, data: txPayload.data };
if (txPayload.value && txPayload.value !== "0x0" && txPayload.value !== "0") {
payParams.value = txPayload.value;
}
const txHash = await requestWalletWithTimeout<string>(
eth, { method: "eth_sendTransaction", params: [payParams] },
"发起支付交易", WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
);
const txHashNorm = String(txHash || "").toLowerCase();
setLastTxHash(txHashNorm);
setLastPaymentStartedAt(Date.now());
const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }),
});
if (!submitRes.ok) {
const raw = (await submitRes.text()).slice(0, 350);
if (submitRes.status === 409) {
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentId, txHashNorm, raw);
return;
}
throw new Error(copy.submitTxFailed.replace("{raw}", raw));
}
const confirmRes = await fetch(`/api/payments/intents/${intentId}/confirm`, {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
});
if (!confirmRes.ok) {
const raw = (await confirmRes.text()).slice(0, 350);
const lowerRaw = raw.toLowerCase();
const maybePending =
(confirmRes.status === 404 && !lowerRaw.includes("payment intent not found")) ||
confirmRes.status === 408 ||
(confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially")));
if (maybePending) {
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`);
await pollIntentUntilConfirmed(intentId, authHeaders, txHashNorm);
return;
}
throw new Error(copy.confirmFailed.replace("{raw}", raw));
}
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm)));
trackAppEvent("checkout_succeeded", {
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
tx_hash: txHashNorm,
});
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
const normalized = normalizePaymentError(error);
if (normalized.pending) {
setPaymentError(normalized.message);
} else if (normalized.userRejected) {
setPaymentInfo(approvedInThisRun
? `${selectedTokenLabel} 授权已完成,本次支付已取消,可直接再次点击支付。`
: ""
);
setPaymentError(normalized.message);
} else {
setPaymentInfo(approvedInThisRun
? `${selectedTokenLabel} 授权已完成,但支付未完成,请重试。`
: ""
);
setPaymentError(normalized.message);
}
} finally {
setPaymentBusy(false);
}
};
// ── createManualPaymentIntent ────────────────────────────
const createManualPaymentIntent = async () => {
clearPaymentMessages();
setManualPayment(null);
setManualTxHash("");
setLastIntentId("");
setLastTxHash("");
if (!paymentHostAllowed) {
setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather.top"));
return;
}
if (!authIsAuthenticated) {
setPaymentError(copy.loginBeforePay);
return;
}
if (!paymentConfig?.configured) {
setPaymentError(copy.payNotReady);
return;
}
setPaymentBusy(true);
try {
const authHeaders = await buildAuthedHeaders(true, false);
const createRes = await fetch("/api/payments/intents", {
method: "POST",
headers: authHeaders,
body: JSON.stringify({
plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "direct",
token_address: resolvedSelectedTokenAddress || undefined,
use_points: billing.canRedeem && usePoints,
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
metadata: {
source: "account_center_manual_transfer",
frontend_host: currentPaymentHost || null,
account_email: backend?.email || null,
},
}),
});
if (!createRes.ok) {
const raw = (await createRes.text()).slice(0, 350);
throw new Error(copy.createManualIntentFailed.replace("{raw}", raw));
}
const created = (await createRes.json()) as CreatedIntent;
const direct = created.direct_payment;
const intentId = String(created.intent?.intent_id || direct?.intent_id || "");
if (!intentId || !direct?.receiver_address || !direct?.amount_usdc) {
throw new Error(copy.manualPaymentInvalid);
}
assertExpectedPaymentReceiver(direct.receiver_address, "manual payment receiver");
setLastIntentId(intentId);
setManualPayment(direct);
setPaymentMethodTab("manual");
setShowOverlay(false);
setPaymentInfo(`手动转账订单已创建:请在 Polygon 网络转 ${direct.amount_usdc} ${direct.token_symbol || selectedTokenLabel}${direct.receiver_address},请在下方【手动转账】面板中查看详情并复制地址,完成后提交 tx hash。`);
trackAppEvent("checkout_started", {
entry: "account_center_manual_transfer",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
payment_mode: "direct",
use_points: billing.canRedeem && usePoints,
pay_amount_usd: billing.payAmount,
});
} catch (error) {
setPaymentError(normalizePaymentError(error).message);
} finally {
setPaymentBusy(false);
}
};
// ── submitManualPaymentTx ──────────────────────────────
const submitManualPaymentTx = async () => {
const txHashNorm = String(manualTxHash || "").trim().toLowerCase();
const intentIdVal = String(lastIntentId || manualPayment?.intent_id || "").trim();
if (!intentIdVal || !manualPayment) {
setPaymentError(copy.manualOrderRequired);
return;
}
if (!txHashNorm.startsWith("0x") || txHashNorm.length !== 66) {
setPaymentError(copy.txHashRequired);
return;
}
setPaymentBusy(true);
setPaymentError("");
try {
const authHeaders = await buildAuthedHeaders(true, false);
const submitRes = await fetch(`/api/payments/intents/${intentIdVal}/submit`, {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
});
if (!submitRes.ok) {
const raw = (await submitRes.text()).slice(0, 350);
if (submitRes.status === 409) {
if (handleSubmit409Ref.current) await handleSubmit409Ref.current(intentIdVal, txHashNorm, raw);
return;
}
throw new Error(copy.submitTxFailed.replace("{raw}", raw));
}
const confirmRes = await fetch(`/api/payments/intents/${intentIdVal}/confirm`, {
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm }),
});
if (!confirmRes.ok) {
const raw = (await confirmRes.text()).slice(0, 350);
const lowerRaw = raw.toLowerCase();
const maybePending =
confirmRes.status === 408 ||
(confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially")));
if (maybePending) {
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`);
await pollIntentUntilConfirmed(intentIdVal, authHeaders, txHashNorm);
return;
}
throw new Error(copy.confirmFailed.replace("{raw}", raw));
}
setLastTxHash(txHashNorm);
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm)));
setManualPayment(null);
setManualTxHash("");
setTxValidation({ loading: false, checked: false });
trackAppEvent("checkout_succeeded", {
entry: "account_center_manual_transfer",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentIdVal,
tx_hash: txHashNorm,
});
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(normalizePaymentError(error).message);
} finally {
setPaymentBusy(false);
}
};
// ── validateTxHash ────────────────────────────────────
const validateTxHash = useCallback(
async (intentId: string, hash: string) => {
const hashNorm = String(hash || "").trim().toLowerCase();
if (!hashNorm.startsWith("0x") || hashNorm.length !== 66) {
setTxValidation({ loading: false, checked: false });
return;
}
setTxValidation({ loading: true, checked: false });
try {
const headers = await buildAuthedHeaders(true, false);
const res = await fetch(`/api/payments/intents/${intentId}/validate`, {
method: "POST", headers, body: JSON.stringify({ tx_hash: hashNorm }),
});
const json = (await res.json()) as {
valid?: boolean; reason?: string; detail?: string; checks?: Record<string, unknown>;
};
setTxValidation({ loading: false, checked: true, valid: Boolean(json.valid), reason: json.reason, detail: json.detail, checks: json.checks });
} catch {
setTxValidation({ loading: false, checked: false });
}
},
[buildAuthedHeaders],
);
// ── handleOverlayCheckout ──────────────────────────────
const handleOverlayCheckout = async () => {
if (!paymentHostAllowed) {
setPaymentError(copy.paymentHostBlocked.replace("{host}", allowedPaymentHosts[0] || "polyweather.top"));
return;
}
if (!authIsAuthenticated) {
setPaymentError(copy.loginBeforePay);
return;
}
if (!hasPayingWallet) {
setPaymentInfo(copy.openBindFlow);
if (connectAndBindWalletRef.current) {
const bound = await connectAndBindWalletRef.current(providerMode, { openOverlayAfterBind: true });
if (!bound) return;
}
setPaymentInfo(copy.walletBoundCreatingOrder);
await createIntentAndPay();
return;
}
await createIntentAndPay();
};
// ==========================================================
return {
fetchLatestPaymentConfig,
pollIntentUntilConfirmed,
createIntentAndPay,
createManualPaymentIntent,
submitManualPaymentTx,
validateTxHash,
handleOverlayCheckout,
availableTokenList,
resolvedSelectedTokenAddress,
selectedPaymentToken,
selectedTokenLabel,
paymentReceiverAddress,
};
}