diff --git a/client/src/hooks/useBridgeWeb3.ts b/client/src/hooks/useBridgeWeb3.ts new file mode 100644 index 0000000..11f299e --- /dev/null +++ b/client/src/hooks/useBridgeWeb3.ts @@ -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 = { + 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; + 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({ + 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, + }; +} diff --git a/client/src/pages/Bridge.tsx b/client/src/pages/Bridge.tsx index fbb6584..1526bac 100644 --- a/client/src/pages/Bridge.tsx +++ b/client/src/pages/Bridge.tsx @@ -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() {

{t.xicReceiveAddrHint}

- {/* Step 4: Receiving address */} + {/* Step 4: Receiving address + Web3 transfer */}
-
- {t.sendTo} - - {selectedChain.icon} {selectedChain.name} - + {/* Header row with chain badge and USDT balance */} +
+
+ {t.sendTo} + + {selectedChain.icon} {selectedChain.name} + +
+ {/* USDT Balance display */} + {wallet.isConnected && ( +
+ {web3Bridge.usdtBalanceLoading ? ( + + ) : web3Bridge.usdtBalance !== null ? ( + + {lang === "zh" ? "余额" : "Bal"}: + {web3Bridge.usdtBalance} USDT + + ) : null} + +
+ )}
+

{t.sendToHint}

+ + {/* Receiving address with copy */}
+ + {/* One-click wallet transfer — shown when wallet connected and not yet registered */} + {wallet.isConnected && !registered && ( +
+
+
+ {lang === "zh" ? "或一键转账" : "or send directly"} +
+
+ + {web3Bridge.transferError && ( +
+ + {web3Bridge.transferError} +
+ )} +

+ {lang === "zh" + ? "钱包将弹出签名确认,无需手动复制地址" + : "Wallet will prompt for signature — no manual copy needed"} +

+
+ )}
{/* Confirm button */} diff --git a/todo.md b/todo.md index ebc1fdc..0a20c34 100644 --- a/todo.md +++ b/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库