Checkpoint: v15: Bridge页面Web3.js集成 - 1) 新建useBridgeWeb3 hook(ethers.js v6),实现USDT余额查询和链上transfer()签名;2) Bridge页面Step 4区域新增USDT余额显示(连接钱包后自动获取,支持手动刷新);3) 新增"Send via Wallet"一键转账按钮,用户无需手动复制地址,直接通过钱包签名发起USDT转账;4) 转账成功后自动调用bridge.recordOrder记录到后端;5) 链不匹配时自动触发switchNetwork;6) 完整错误处理(用户取消/余额不足/网络错误)
This commit is contained in:
parent
84dd7d288f
commit
31a798a9ea
|
|
@ -0,0 +1,186 @@
|
|||
// NAC XIC Presale — Bridge Web3 Hook
|
||||
// Provides USDT balance query and on-chain USDT transfer via connected wallet
|
||||
// Uses ethers.js v6 (already installed)
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Contract, parseUnits, formatUnits, BrowserProvider, JsonRpcSigner } from "ethers";
|
||||
|
||||
// Minimal ERC-20 ABI for USDT operations
|
||||
const ERC20_ABI = [
|
||||
"function balanceOf(address owner) view returns (uint256)",
|
||||
"function decimals() view returns (uint8)",
|
||||
"function symbol() view returns (string)",
|
||||
"function transfer(address to, uint256 amount) returns (bool)",
|
||||
];
|
||||
|
||||
// USDT contract addresses per chain
|
||||
const USDT_CONTRACTS: Record<number, string> = {
|
||||
56: "0x55d398326f99059fF775485246999027B3197955", // BSC USDT (BEP-20, 18 decimals)
|
||||
1: "0xdAC17F958D2ee523a2206206994597C13D831ec7", // ETH USDT (ERC-20, 6 decimals)
|
||||
137: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", // Polygon USDT (6 decimals)
|
||||
42161: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", // Arbitrum USDT (6 decimals)
|
||||
43114: "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", // Avalanche USDT (6 decimals)
|
||||
};
|
||||
|
||||
export interface BridgeWeb3State {
|
||||
usdtBalance: string | null; // formatted balance e.g. "1234.56"
|
||||
usdtBalanceLoading: boolean;
|
||||
transferring: boolean;
|
||||
transferError: string | null;
|
||||
transferTxHash: string | null;
|
||||
transferSuccess: boolean;
|
||||
}
|
||||
|
||||
export interface UseBridgeWeb3Return extends BridgeWeb3State {
|
||||
fetchUsdtBalance: () => Promise<void>;
|
||||
sendUsdtTransfer: (params: {
|
||||
toAddress: string;
|
||||
usdtAmount: number;
|
||||
chainId: number;
|
||||
decimals: number;
|
||||
}) => Promise<{ txHash: string } | null>;
|
||||
resetTransferState: () => void;
|
||||
}
|
||||
|
||||
export function useBridgeWeb3(
|
||||
provider: BrowserProvider | null,
|
||||
signer: JsonRpcSigner | null,
|
||||
address: string | null,
|
||||
chainId: number | null
|
||||
): UseBridgeWeb3Return {
|
||||
const [state, setState] = useState<BridgeWeb3State>({
|
||||
usdtBalance: null,
|
||||
usdtBalanceLoading: false,
|
||||
transferring: false,
|
||||
transferError: null,
|
||||
transferTxHash: null,
|
||||
transferSuccess: false,
|
||||
});
|
||||
|
||||
// Fetch USDT balance for current chain
|
||||
const fetchUsdtBalance = useCallback(async () => {
|
||||
if (!provider || !address || !chainId) {
|
||||
setState(s => ({ ...s, usdtBalance: null }));
|
||||
return;
|
||||
}
|
||||
const usdtAddr = USDT_CONTRACTS[chainId];
|
||||
if (!usdtAddr) {
|
||||
setState(s => ({ ...s, usdtBalance: null }));
|
||||
return;
|
||||
}
|
||||
setState(s => ({ ...s, usdtBalanceLoading: true }));
|
||||
try {
|
||||
const contract = new Contract(usdtAddr, ERC20_ABI, provider);
|
||||
const [rawBalance, decimals] = await Promise.all([
|
||||
contract.balanceOf(address),
|
||||
contract.decimals(),
|
||||
]);
|
||||
const formatted = formatUnits(rawBalance, decimals);
|
||||
const display = parseFloat(formatted).toFixed(2);
|
||||
setState(s => ({ ...s, usdtBalance: display, usdtBalanceLoading: false }));
|
||||
} catch (err) {
|
||||
console.warn("[useBridgeWeb3] fetchUsdtBalance error:", err);
|
||||
setState(s => ({ ...s, usdtBalance: null, usdtBalanceLoading: false }));
|
||||
}
|
||||
}, [provider, address, chainId]);
|
||||
|
||||
// Auto-fetch balance when wallet/chain changes
|
||||
useEffect(() => {
|
||||
if (address && chainId && provider) {
|
||||
fetchUsdtBalance();
|
||||
} else {
|
||||
setState(s => ({ ...s, usdtBalance: null }));
|
||||
}
|
||||
}, [address, chainId, provider, fetchUsdtBalance]);
|
||||
|
||||
// Send USDT transfer via wallet signature
|
||||
const sendUsdtTransfer = useCallback(async ({
|
||||
toAddress,
|
||||
usdtAmount,
|
||||
chainId: targetChainId,
|
||||
decimals,
|
||||
}: {
|
||||
toAddress: string;
|
||||
usdtAmount: number;
|
||||
chainId: number;
|
||||
decimals: number;
|
||||
}): Promise<{ txHash: string } | null> => {
|
||||
if (!signer || !address) {
|
||||
setState(s => ({ ...s, transferError: "Wallet not connected" }));
|
||||
return null;
|
||||
}
|
||||
const usdtAddr = USDT_CONTRACTS[targetChainId];
|
||||
if (!usdtAddr) {
|
||||
setState(s => ({ ...s, transferError: `USDT not supported on chain ${targetChainId}` }));
|
||||
return null;
|
||||
}
|
||||
|
||||
setState(s => ({
|
||||
...s,
|
||||
transferring: true,
|
||||
transferError: null,
|
||||
transferTxHash: null,
|
||||
transferSuccess: false,
|
||||
}));
|
||||
|
||||
try {
|
||||
const contract = new Contract(usdtAddr, ERC20_ABI, signer);
|
||||
const amountWei = parseUnits(usdtAmount.toString(), decimals);
|
||||
|
||||
// Send transfer transaction — wallet will prompt for signature
|
||||
const tx = await contract.transfer(toAddress, amountWei);
|
||||
const txHash: string = tx.hash;
|
||||
|
||||
setState(s => ({ ...s, transferTxHash: txHash }));
|
||||
|
||||
// Wait for 1 confirmation
|
||||
await tx.wait(1);
|
||||
|
||||
setState(s => ({
|
||||
...s,
|
||||
transferring: false,
|
||||
transferSuccess: true,
|
||||
transferTxHash: txHash,
|
||||
}));
|
||||
|
||||
// Refresh balance after transfer
|
||||
setTimeout(() => fetchUsdtBalance(), 2000);
|
||||
|
||||
return { txHash };
|
||||
} catch (err: unknown) {
|
||||
const error = err as { code?: number; message?: string };
|
||||
let msg: string;
|
||||
if (
|
||||
error?.code === 4001 ||
|
||||
error?.message?.toLowerCase().includes("user rejected") ||
|
||||
error?.message?.toLowerCase().includes("user denied") ||
|
||||
error?.message?.toLowerCase().includes("cancelled")
|
||||
) {
|
||||
msg = "Transaction cancelled by user";
|
||||
} else if (error?.message?.toLowerCase().includes("insufficient")) {
|
||||
msg = "Insufficient USDT balance or gas fee";
|
||||
} else {
|
||||
msg = error?.message || "Transaction failed";
|
||||
}
|
||||
setState(s => ({ ...s, transferring: false, transferError: msg }));
|
||||
return null;
|
||||
}
|
||||
}, [signer, address, fetchUsdtBalance]);
|
||||
|
||||
const resetTransferState = useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
transferring: false,
|
||||
transferError: null,
|
||||
transferTxHash: null,
|
||||
transferSuccess: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
fetchUsdtBalance,
|
||||
sendUsdtTransfer,
|
||||
resetTransferState,
|
||||
};
|
||||
}
|
||||
|
|
@ -10,11 +10,12 @@ import { trpc } from "@/lib/trpc";
|
|||
import {
|
||||
ArrowDown, ArrowLeft, Copy, CheckCheck, ExternalLink,
|
||||
Loader2, RefreshCw, History, ChevronDown, ChevronUp,
|
||||
Wallet, AlertCircle, CheckCircle2, Clock, XCircle
|
||||
Wallet, AlertCircle, CheckCircle2, Clock, XCircle, Zap
|
||||
} from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { WalletSelector } from "@/components/WalletSelector";
|
||||
import { useWallet } from "@/hooks/useWallet";
|
||||
import { useBridgeWeb3 } from "@/hooks/useBridgeWeb3";
|
||||
|
||||
// ─── Language ─────────────────────────────────────────────────────────────────
|
||||
type Lang = "zh" | "en";
|
||||
|
|
@ -231,6 +232,14 @@ export default function Bridge() {
|
|||
const wallet = useWallet();
|
||||
const [showWalletSelector, setShowWalletSelector] = useState(false);
|
||||
|
||||
// Web3 bridge: USDT balance + on-chain transfer
|
||||
const web3Bridge = useBridgeWeb3(
|
||||
wallet.provider,
|
||||
wallet.signer,
|
||||
wallet.address,
|
||||
wallet.chainId
|
||||
);
|
||||
|
||||
// Auto-fill XIC receive address from connected wallet
|
||||
useEffect(() => {
|
||||
if (wallet.address && !xicReceiveAddress) {
|
||||
|
|
@ -240,6 +249,8 @@ export default function Bridge() {
|
|||
|
||||
// tRPC: register bridge intent
|
||||
const registerIntent = trpc.bridge.registerIntent.useMutation();
|
||||
// tRPC: record completed on-chain order
|
||||
const recordOrder = trpc.bridge.recordOrder.useMutation();
|
||||
|
||||
// tRPC: query orders by wallet address
|
||||
const { data: orders, isLoading: ordersLoading, refetch: refetchOrders } = trpc.bridge.myOrders.useQuery(
|
||||
|
|
@ -267,6 +278,71 @@ export default function Bridge() {
|
|||
setTimeout(() => setCopiedHash(null), 2000);
|
||||
}, []);
|
||||
|
||||
// Handle on-chain USDT transfer via wallet (Web3)
|
||||
const handleSendViaWallet = async () => {
|
||||
const amount = Number(usdtAmount);
|
||||
if (!usdtAmount || isNaN(amount) || amount <= 0) {
|
||||
toast.error(t.amountRequired);
|
||||
return;
|
||||
}
|
||||
if (amount < 10) {
|
||||
toast.error(t.amountMin);
|
||||
return;
|
||||
}
|
||||
if (!xicReceiveAddress || !/^0x[0-9a-fA-F]{40}$/.test(xicReceiveAddress)) {
|
||||
toast.error(t.addressRequired);
|
||||
return;
|
||||
}
|
||||
if (!wallet.isConnected || !wallet.signer) {
|
||||
toast.error(lang === "zh" ? "请先连接钱包" : "Please connect wallet first");
|
||||
setShowWalletSelector(true);
|
||||
return;
|
||||
}
|
||||
// Check if on correct chain
|
||||
if (wallet.chainId !== selectedChain.chainId) {
|
||||
toast.error(lang === "zh"
|
||||
? `请切换到 ${selectedChain.name} 网络`
|
||||
: `Please switch to ${selectedChain.name} network`);
|
||||
await wallet.switchNetwork(selectedChain.chainId);
|
||||
return;
|
||||
}
|
||||
web3Bridge.resetTransferState();
|
||||
const result = await web3Bridge.sendUsdtTransfer({
|
||||
toAddress: selectedChain.receivingAddress,
|
||||
usdtAmount: amount,
|
||||
chainId: selectedChain.chainId,
|
||||
decimals: selectedChain.usdtDecimals,
|
||||
});
|
||||
if (result?.txHash) {
|
||||
// Record the completed on-chain order
|
||||
try {
|
||||
await recordOrder.mutateAsync({
|
||||
txHash: result.txHash,
|
||||
walletAddress: wallet.address || xicReceiveAddress,
|
||||
fromChainId: selectedChain.chainId,
|
||||
fromToken: "USDT",
|
||||
fromAmount: amount.toString(),
|
||||
toChainId: 56, // XIC is on BSC
|
||||
toToken: "XIC",
|
||||
toAmount: (amount / XIC_PRICE).toFixed(0),
|
||||
});
|
||||
setRegistered(true);
|
||||
toast.success(lang === "zh"
|
||||
? `转账成功!TX: ${result.txHash.slice(0, 10)}...`
|
||||
: `Transfer sent! TX: ${result.txHash.slice(0, 10)}...`);
|
||||
setQueryAddress(xicReceiveAddress || wallet.address || "");
|
||||
setHistoryAddress(xicReceiveAddress || wallet.address || "");
|
||||
} catch {
|
||||
setRegistered(true);
|
||||
toast.success(lang === "zh"
|
||||
? `转账已发送!TX: ${result.txHash.slice(0, 10)}...`
|
||||
: `Transfer sent! TX: ${result.txHash.slice(0, 10)}...`);
|
||||
}
|
||||
} else if (web3Bridge.transferError) {
|
||||
toast.error(web3Bridge.transferError);
|
||||
}
|
||||
};
|
||||
|
||||
// Validate and register intent
|
||||
const handleConfirmSend = async () => {
|
||||
const amount = Number(usdtAmount);
|
||||
|
|
@ -509,21 +585,47 @@ export default function Bridge() {
|
|||
<p className="text-xs text-white/30 mt-1">{t.xicReceiveAddrHint}</p>
|
||||
</div>
|
||||
|
||||
{/* Step 4: Receiving address */}
|
||||
{/* Step 4: Receiving address + Web3 transfer */}
|
||||
<div
|
||||
className="rounded-xl p-4 space-y-3"
|
||||
style={{ background: "rgba(240,180,41,0.05)", border: "1px solid rgba(240,180,41,0.2)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-400 text-sm font-semibold">{t.sendTo}</span>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
style={{ background: `${selectedChain.color}22`, color: selectedChain.color }}
|
||||
>
|
||||
{selectedChain.icon} {selectedChain.name}
|
||||
</span>
|
||||
{/* Header row with chain badge and USDT balance */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-amber-400 text-sm font-semibold">{t.sendTo}</span>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
style={{ background: `${selectedChain.color}22`, color: selectedChain.color }}
|
||||
>
|
||||
{selectedChain.icon} {selectedChain.name}
|
||||
</span>
|
||||
</div>
|
||||
{/* USDT Balance display */}
|
||||
{wallet.isConnected && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
{web3Bridge.usdtBalanceLoading ? (
|
||||
<Loader2 size={10} className="animate-spin text-white/40" />
|
||||
) : web3Bridge.usdtBalance !== null ? (
|
||||
<span className="text-white/50">
|
||||
{lang === "zh" ? "余额" : "Bal"}:
|
||||
<span className="text-amber-300 font-mono ml-1">{web3Bridge.usdtBalance} USDT</span>
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => web3Bridge.fetchUsdtBalance()}
|
||||
className="text-white/30 hover:text-white/60 transition-colors ml-1"
|
||||
title={lang === "zh" ? "刷新余额" : "Refresh balance"}
|
||||
>
|
||||
<RefreshCw size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-white/50">{t.sendToHint}</p>
|
||||
|
||||
{/* Receiving address with copy */}
|
||||
<div
|
||||
className="flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-white/5 transition-colors"
|
||||
style={{ background: "rgba(0,0,0,0.3)", border: "1px solid rgba(255,255,255,0.1)" }}
|
||||
|
|
@ -546,6 +648,66 @@ export default function Bridge() {
|
|||
{copied ? t.copied : t.copyAddress}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* One-click wallet transfer — shown when wallet connected and not yet registered */}
|
||||
{wallet.isConnected && !registered && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.08)" }} />
|
||||
<span className="text-xs text-white/30">{lang === "zh" ? "或一键转账" : "or send directly"}</span>
|
||||
<div className="flex-1 h-px" style={{ background: "rgba(255,255,255,0.08)" }} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendViaWallet}
|
||||
disabled={web3Bridge.transferring}
|
||||
className="w-full py-3 rounded-xl font-bold text-sm transition-all flex items-center justify-center gap-2"
|
||||
style={{
|
||||
background: web3Bridge.transferring
|
||||
? "rgba(0,212,255,0.1)"
|
||||
: "linear-gradient(135deg, rgba(0,212,255,0.15) 0%, rgba(0,150,200,0.25) 100%)",
|
||||
border: "1px solid rgba(0,212,255,0.35)",
|
||||
color: web3Bridge.transferring ? "rgba(0,212,255,0.4)" : "#00d4ff",
|
||||
cursor: web3Bridge.transferring ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
{web3Bridge.transferring ? (
|
||||
<>
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
{lang === "zh" ? "转账中...请在钱包确认" : "Sending... confirm in wallet"}
|
||||
{web3Bridge.transferTxHash && (
|
||||
<span className="text-xs opacity-50 font-mono">
|
||||
{web3Bridge.transferTxHash.slice(0, 8)}...
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap size={15} />
|
||||
{lang === "zh" ? "一键钱包转账" : "Send via Wallet"}
|
||||
{usdtAmount && Number(usdtAmount) > 0 && (
|
||||
<span className="text-xs opacity-60">
|
||||
{usdtAmount} USDT
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{web3Bridge.transferError && (
|
||||
<div
|
||||
className="rounded-lg p-2.5 text-xs flex items-start gap-2"
|
||||
style={{ background: "rgba(255,80,80,0.08)", border: "1px solid rgba(255,80,80,0.2)", color: "#ff8080" }}
|
||||
>
|
||||
<AlertCircle size={12} className="shrink-0 mt-0.5" />
|
||||
<span>{web3Bridge.transferError}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-white/25 text-center">
|
||||
{lang === "zh"
|
||||
? "钱包将弹出签名确认,无需手动复制地址"
|
||||
: "Wallet will prompt for signature — no manual copy needed"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm button */}
|
||||
|
|
|
|||
13
todo.md
13
todo.md
|
|
@ -185,3 +185,16 @@
|
|||
- [x] 管理员后台添加Bridge订单管理页面(Bridge Intents + Bridge Orders表格,状态过滤,手动标记分发)
|
||||
- [ ] 构建部署到AI服务器并测试
|
||||
- [ ] 同步到备份Git库
|
||||
|
||||
## v15 Web3.js集成完成记录
|
||||
|
||||
- [x] ethers.js v6 已集成(已有依赖)
|
||||
- [x] 创建 useBridgeWeb3 hook:USDT余额查询、链上转账签名(client/src/hooks/useBridgeWeb3.ts)
|
||||
- [x] Bridge页面:连接钱包后在Step 4区域显示USDT余额(右上角余额+刷新按钮)
|
||||
- [x] Bridge页面:新增"Send via Wallet"按钮,调用ethers.js发起真实USDT transfer()
|
||||
- [x] Bridge页面:转账成功后自动调用bridge.recordOrder记录到后端数据库
|
||||
- [x] 转账中显示加载动画和TX Hash前缀
|
||||
- [x] 转账失败显示错误信息(用户取消/余额不足/其他错误)
|
||||
- [x] 链不匹配时自动触发switchNetwork
|
||||
- [ ] 构建部署到AI服务器并测试
|
||||
- [ ] 同步到备份Git库
|
||||
|
|
|
|||
Loading…
Reference in New Issue