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:
Manus 2026-03-10 06:46:06 -04:00
parent 84dd7d288f
commit 31a798a9ea
3 changed files with 371 additions and 10 deletions

View File

@ -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,
};
}

View File

@ -10,11 +10,12 @@ import { trpc } from "@/lib/trpc";
import { import {
ArrowDown, ArrowLeft, Copy, CheckCheck, ExternalLink, ArrowDown, ArrowLeft, Copy, CheckCheck, ExternalLink,
Loader2, RefreshCw, History, ChevronDown, ChevronUp, Loader2, RefreshCw, History, ChevronDown, ChevronUp,
Wallet, AlertCircle, CheckCircle2, Clock, XCircle Wallet, AlertCircle, CheckCircle2, Clock, XCircle, Zap
} from "lucide-react"; } from "lucide-react";
import { Link } from "wouter"; import { Link } from "wouter";
import { WalletSelector } from "@/components/WalletSelector"; import { WalletSelector } from "@/components/WalletSelector";
import { useWallet } from "@/hooks/useWallet"; import { useWallet } from "@/hooks/useWallet";
import { useBridgeWeb3 } from "@/hooks/useBridgeWeb3";
// ─── Language ───────────────────────────────────────────────────────────────── // ─── Language ─────────────────────────────────────────────────────────────────
type Lang = "zh" | "en"; type Lang = "zh" | "en";
@ -231,6 +232,14 @@ export default function Bridge() {
const wallet = useWallet(); const wallet = useWallet();
const [showWalletSelector, setShowWalletSelector] = useState(false); 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 // Auto-fill XIC receive address from connected wallet
useEffect(() => { useEffect(() => {
if (wallet.address && !xicReceiveAddress) { if (wallet.address && !xicReceiveAddress) {
@ -240,6 +249,8 @@ export default function Bridge() {
// tRPC: register bridge intent // tRPC: register bridge intent
const registerIntent = trpc.bridge.registerIntent.useMutation(); const registerIntent = trpc.bridge.registerIntent.useMutation();
// tRPC: record completed on-chain order
const recordOrder = trpc.bridge.recordOrder.useMutation();
// tRPC: query orders by wallet address // tRPC: query orders by wallet address
const { data: orders, isLoading: ordersLoading, refetch: refetchOrders } = trpc.bridge.myOrders.useQuery( const { data: orders, isLoading: ordersLoading, refetch: refetchOrders } = trpc.bridge.myOrders.useQuery(
@ -267,6 +278,71 @@ export default function Bridge() {
setTimeout(() => setCopiedHash(null), 2000); 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 // Validate and register intent
const handleConfirmSend = async () => { const handleConfirmSend = async () => {
const amount = Number(usdtAmount); const amount = Number(usdtAmount);
@ -509,21 +585,47 @@ export default function Bridge() {
<p className="text-xs text-white/30 mt-1">{t.xicReceiveAddrHint}</p> <p className="text-xs text-white/30 mt-1">{t.xicReceiveAddrHint}</p>
</div> </div>
{/* Step 4: Receiving address */} {/* Step 4: Receiving address + Web3 transfer */}
<div <div
className="rounded-xl p-4 space-y-3" 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)" }} style={{ background: "rgba(240,180,41,0.05)", border: "1px solid rgba(240,180,41,0.2)" }}
> >
<div className="flex items-center gap-2"> {/* Header row with chain badge and USDT balance */}
<span className="text-amber-400 text-sm font-semibold">{t.sendTo}</span> <div className="flex items-center justify-between">
<span <div className="flex items-center gap-2">
className="text-xs px-2 py-0.5 rounded-full font-medium" <span className="text-amber-400 text-sm font-semibold">{t.sendTo}</span>
style={{ background: `${selectedChain.color}22`, color: selectedChain.color }} <span
> className="text-xs px-2 py-0.5 rounded-full font-medium"
{selectedChain.icon} {selectedChain.name} style={{ background: `${selectedChain.color}22`, color: selectedChain.color }}
</span> >
{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> </div>
<p className="text-xs text-white/50">{t.sendToHint}</p> <p className="text-xs text-white/50">{t.sendToHint}</p>
{/* Receiving address with copy */}
<div <div
className="flex items-center gap-2 p-3 rounded-lg cursor-pointer hover:bg-white/5 transition-colors" 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)" }} 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} {copied ? t.copied : t.copyAddress}
</button> </button>
</div> </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> </div>
{/* Confirm button */} {/* Confirm button */}

13
todo.md
View File

@ -185,3 +185,16 @@
- [x] 管理员后台添加Bridge订单管理页面Bridge Intents + Bridge Orders表格状态过滤手动标记分发 - [x] 管理员后台添加Bridge订单管理页面Bridge Intents + Bridge Orders表格状态过滤手动标记分发
- [ ] 构建部署到AI服务器并测试 - [ ] 构建部署到AI服务器并测试
- [ ] 同步到备份Git库 - [ ] 同步到备份Git库
## v15 Web3.js集成完成记录
- [x] ethers.js v6 已集成(已有依赖)
- [x] 创建 useBridgeWeb3 hookUSDT余额查询、链上转账签名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库