feat: support ethereum usdc payment route

This commit is contained in:
2569718930@qq.com
2026-05-29 01:57:42 +08:00
parent 3cc2251b9b
commit f2e90fbcda
28 changed files with 1155 additions and 150 deletions
+125 -12
View File
@@ -8,6 +8,7 @@ import type {
CreatedIntent,
EvmProvider,
IntentStatusResponse,
PaymentChainOption,
PaymentConfig,
PaymentTokenOption,
ProviderMode,
@@ -16,7 +17,7 @@ import type {
import {
WALLET_TRANSACTION_REQUEST_TIMEOUT_MS,
} from "./constants";
import { clearStoredPaymentRecovery, shortAddress } from "./formatters";
import { chainIdToDisplayName, clearStoredPaymentRecovery, shortAddress } from "./formatters";
import {
buildAllowanceCalldata,
buildApproveCalldata,
@@ -43,6 +44,8 @@ export interface UsePaymentFlowParams {
setPaymentConfig: React.Dispatch<React.SetStateAction<PaymentConfig | null>>;
selectedPlanCode: string;
setSelectedPlanCode: React.Dispatch<React.SetStateAction<string>>;
selectedPaymentChainId: number;
setSelectedPaymentChainId: React.Dispatch<React.SetStateAction<number | null>>;
selectedTokenAddress: string;
setSelectedTokenAddress: React.Dispatch<React.SetStateAction<string>>;
@@ -100,7 +103,7 @@ export interface UsePaymentFlowParams {
loadSnapshot: () => Promise<void>;
loadPaymentSnapshot: () => Promise<void>;
waitForReceipt: (txHash: string, provider?: EvmProvider, timeoutMs?: number, pollMs?: number) => Promise<any>;
ensureTargetChain: (eth: EvmProvider, targetChainId: number) => Promise<void>;
ensureTargetChain: (eth: EvmProvider, targetChainId: number, chain?: PaymentChainOption) => Promise<void>;
// Ref-wrapped cross-hook callbacks
connectAndBindWalletRef: React.MutableRefObject<((mode?: ProviderMode, options?: ConnectBindOptions) => Promise<boolean>) | null>;
@@ -120,6 +123,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentConfig,
selectedPlanCode,
setSelectedPlanCode,
selectedPaymentChainId,
setSelectedPaymentChainId,
selectedTokenAddress,
setSelectedTokenAddress,
boundWallets,
@@ -171,6 +176,42 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const effectivePlanList = monthlyPlanList.length ? monthlyPlanList : planList;
const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0];
const availableChainList: PaymentChainOption[] = useMemo(() => {
const configured = Array.isArray(paymentConfig?.chains) ? paymentConfig?.chains || [] : [];
const clean = configured
.map((row) => ({
...row,
chain_id: Number(row.chain_id),
name: String(row.name || chainIdToDisplayName(Number(row.chain_id))),
}))
.filter((row) => Number.isFinite(row.chain_id) && row.chain_id > 0);
if (clean.length) return clean;
const chainIds = new Set<number>();
const defaultChainId = Number(paymentConfig?.default_chain_id || paymentConfig?.chain_id || 137);
if (Number.isFinite(defaultChainId) && defaultChainId > 0) chainIds.add(defaultChainId);
(paymentConfig?.tokens || []).forEach((token) => {
const chainId = Number(token.chain_id || defaultChainId);
if (Number.isFinite(chainId) && chainId > 0) chainIds.add(chainId);
});
return Array.from(chainIds).sort((a, b) => a - b).map((chainId) => ({
chain_id: chainId,
name: chainIdToDisplayName(chainId),
is_default: chainId === defaultChainId,
}));
}, [paymentConfig]);
const selectedPaymentChain =
availableChainList.find((chain) => chain.chain_id === selectedPaymentChainId) ||
availableChainList.find((chain) => chain.is_default) ||
availableChainList[0];
const effectivePaymentChainId = Number(
selectedPaymentChain?.chain_id ||
selectedPaymentChainId ||
paymentConfig?.default_chain_id ||
paymentConfig?.chain_id ||
137,
);
const availableTokenList: PaymentTokenOption[] = useMemo(() => {
const configured = Array.isArray(paymentConfig?.tokens) ? paymentConfig?.tokens || [] : [];
const clean = configured
@@ -181,22 +222,32 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
symbol: String(row.symbol || "USDC"),
name: String(row.name || row.symbol || "USDC"),
code: String(row.code || "usdc"),
chain_id: Number(row.chain_id || effectivePaymentChainId),
decimals: Number.isFinite(Number(row.decimals))
? Number(row.decimals)
: Number(paymentConfig?.token_decimals ?? 6),
}));
}))
.filter((row) => Number(row.chain_id) === effectivePaymentChainId);
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,
chain_id: effectivePaymentChainId,
decimals: Number(paymentConfig?.token_decimals ?? 6),
receiver_contract: paymentConfig?.receiver_contract, is_default: true,
}];
}, [paymentConfig]);
}, [effectivePaymentChainId, paymentConfig]);
const resolvedSelectedTokenAddress = String(
selectedTokenAddress || paymentConfig?.default_token_address ||
(
selectedTokenAddress &&
availableTokenList.some((row) => row.address === String(selectedTokenAddress).toLowerCase())
? selectedTokenAddress
: ""
) ||
availableTokenList.find((row) => row.is_default)?.address ||
paymentConfig?.default_token_address ||
availableTokenList.find((row) => row.is_default)?.address ||
availableTokenList[0]?.address || paymentConfig?.token_address || "",
).toLowerCase();
@@ -225,21 +276,50 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
if (!selectedPlanCode && configJson.plans?.length) {
setSelectedPlanCode(configJson.plans[0].plan_code);
}
const chainOptions = Array.isArray(configJson.chains)
? configJson.chains.filter((row) => Number(row?.chain_id) > 0)
: [];
const defaultChainId = Number(
configJson.default_chain_id ||
chainOptions.find((row) => row.is_default)?.chain_id ||
configJson.chain_id ||
137,
);
const supportedChainIds = new Set(
(chainOptions.length ? chainOptions : [{ chain_id: defaultChainId }])
.map((row) => Number(row.chain_id))
.filter((value) => Number.isFinite(value) && value > 0),
);
setSelectedPaymentChainId((prev) =>
prev && supportedChainIds.has(prev) ? prev : defaultChainId,
);
const activeChainId =
selectedPaymentChainId && supportedChainIds.has(selectedPaymentChainId)
? selectedPaymentChainId
: defaultChainId;
const tokenOptions = Array.isArray(configJson.tokens)
? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x"))
: [];
const tokenOptionsForChain = tokenOptions.filter(
(row) => Number(row.chain_id || activeChainId) === activeChainId,
);
const defaultTokenAddress = String(
configJson.default_token_address ||
tokenOptionsForChain.find((row) => row.is_default)?.address ||
tokenOptionsForChain[0]?.address ||
tokenOptions.find((row) => row.is_default)?.address ||
tokenOptions[0]?.address || configJson.token_address || "",
).toLowerCase();
if (defaultTokenAddress) {
setSelectedTokenAddress((prev: string) => prev || defaultTokenAddress);
const tokenSet = new Set(tokenOptionsForChain.map((row) => String(row.address).toLowerCase()));
setSelectedTokenAddress((prev: string) =>
prev && tokenSet.has(String(prev).toLowerCase()) ? prev : defaultTokenAddress,
);
}
}
return configJson;
},
[buildAuthedHeaders, selectedPlanCode],
[buildAuthedHeaders, selectedPaymentChainId, selectedPlanCode],
);
// ── pollIntentUntilConfirmed ────────────────────────────
@@ -345,7 +425,30 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const latestConfig = await fetchLatestPaymentConfig(authHeaders, true);
if (!latestConfig?.enabled || !latestConfig?.configured) throw new Error(copy.payNotReady);
const expectedReceiver = String(latestConfig.receiver_contract || "").toLowerCase();
const targetChainId = Number(selectedPaymentChainId || latestConfig.default_chain_id || latestConfig.chain_id || 137);
const latestChains = Array.isArray(latestConfig.chains) ? latestConfig.chains : [];
const targetChain =
latestChains.find((chain) => Number(chain.chain_id) === targetChainId) ||
selectedPaymentChain;
const latestTokens = Array.isArray(latestConfig.tokens) ? latestConfig.tokens : [];
const selectedLatestToken =
latestTokens.find(
(token) =>
Number(token.chain_id || targetChainId) === targetChainId &&
String(token.address || "").toLowerCase() === resolvedSelectedTokenAddress,
) ||
latestTokens.find(
(token) => Number(token.chain_id || targetChainId) === targetChainId && token.is_default,
) ||
latestTokens.find((token) => Number(token.chain_id || targetChainId) === targetChainId);
if (selectedLatestToken?.supports_contract_checkout === false) {
throw new Error(
isEn
? `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} supports manual transfer only.`
: `${selectedLatestToken.chain_name || chainIdToDisplayName(targetChainId)} ${selectedLatestToken.symbol || "USDC"} 仅支持手动转账。`,
);
}
const expectedReceiver = String(selectedLatestToken?.receiver_contract || 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)));
@@ -353,8 +456,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setPaymentInfo(copy.currentReceiver.replace("{address}", shortAddress(expectedReceiver)));
}
const targetChainId = Number(latestConfig.chain_id || 137);
await ensureTargetChain(eth, targetChainId);
await ensureTargetChain(eth, targetChainId, targetChain);
const createRes = await fetch("/api/payments/intents", {
method: "POST",
@@ -363,7 +465,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "strict",
allowed_wallet: payingWallet,
token_address: resolvedSelectedTokenAddress || undefined,
chain_id: targetChainId,
token_address: String(selectedLatestToken?.address || resolvedSelectedTokenAddress || "").toLowerCase() || undefined,
use_points: billing.canRedeem && usePoints,
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
metadata: {
@@ -537,6 +640,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
body: JSON.stringify({
plan_code: selectedPlan?.plan_code || "pro_monthly",
payment_mode: "direct",
chain_id: effectivePaymentChainId,
token_address: resolvedSelectedTokenAddress || undefined,
use_points: billing.canRedeem && usePoints,
points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0,
@@ -563,7 +667,14 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setManualPayment(direct);
setPaymentMethodTab("manual");
setShowOverlay(false);
setPaymentInfo(`手动转账订单已创建:请在 Polygon 网络转 ${direct.amount_usdc} ${direct.token_symbol || selectedTokenLabel}${direct.receiver_address},请在下方【手动转账】面板中查看详情并复制地址,完成后提交 tx hash。`);
const chainName = direct.chain_name || chainIdToDisplayName(direct.chain_id);
setPaymentInfo(
copy.manualOrderCreated
.replace("{amount}", direct.amount_usdc)
.replace("{symbol}", direct.token_symbol || selectedTokenLabel)
.replace("{chain}", chainName)
.replace("{receiver}", shortAddress(direct.receiver_address)),
);
trackAppEvent("checkout_started", {
entry: "account_center_manual_transfer",
plan_code: selectedPlan?.plan_code || "pro_monthly",
@@ -700,6 +811,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
validateTxHash,
handleOverlayCheckout,
availableTokenList,
availableChainList,
selectedPaymentChain,
resolvedSelectedTokenAddress,
selectedPaymentToken,
selectedTokenLabel,