feat: support ethereum usdc payment route
This commit is contained in:
@@ -124,6 +124,7 @@ export function AccountCenter() {
|
||||
boundWallets,
|
||||
walletAddress,
|
||||
selectedPlanCode,
|
||||
selectedPaymentChainId,
|
||||
selectedTokenAddress,
|
||||
selectedWallet,
|
||||
providerMode,
|
||||
@@ -133,6 +134,7 @@ export function AccountCenter() {
|
||||
|
||||
// Shared setters
|
||||
setSelectedTokenAddress,
|
||||
setSelectedPaymentChainId,
|
||||
setSelectedWallet,
|
||||
setSelectedInjectedProviderKey,
|
||||
setProviderMode,
|
||||
@@ -149,6 +151,8 @@ export function AccountCenter() {
|
||||
selectedPaymentToken,
|
||||
selectedTokenLabel,
|
||||
availableTokenList,
|
||||
availableChainList,
|
||||
selectedPaymentChain,
|
||||
effectivePlanList,
|
||||
resolvedSelectedTokenAddress,
|
||||
paymentReceiverAddress,
|
||||
@@ -810,7 +814,7 @@ export function AccountCenter() {
|
||||
errorText={paymentError || undefined}
|
||||
infoText={paymentInfo || undefined}
|
||||
txHash={lastTxHash || undefined}
|
||||
chainId={paymentConfig?.chain_id || 137}
|
||||
chainId={selectedPaymentChainId || paymentConfig?.chain_id || 137}
|
||||
paymentTokenLabel={selectedTokenLabel}
|
||||
faqHref={SUBSCRIPTION_HELP_HREF}
|
||||
telegramGroupUrl=""
|
||||
@@ -959,7 +963,10 @@ export function AccountCenter() {
|
||||
<InfoRow
|
||||
icon={ExternalLink}
|
||||
label={copy.paymentNetwork}
|
||||
value={chainIdToDisplayName(paymentConfig?.chain_id)}
|
||||
value={
|
||||
selectedPaymentChain?.name ||
|
||||
chainIdToDisplayName(selectedPaymentChainId)
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
icon={ExternalLink}
|
||||
@@ -970,6 +977,57 @@ export function AccountCenter() {
|
||||
{copy.paymentGuardHint}
|
||||
</p>
|
||||
</div>
|
||||
{availableChainList.length > 1 && (
|
||||
<div className="mb-5">
|
||||
<p className="mb-2 text-[11px] uppercase text-slate-500">
|
||||
{copy.paymentNetwork}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableChainList.map((chain) => {
|
||||
const active = chain.chain_id === selectedPaymentChainId;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={chain.chain_id}
|
||||
onClick={() => {
|
||||
setSelectedPaymentChainId(chain.chain_id);
|
||||
const nextToken =
|
||||
paymentConfig?.tokens?.find(
|
||||
(token) =>
|
||||
Number(token.chain_id || chain.chain_id) ===
|
||||
chain.chain_id &&
|
||||
token.is_default,
|
||||
) ||
|
||||
paymentConfig?.tokens?.find(
|
||||
(token) =>
|
||||
Number(token.chain_id || chain.chain_id) ===
|
||||
chain.chain_id,
|
||||
);
|
||||
if (nextToken?.address) {
|
||||
setSelectedTokenAddress(
|
||||
String(nextToken.address).toLowerCase(),
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={paymentBusy}
|
||||
className={`rounded-xl border px-3 py-2 text-left transition-all ${
|
||||
active
|
||||
? "border-blue-300 bg-blue-50 text-blue-900"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-bold">
|
||||
{chain.name || chainIdToDisplayName(chain.chain_id)}
|
||||
</div>
|
||||
<div className="text-[10px] opacity-80">
|
||||
{chain.native_currency_symbol || "ETH"} gas
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{availableTokenList.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<p className="mb-2 text-[11px] uppercase text-slate-500">
|
||||
@@ -984,7 +1042,7 @@ export function AccountCenter() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={token.address}
|
||||
key={`${token.chain_id || selectedPaymentChainId}:${token.address}`}
|
||||
onClick={() =>
|
||||
setSelectedTokenAddress(
|
||||
token.address,
|
||||
@@ -1073,7 +1131,7 @@ export function AccountCenter() {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px]">
|
||||
{copy.polygonChain}
|
||||
{chainIdToDisplayName(w.chain_id)}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function assert(condition: unknown, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
export function runTests() {
|
||||
const projectRoot = process.cwd();
|
||||
const accountDir = path.join(projectRoot, "components", "account");
|
||||
const useAccountPaymentSource = fs.readFileSync(
|
||||
path.join(accountDir, "useAccountPayment.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const usePaymentFlowSource = fs.readFileSync(
|
||||
path.join(accountDir, "usePaymentFlow.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const useWalletBindSource = fs.readFileSync(
|
||||
path.join(accountDir, "useWalletBind.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const accountCenterSource = fs.readFileSync(
|
||||
path.join(accountDir, "AccountCenter.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const opsPaymentsSource = fs.readFileSync(
|
||||
path.join(projectRoot, "components", "ops", "payments", "PaymentsPageClient.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert(
|
||||
useAccountPaymentSource.includes("selectedPaymentChainId") &&
|
||||
useAccountPaymentSource.includes("setSelectedPaymentChainId"),
|
||||
"account payment state must track the selected payment chain separately from the legacy default chain",
|
||||
);
|
||||
assert(
|
||||
usePaymentFlowSource.includes("chain_id: selectedPaymentChainId") ||
|
||||
usePaymentFlowSource.includes("chain_id: targetChainId"),
|
||||
"payment intent creation must send the selected chain_id to the backend",
|
||||
);
|
||||
assert(
|
||||
!usePaymentFlowSource.includes("请在 Polygon 网络转"),
|
||||
"manual transfer instructions must not hard-code Polygon after Ethereum USDC is supported",
|
||||
);
|
||||
assert(
|
||||
useWalletBindSource.includes("chainName") &&
|
||||
useWalletBindSource.includes("wallet_addEthereumChain") &&
|
||||
useWalletBindSource.includes("Ethereum Mainnet"),
|
||||
"wallet network switching must use chain metadata instead of hard-coded Polygon-only add-network params",
|
||||
);
|
||||
assert(
|
||||
accountCenterSource.includes("availableChainList") &&
|
||||
accountCenterSource.includes("setSelectedPaymentChainId") &&
|
||||
accountCenterSource.includes("paymentNetwork"),
|
||||
"account center must expose a payment network selector when multiple chains are configured",
|
||||
);
|
||||
assert(
|
||||
opsPaymentsSource.includes("etherscan.io") &&
|
||||
opsPaymentsSource.includes("polygonscan.com"),
|
||||
"ops payment tx links must route Ethereum payments to Etherscan and Polygon payments to Polygonscan",
|
||||
);
|
||||
}
|
||||
@@ -218,8 +218,8 @@ export function createAccountCopy(isEn: boolean): Record<string, string> {
|
||||
? "Current receiver contract: {address}"
|
||||
: "当前收款合约: {address}",
|
||||
manualOrderCreated: isEn
|
||||
? "Manual transfer order created. Please send {amount} USDC to the receiver on Polygon network. Then submit your tx hash below."
|
||||
: "手动转账订单已创建:请在 Polygon 网络转 {amount} USDC 到收款地址。完成后在下方提交 tx hash。",
|
||||
? "Manual transfer order created. Send {amount} {symbol} on {chain} to {receiver}, then submit your tx hash below."
|
||||
: "手动转账订单已创建:请在 {chain} 转 {amount} {symbol} 到 {receiver},完成后在下方提交 tx hash。",
|
||||
manualOrderRequired: isEn
|
||||
? "Please create a manual transfer order first."
|
||||
: "请先创建手动转账订单。",
|
||||
|
||||
@@ -7,6 +7,12 @@ export function chainIdToDisplayName(chainId: number | undefined | null): string
|
||||
return "Polygon";
|
||||
}
|
||||
|
||||
export function chainIdToExplorerBase(chainId: number | undefined | null): string {
|
||||
if (chainId === 1) return "https://etherscan.io";
|
||||
if (chainId === 137) return "https://polygonscan.com";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function formatTime(value: string | undefined | null, locale: string) {
|
||||
if (!value) return "--";
|
||||
try {
|
||||
|
||||
@@ -40,7 +40,24 @@ export type PaymentTokenOption = {
|
||||
name: string;
|
||||
address: string;
|
||||
decimals: number;
|
||||
chain_id?: number;
|
||||
chain_code?: string;
|
||||
chain_name?: string;
|
||||
receiver_contract?: string;
|
||||
direct_receiver_address?: string;
|
||||
explorer_tx_url?: string;
|
||||
supports_contract_checkout?: boolean;
|
||||
supports_direct_transfer?: boolean;
|
||||
is_default?: boolean;
|
||||
};
|
||||
|
||||
export type PaymentChainOption = {
|
||||
chain_id: number;
|
||||
code?: string;
|
||||
name?: string;
|
||||
native_currency_symbol?: string;
|
||||
block_explorer_url?: string;
|
||||
explorer_tx_url?: string;
|
||||
is_default?: boolean;
|
||||
};
|
||||
|
||||
@@ -54,9 +71,11 @@ export type PaymentConfig = {
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
chain_id?: number;
|
||||
default_chain_id?: number;
|
||||
token_address?: string;
|
||||
token_decimals?: number;
|
||||
default_token_address?: string;
|
||||
chains?: PaymentChainOption[];
|
||||
tokens?: PaymentTokenOption[];
|
||||
receiver_contract?: string;
|
||||
confirmations?: number;
|
||||
@@ -93,6 +112,7 @@ export type CreatedIntent = {
|
||||
direct_payment?: {
|
||||
chain_id: number;
|
||||
chain?: string;
|
||||
chain_name?: string;
|
||||
token_symbol?: string;
|
||||
token_address: string;
|
||||
token_decimals?: number;
|
||||
@@ -101,6 +121,7 @@ export type CreatedIntent = {
|
||||
amount_usdc: string;
|
||||
intent_id: string;
|
||||
expires_at: string;
|
||||
explorer_tx_url?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -211,6 +211,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]);
|
||||
const [walletAddress, setWalletAddress] = useState("");
|
||||
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
|
||||
const [selectedPaymentChainId, setSelectedPaymentChainId] = useState<number | null>(null);
|
||||
const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
|
||||
const [selectedWallet, setSelectedWallet] = useState("");
|
||||
const [providerMode, setProviderMode] = useState<ProviderMode>("auto");
|
||||
@@ -218,7 +219,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
const [selectedInjectedProviderKey, setSelectedInjectedProviderKey] = useState("");
|
||||
|
||||
// ── Chain ID derived from payment config ────────────────
|
||||
const chainId = paymentConfig?.chain_id ?? 137;
|
||||
const chainId = selectedPaymentChainId || paymentConfig?.default_chain_id || paymentConfig?.chain_id || 137;
|
||||
|
||||
// ── loadPaymentSnapshot ──────────────────────────────────
|
||||
// Defined in master because it sets state across multiple sub-hook domains.
|
||||
@@ -243,8 +244,35 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
const tokenOptions = Array.isArray(configJson.tokens)
|
||||
? configJson.tokens.filter((row) => typeof row?.address === "string" && String(row.address).startsWith("0x"))
|
||||
: [];
|
||||
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 ||
|
||||
tokenOptions.find((row) => row.is_default)?.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 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 || "",
|
||||
@@ -283,6 +311,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
backend?.authenticated,
|
||||
buildAuthedHeaders,
|
||||
selectedPlanCode,
|
||||
selectedPaymentChainId,
|
||||
selectedWallet,
|
||||
walletAddress,
|
||||
]);
|
||||
@@ -371,6 +400,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
setPaymentConfig,
|
||||
selectedPlanCode,
|
||||
setSelectedPlanCode,
|
||||
selectedPaymentChainId: chainId,
|
||||
setSelectedPaymentChainId,
|
||||
selectedTokenAddress,
|
||||
setSelectedTokenAddress,
|
||||
boundWallets,
|
||||
@@ -451,6 +482,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
boundWallets,
|
||||
walletAddress,
|
||||
selectedPlanCode,
|
||||
selectedPaymentChainId: chainId,
|
||||
selectedTokenAddress,
|
||||
selectedWallet,
|
||||
providerMode,
|
||||
@@ -460,6 +492,7 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
|
||||
// Setters for shared state
|
||||
setSelectedTokenAddress,
|
||||
setSelectedPaymentChainId,
|
||||
setSelectedWallet,
|
||||
setSelectedInjectedProviderKey,
|
||||
setProviderMode,
|
||||
@@ -476,6 +509,8 @@ export function useAccountPayment(params: UseAccountPaymentParams) {
|
||||
selectedPaymentToken: paymentFlow.selectedPaymentToken,
|
||||
selectedTokenLabel: paymentFlow.selectedTokenLabel,
|
||||
availableTokenList: paymentFlow.availableTokenList,
|
||||
availableChainList: paymentFlow.availableChainList,
|
||||
selectedPaymentChain: paymentFlow.selectedPaymentChain,
|
||||
effectivePlanList,
|
||||
resolvedSelectedTokenAddress: paymentFlow.resolvedSelectedTokenAddress,
|
||||
paymentReceiverAddress: paymentFlow.paymentReceiverAddress,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
Eip6963ProviderDetail,
|
||||
EvmProvider,
|
||||
InjectedProviderOption,
|
||||
PaymentChainOption,
|
||||
ProviderMode,
|
||||
ProviderSelection,
|
||||
} from "./types";
|
||||
@@ -190,12 +191,30 @@ export function useWalletBind(params: UseWalletBindParams) {
|
||||
}
|
||||
};
|
||||
|
||||
const ensureTargetChain = async (eth: EvmProvider, targetChainId: number): Promise<void> => {
|
||||
const ensureTargetChain = async (
|
||||
eth: EvmProvider,
|
||||
targetChainId: number,
|
||||
chain?: PaymentChainOption,
|
||||
): Promise<void> => {
|
||||
const currentChainIdHex = String(
|
||||
(await requestWalletWithTimeout<string>(eth, { method: "eth_chainId" }, copy.chainReadError)) || "",
|
||||
);
|
||||
const targetChainHex = `0x${targetChainId.toString(16)}`;
|
||||
if (currentChainIdHex.toLowerCase() === targetChainHex.toLowerCase()) return;
|
||||
const chainName =
|
||||
String(chain?.name || "").trim() ||
|
||||
(targetChainId === 1 ? "Ethereum Mainnet" : targetChainId === 137 ? "Polygon Mainnet" : `Chain ${targetChainId}`);
|
||||
const nativeSymbol =
|
||||
String(chain?.native_currency_symbol || "").trim() ||
|
||||
(targetChainId === 137 ? "POL" : "ETH");
|
||||
const explorerBase = String(chain?.block_explorer_url || "").trim() ||
|
||||
(targetChainId === 1 ? "https://etherscan.io" : targetChainId === 137 ? "https://polygonscan.com" : "");
|
||||
const defaultRpc =
|
||||
targetChainId === 137
|
||||
? "https://polygon-rpc.com"
|
||||
: targetChainId === 1
|
||||
? "https://ethereum-rpc.publicnode.com"
|
||||
: "";
|
||||
try {
|
||||
await requestWalletWithTimeout(
|
||||
eth,
|
||||
@@ -206,24 +225,31 @@ export function useWalletBind(params: UseWalletBindParams) {
|
||||
const code = Number(err?.code);
|
||||
if (code === 4902 || targetChainId === 137) {
|
||||
try {
|
||||
const addParams: Record<string, any> = {
|
||||
chainId: targetChainHex,
|
||||
chainName,
|
||||
nativeCurrency: { name: nativeSymbol, symbol: nativeSymbol, decimals: 18 },
|
||||
};
|
||||
if (defaultRpc) addParams.rpcUrls = [defaultRpc];
|
||||
if (explorerBase) addParams.blockExplorerUrls = [explorerBase];
|
||||
await requestWalletWithTimeout(
|
||||
eth,
|
||||
{
|
||||
method: "wallet_addEthereumChain",
|
||||
params: [{
|
||||
chainId: "0x89",
|
||||
chainName: "Polygon Mainnet",
|
||||
nativeCurrency: { name: "POL", symbol: "POL", decimals: 18 },
|
||||
rpcUrls: ["https://polygon-rpc.com"],
|
||||
blockExplorerUrls: ["https://polygonscan.com"],
|
||||
}],
|
||||
params: [addParams],
|
||||
},
|
||||
copy.chainAddPolygon,
|
||||
isEn ? `Add ${chainName}` : `添加 ${chainName}`,
|
||||
);
|
||||
return;
|
||||
} catch (addErr: any) { err = addErr; }
|
||||
}
|
||||
throw new Error(`${copy.chainSwitchPrompt} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`);
|
||||
throw new Error(
|
||||
`${
|
||||
isEn
|
||||
? `Please manually switch to ${chainName} in your wallet and try again.`
|
||||
: `请在钱包中手动切换到 ${chainName} 后再试。`
|
||||
} (${err?.message || (isEn ? "Network switch failed" : "网络切换失败")})`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -61,7 +61,17 @@ export function PaymentsPageClient() {
|
||||
value,
|
||||
}));
|
||||
|
||||
const COLORS = ["#ef4444", "#f59e0b", "#3b82f6", "#10b981", "#a855f7", "#6366f1", "#ec4899"];
|
||||
const COLORS = ["#ef4444", "#f59e0b", "#3b82f6", "#10b981", "#a855f7", "#6366f1", "#ec4899"];
|
||||
|
||||
function paymentExplorerUrl(payment: PaymentRecord): string {
|
||||
const txHash = String(payment.tx_hash || "").trim();
|
||||
if (!txHash) return "";
|
||||
const chain = String(payment.chain || "").trim().toLowerCase();
|
||||
const base = chain.includes("eth")
|
||||
? "https://etherscan.io"
|
||||
: "https://polygonscan.com";
|
||||
return `${base}/tx/${txHash}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -210,7 +220,7 @@ export function PaymentsPageClient() {
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
{p.tx_hash ? (
|
||||
<a
|
||||
href={`https://polygonscan.com/tx/${p.tx_hash}`}
|
||||
href={paymentExplorerUrl(p)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:text-blue-300 font-mono inline-flex items-center gap-1"
|
||||
|
||||
Reference in New Issue
Block a user