章节定位:打通智能合约到前端用户的最后一公里。本章聚焦 ethers.js、Viem 两个主流库的核心用法,现代钱包连接标准,以及交易从构造到确认的全链路追踪。
14.1 以太坊前端库:ethers.js 与 Viem
14.1.1 为什么需要专门的前端库
在浏览器中直接与以太坊节点通信的原始方式是发送裸 JSON-RPC 请求。开发者需要手动完成 ABI 编码、十六进制转换、签名构造、事件解码等工作,不仅极易出错,而且维护成本极高。现代前端库的核心价值在于充当"翻译层"——将人类友好的 JavaScript/TypeScript API 自动转换为节点可理解的 RPC 调用,并处理返回数据的完整反序列化。
现代 DApp 对前端库还提出了更高要求:TypeScript 原生类型推断、ES 模块级别的树摇优化(Tree-shaking)以控制打包体积、结构化的错误码体系便于精准提示用户。
14.1.2 ethers.js v6 的核心架构
ethers.js v6 采用 Provider、Signer、Contract 三层分离的架构设计,职责边界清晰:
- Provider(提供者):只读接口,负责与以太坊节点通信,获取区块高度、账户余额、合约读取结果等公开数据。支持在 Infura、Alchemy、Etherscan 等后端之间平滑切换。
- Signer(签名者):专属于某一外部账户(EOA),持有私钥或代理到浏览器钱包(如 MetaMask),用于对交易进行数字签名。
- Contract(合约对象):将合约的 ABI 与部署地址绑定为一个可调用对象,内部自动完成函数选择器计算、参数 ABI 编码、返回值解码以及事件日志解析。
ethers.js 采用事件驱动模型,contract.on("EventName", callback) 可让前端在链上事件触发时自动同步 UI 状态。
architecture
title ethers.js v6 三层架构
group "读操作" as read {
service "Provider" as Provider <<只读>>
}
group "写操作" as write {
service "Signer" as Signer <<签名>>
}
group "交互" as interact {
service "Contract" as Contract <<绑定 ABI+地址>>
}
group "外部" as external {
service "以太坊节点" as Node <<RPC>>
service "浏览器钱包" as Wallet <<MetaMask>>
}
Provider --> Node: HTTP / WebSocket
Signer --> Wallet: EIP-1193
Contract --> Provider: 调用 read
Contract --> Signer: 调用 write / 签名
14.1.3 Viem 的设计哲学
Viem 是新一代以太坊前端库,其设计围绕类型安全、轻量与模块化展开:
- 类型安全:Viem 支持从 ABI 自动生成 TypeScript 类型,实现"写错函数名即编译失败"。
- 树摇优化:底层完全采用原生 ES 模块,未使用的代码在打包阶段可被消除,最终产物显著更小。
- 模块化架构:
Public Client(只读)+Wallet Client(签名写操作)+ 合约交互函数,语义清晰,可与 ethers.js 的概念形成直接映射。
在生态层面,Viem 配合 wagmi 与 RainbowKit 已成为新 DApp 项目的事实推荐栈。
14.1.4 ethers.js v6 与 Viem 横向对比
| 维度 | ethers.js v6 | Viem |
|---|---|---|
| 包体积 | 较大(含高级工具函数) | 更小,树摇效果更优 |
| TypeScript 支持 | 良好 | 从 ABI 推导类型,更完善 |
| 错误处理 | 异常体系成熟 | 结构化错误码,精准定位 |
| RPC 调用效率 | 标准 | 批量请求与缓存策略更精细 |
选型建议:新项目优先评估 Viem;基于 ethers.js v5/v6 的遗产项目若追求类型极致安全,可逐步迁移至 Viem。
14.1.5 代码示例
以下展示 ethers.js v6 的完整读写合约流程(TypeScript):
import { BrowserProvider, Contract, JsonRpcProvider, formatEther, parseEther } from "ethers";
// 1. 创建 Provider(读)
const readProvider = new JsonRpcProvider("https://mainnet.infura.io/v3/YOUR_KEY");
// 2. 连接浏览器钱包(Signer)
const browserProvider = new BrowserProvider(window.ethereum!);
const signer = await browserProvider.getSigner();
// 3. 定义 ABI 与合约地址
const ERC20_ABI = [
"function balanceOf(address) view returns (uint256)",
"function transfer(address to, uint256 amount) returns (bool)",
"event Transfer(address indexed from, address indexed to, uint256 value)",
];
const USDC_ADDRESS = "0xA0b86a33E9E31A0E6dF1d4E9f7B9C3d2e4F5a6B7";
// 4. 读取:仅依赖 Provider
const readContract = new Contract(USDC_ADDRESS, ERC20_ABI, readProvider);
const balance = await readContract.balanceOf(signer.address);
console.log("Balance:", formatEther(balance));
// 5. 写入:绑定 Signer
const writeContract = new Contract(USDC_ADDRESS, ERC20_ABI, signer);
const tx = await writeContract.transfer("0xRecipient...", parseEther("1"));
const receipt = await tx.wait();
console.log("Confirmed in block:", receipt?.blockNumber);
// 6. 监听事件
readContract.on("Transfer", (from, to, value, event) => {
console.log(`Transfer: {to} : ${formatEther(value)}`);
});以下展示同等功能在 Viem 中的完整实现(TypeScript):
import {
createPublicClient, createWalletClient, http, parseEther, formatEther,
custom, getContract, erc20Abi
} from "viem";
import { mainnet } from "viem/chains";
import type { Transport, Chain, Account } from "viem";
// 1. Public Client(读)
const publicClient = createPublicClient({
chain: mainnet,
transport: http("https://mainnet.infura.io/v3/YOUR_KEY"),
});
// 2. Wallet Client(写)
const walletClient = createWalletClient({
chain: mainnet,
transport: custom(window.ethereum!),
});
const [account] = await walletClient.getAddresses();
// 3. 读取:readContract
const balance = await publicClient.readContract({
address: "0xA0b86a33E9E31A0E6dF1d4E9f7B9C3d2e4F5a6B7",
abi: erc20Abi,
functionName: "balanceOf",
args: [account],
});
console.log("Viem Balance:", formatEther(balance));
// 4. 写入:writeContract
const hash = await walletClient.writeContract({
address: "0xA0b86a33E9E31A0E6dF1d4E9f7B9C3d2e4F5a6B7",
abi: erc20Abi,
functionName: "transfer",
args: ["0xRecipient...", parseEther("1")],
account,
});
// 5. 等待确认
const receipt = await publicClient.waitForTransactionReceipt({ hash });
console.log("Confirmed:", receipt.blockNumber, "Status:", receipt.status);
// 6. 监听事件
const unwatch = publicClient.watchContractEvent({
address: "0xA0b86a33E9E31A0E6dF1d4E9f7B9C3d2e4F5a6B7",
abi: erc20Abi,
eventName: "Transfer",
onLogs: (logs) => {
for (const log of logs) {
console.log(`Transfer: {log.args.to} : ${formatEther(log.args.value!)}`);
}
},
});14.1 小节要点
- 前端库是合约 ABI 与 RPC 节点之间的"翻译层",避免开发者手动处理编码解码。
- ethers.js v6 通过 Provider / Signer / Contract 分离读写与签名职责,事件驱动模型成熟。
- Viem 以类型安全、树摇优化和精细 RPC 策略见长,配合 wagmi 已成为新项目的推荐栈。
- 新项目建议从 Viem 起步;维护旧版 ethers.js 项目时,从 v5 迁移至 v6 或 Viem 均可。
14.2 钱包连接:MetaMask、WalletConnect 与嵌入式钱包
14.2.1 从被动注入到主动请求:EIP-1102 与 EIP-1193
早期浏览器钱包直接将对象注入为 window.ethereum,DApp 页面加载即可读取用户地址,存在严重的隐私泄露风险。2018 年提出的 EIP-1102 引入了 eth_requestAccounts 方法,要求 DApp 必须主动请求,用户通过弹窗显式授权后才会暴露地址。
EIP-1193 进一步标准化了 Provider API:统一 request 方法,并标准化了四个核心事件——accountsChanged、chainChanged、connect、disconnect。这让前端代码可以与具体钱包品牌解耦,一份监听逻辑即可适配多种钱包。
14.2.2 EIP-6963:解决多钱包互斥注入
当用户同时安装 MetaMask、Coinbase Wallet、Rabby 等扩展时,它们往往竞相注入 window.ethereum,后来者覆盖前者,导致用户被迫只能使用单一钱包。EIP-6963 定义了新的发现机制:每个钱包在页面加载时通过 window.dispatchEvent 广播自身的 rdns(反向域名标识)、名称、图标和 Provider 实例。DApp 通过监听 eip6963:announceProvider 即可收集所有已安装钱包,构建真正的多钱包选择器。
以下展示使用原始事件监听实现钱包发现(TypeScript):
interface EIP6963ProviderInfo {
rdns: string;
uuid: string;
name: string;
icon: string;
}
interface EIP6963ProviderDetail {
info: EIP6963ProviderInfo;
provider: any;
}
const providers: EIP6963ProviderDetail[] = [];
window.addEventListener("eip6963:announceProvider", (event: any) => {
const { info, provider } = event.detail as EIP6963ProviderDetail;
if (!providers.find((p) => p.info.uuid === info.uuid)) {
providers.push({ info, provider });
console.log("Wallet discovered:", info.name);
}
});
// 触发所有钱包开始广播
window.dispatchEvent(new Event("eip6963:requestProvider"));
// 选择后连接
async function connectWallet(provider: any) {
const accounts = await provider.request({
method: "eth_requestAccounts",
});
console.log("Connected:", accounts[0]);
}14.2.3 WalletConnect v2 的协议架构
WalletConnect v2 采用基于去中心化消息网络的 Relay 协议,DApp 与钱包无需直接建立 P2P 连接。其会话生命周期包括:
- 配对(Pair):DApp 生成 URI,钱包扫描或跳转;
- 会话建立(Session):双方协商授权链与权限;
- 请求/签名/发送:通过 JSON-RPC 2.0 中继消息;
- 断开:任一方主动关闭会话。
WalletConnect v2 原生支持多链,一个会话可同时授权以太坊主网、Polygon、Base 等多条链的读写权限。在前端实践中,@walletconnect/ethereum-provider 或 wagmi 的 WalletConnectConnector 已封装了复杂的配对流程。
sequenceDiagram
participant User as 用户
participant DApp as DApp 前端
participant Selector as 钱包选择器
participant Meta as MetaMask
participant WC as WalletConnect
participant Embedded as 嵌入式钱包
participant Chain as 区块链
User->>DApp: 点击"连接钱包"
DApp->>Selector: 展示选项
Selector->>Meta: 选项 A:EIP-6963 列表
Selector->>WC: 选项 B:显示 WalletConnect QR 码
Selector->>Embedded: 选项 C:邮箱 / 社交登录
alt 选择 MetaMask
User->>Meta: 选择并授权
Meta->>DApp: 返回地址 + Signer
else 选择 WalletConnect
User->>WC: 扫描二维码
WC->>DApp: 建立中继会话
WC->>DApp: 返回地址
else 嵌入式登录
User->>Embedded: 邮箱 / 社交账号
Embedded->>DApp: 服务端返回地址
end
DApp->>DApp: 更新全局连接状态
DApp->>Chain: 查询余额 / 交易记录
14.2.4 嵌入式钱包:降低 Web3 入门门槛
嵌入式钱包(Embedded Wallet)将密钥管理完全托管在 DApp 后端或第三方服务(如 Privy、Dynamic、Web3Auth),用户仅需邮箱、手机号或社交账号登录即可拥有链上地址。服务端在背后自动生成并管理 EOA 或 MPC(多方计算)分片钱包。这种方案消除了助记词、私钥备份、Gas 费计算等认知门槛,使用户获得接近 Web2 的登录体验。
在安全与信任边界上,用户不直接持有私钥,必须依赖服务方的安全架构。部分方案通过 MPC 分片将密钥分存于多方,降低单点泄露风险。嵌入式钱包适用于消费级应用、链上游戏和内容平台,典型的策略是"渐进式去中心化":先用嵌入式钱包引入用户,后续引导其导出或迁移至自主托管钱包。
以下展示 EIP-1193 连接与事件监听的标准实现:
declare global {
interface Window {
ethereum?: any;
}
}
async function connectEIP1193() {
if (!window.ethereum) {
throw new Error("No EIP-1193 provider found. Please install a wallet.");
}
// 请求连接
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
const chainId = await window.ethereum.request({ method: "eth_chainId" });
console.log("Connected account:", accounts[0], "Chain:", chainId);
// 监听账户切换
window.ethereum.on("accountsChanged", (newAccounts: string[]) => {
if (newAccounts.length === 0) {
console.log("Wallet disconnected");
} else {
console.log("Switched to:", newAccounts[0]);
}
});
// 监听链切换
window.ethereum.on("chainChanged", (newChainId: string) => {
console.log("Switched chain to:", newChainId);
// 推荐刷新页面重载状态
window.location.reload();
});
// 监听连接与断开
window.ethereum.on("connect", (info: { chainId: string }) => {
console.log("Connected to chain", info.chainId);
});
window.ethereum.on("disconnect", (error: Error) => {
console.log("Disconnected:", error);
});
return { account: accounts[0], chainId };
}14.2 小节要点
- EIP-1102 与 EIP-1193 实现了从被动注入到主动请求的转变,统一 Provider 事件让前端与具体钱包品牌解耦。
- EIP-6963 通过广播机制解决多钱包互斥注入,使真正的多钱包选择器成为可能。
- WalletConnect v2 基于 Relay 协议,支持一个会话多链授权,移动端体验友好。
- 嵌入式钱包以牺牲完全自托管为代价,大幅降低消费级应用的用户入门门槛。
14.3 交易构造、发送与状态追踪
14.3.1 交易对象字段详解
EIP-1559 风格的交易对象包含以下核心字段:
| 字段 | 语义 |
|---|---|
to | 目标地址:接收 ETH 的 EOA,或待调用的合约地址。 |
data | Calldata:合约函数调用的 ABI 编码,纯 ETH 转账时可为空。 |
value | 转账金额,以 wei 为单位。 |
gasLimit | 交易允许消耗的最大 Gas 单位,决定执行上限。 |
maxFeePerGas | 每单位 Gas 愿意支付的最高总费用上限。 |
maxPriorityFeePerGas | 给验证者的额外小费,影响交易进入区块的优先级。 |
与旧版 Legacy 交易(单一 gasPrice)相比,EIP-1559 的费用模型使成本更可预测,gasLimit * maxFeePerGas 构成用户总成本上限,而实际支付额由协议动态决定的基础费与小费共同构成。
EIP-1559 交易费用的两个核心计算公式如下。
交易总费用上限(MaxCost):
用户实际支付费用(ActualCost):
其中 baseFeePerGas 由协议算法根据前一区块的 Gas 目标利用率(50%)动态调节,并被直接销毁;优先费部分归验证者所有,决定交易的入块优先级。
14.3.2 前端 Gas 估算策略
前端在发送交易前需合理填充上述费率字段。Viem 提供了便捷的 Gas 估算 API:
import { createPublicClient, http, parseEther, formatGwei } from "viem";
import { mainnet } from "viem/chains";
const publicClient = createPublicClient({
chain: mainnet,
transport: http("https://mainnet.infura.io/v3/YOUR_KEY"),
});
// 1. 估算 gasLimit
const estimatedGas = await publicClient.estimateGas({
account: "0xSenderAddress...",
to: "0xRecipientAddress...",
value: parseEther("0.01"),
data: "0x",
});
console.log("Estimated gas limit:", estimatedGas);
// 2. 估算 EIP-1559 费用
const { maxFeePerGas, maxPriorityFeePerGas } =
await publicClient.estimateFeesPerGas();
// 3. 前端三档位 UI 计算逻辑
function getGasTiers(baseMax: bigint, basePriority: bigint) {
return {
slow: {
maxFeePerGas: (baseMax * 100n) / 100n,
maxPriorityFeePerGas: (basePriority * 80n) / 100n,
},
standard: {
maxFeePerGas: (baseMax * 115n) / 100n,
maxPriorityFeePerGas: (basePriority * 100n) / 100n,
},
fast: {
maxFeePerGas: (baseMax * 150n) / 100n,
maxPriorityFeePerGas: (basePriority * 150n) / 100n,
},
};
}
const tiers = getGasTiers(maxFeePerGas, maxPriorityFeePerGas);
console.log("Standard tier maxFee:", formatGwei(tiers.standard.maxFeePerGas), "gwei");14.3.3 交易状态生命周期
一笔交易从发出到最终确认,经历如下状态:
stateDiagram-v2
[*] --> constructed: 填充字段
constructed --> signed: 钱包签名
signed --> broadcast: 提交到 RPC / Mempool
broadcast --> pending: 进入待处理队列
pending --> included: 被打包进区块
included --> success: status = 1
included --> revert: status = 0
success --> confirmed: 确认数增长
revert --> confirmed: 确认数增长(状态回滚)
confirmed --> [*]: 达到目标确认数(如12)
pending --> replaced: 用户加速(speed up)
replaced --> signed: 更高费率重新签名
pending --> cancelled: 用户取消(0 value to self)
- pending:交易位于 Mempool,等待验证者打包。若
maxFeePerGas低于当前 Base Fee,将永久停留 pending 状态。 - included / success / revert:交易进入区块后,回执中的
status字段为1表示执行成功;0表示 EVM 执行回滚——所有状态变更被撤销,但已消耗的 Gas 费用不退还。 - confirmed(N):随着后续区块不断叠加,交易确认数增加。以太坊通常以 12 个区块确认(约 2.5 分钟)视为安全。
14.3.4 自定义 useSendTransaction Hook
在实际项目中,发送交易涉及连接检查、参数构建、Gas 估算、签名、广播、等待回执、错误处理等多个环节。前端应将其封装为可复用的 React Hook:
import { useState, useCallback } from "react";
import {
createPublicClient, createWalletClient, http, custom, parseEther,
type WalletClient, type PublicClient, type Chain, type Hash
} from "viem";
import { mainnet } from "viem/chains";
interface SendTransactionState {
isPending: boolean;
isSuccess: boolean;
isError: boolean;
hash: Hash | null;
error: Error | null;
confirmations: number;
}
interface SendTxParams {
to: `0x${string}`;
value?: bigint;
data?: `0x${string}`;
gasLimit?: bigint;
maxFeePerGas?: bigint;
maxPriorityFeePerGas?: bigint;
}
export function useSendTransaction(
publicClient: PublicClient,
walletClient: WalletClient,
targetConfirmations: number = 12
) {
const [state, setState] = useState<SendTransactionState>({
isPending: false,
isSuccess: false,
isError: false,
hash: null,
error: null,
confirmations: 0,
});
const sendTransaction = useCallback(
async (params: SendTxParams) => {
setState({
isPending: true,
isSuccess: false,
isError: false,
hash: null,
error: null,
confirmations: 0,
});
try {
// 1. 获取账户
const [account] = await walletClient.getAddresses();
if (!account) throw new Error("No connected account");
// 2. 估算 Gas
const estimatedGas = params.gasLimit ?? await publicClient.estimateGas({
account,
to: params.to,
value: params.value,
data: params.data,
});
// 3. 估算费用
const { maxFeePerGas, maxPriorityFeePerGas } =
params.maxFeePerGas && params.maxPriorityFeePerGas
? { maxFeePerGas: params.maxFeePerGas, maxPriorityFeePerGas: params.maxPriorityFeePerGas }
: await publicClient.estimateFeesPerGas();
// 4. 发送交易
const hash = await walletClient.sendTransaction({
account,
to: params.to,
value: params.value,
data: params.data,
gas: estimatedGas,
maxFeePerGas,
maxPriorityFeePerGas,
});
setState((s) => ({ ...s, hash }));
// 5. 等待回执
const receipt = await publicClient.waitForTransactionReceipt({
hash,
confirmations: targetConfirmations,
timeout: 120_000, // 120 秒
});
const success = receipt.status === "success";
setState({
isPending: false,
isSuccess: success,
isError: !success,
hash,
error: success
? null
: new Error(`Transaction reverted in block ${receipt.blockNumber}`),
confirmations: targetConfirmations,
});
return { receipt, hash };
} catch (err: any) {
const error = err instanceof Error ? err : new Error(String(err));
// 分类错误
let classified = error;
if (error.message?.includes("insufficient funds")) {
classified = new Error("余额不足,无法支付交易费用");
} else if (error.message?.includes("User rejected")) {
classified = new Error("用户拒绝了签名请求");
} else if (error.message?.includes("reverted")) {
classified = new Error("合约执行回滚,交易失败");
}
setState({
isPending: false,
isSuccess: false,
isError: true,
hash: state.hash,
error: classified,
confirmations: 0,
});
throw classified;
}
},
[publicClient, walletClient, targetConfirmations, state.hash]
);
return { sendTransaction, ...state };
}Hook 的使用方式如下:
import { useSendTransaction } from "./useSendTransaction";
const publicClient = createPublicClient({ chain: mainnet, transport: http() });
const walletClient = createWalletClient({ chain: mainnet, transport: custom(window.ethereum!) });
function TransferButton() {
const { sendTransaction, isPending, isSuccess, isError, hash, error } =
useSendTransaction(publicClient, walletClient);
const handleSend = async () => {
try {
await sendTransaction({
to: "0xRecipient...",
value: parseEther("0.01"),
});
} catch (e) {
// 错误已在 Hook 内分类与状态同步
}
};
return (
<button onClick={handleSend} disabled={isPending}>
{isPending ? "发送中..." : isSuccess ? "已确认" : "发送 ETH"}
</button>
);
}14.3.5 等待回执与解析失败原因
交易回滚后,前端有时需要向用户展示具体的合约错误信息。当回执缺少明确的 revertReason 时,可以通过模拟执行复现错误:
import { createPublicClient, http } from "viem";
import { mainnet } from "viem/chains";
const publicClient = createPublicClient({
chain: mainnet,
transport: http(),
});
async function simulateRevert(
address: `0x${string}`,
abi: any[],
functionName: string,
args: any[],
sender: `0x${string}`
) {
try {
// simulateContract 会抛出一个带 revertReason 的异常
await publicClient.simulateContract({
address,
abi,
functionName,
args,
account: sender,
});
return null; // 不会运行到此处
} catch (error: any) {
// Viem 结构化错误码:ContractFunctionRevertedError
if (error.cause?.name === "ContractFunctionRevertedError") {
return error.cause.data?.errorName ?? error.cause.reason ?? "Unknown revert";
}
return error.message;
}
}前端错误可按三层分类:
- 用户侧错误:余额不足、Gas 设置过低、用户主动拒绝签名。
- 合约逻辑错误:自定义
error类型(Solidity 0.8.4+)或require消息字符串,需解析回滚原因。 - 网络/基础设施错误:RPC 超时、节点不同步、链重组导致回执短暂丢失。
14.3 小节要点
- EIP-1559 的费用模型要求前端正确设置
maxFeePerGas与maxPriorityFeePerGas,MaxCost与ActualCost公式是理解用户成本的基础。- 交易生命周期涵盖:constructed → signed → broadcast → pending → included → success/revert → confirmed。前端需为每个阶段提供明确的 UX 反馈。
- 封装
useSendTransaction等 Hook 可将 Gas 估算、发送、等待回执、错误分类等逻辑内聚复用,保持 UI 层的简洁。- 对回滚交易,应结合回执状态与
simulateContract复现,尽可能向用户展示可操作的错误信息。
14.7 本章小结(14.1–14.3)
本章覆盖了 DApp 前端与链交互的三大基石:
- 前端不是简单的 UI,而是用户与智能协议之间的"翻译层"。ethers.js v6 与 Viem 分别代表了成熟生态与类型安全现代栈两种思路,理解 Provider/Signer(或 PublicClient/WalletClient)的分离是驾驭任何前端库的前提。
- 交易状态管理是 DApp 用户体验的核心难点。从 Gas 估算、用户授权、pending 等待、确认数增长到可能的回滚回滚——每个环节都需要精确的状态同步与错误分类。一个设计良好的
useSendTransactionHook 能显著降低重复代码与 UX 不一致风险。 - 嵌入式钱包正在大幅降低 Web3 入门门槛。从早期的
window.ethereum被动注入,到 EIP-1102 主动请求、EIP-6963 多钱包发现,再到 WalletConnect 的跨端体验,以及 Privy/Dynamic 等嵌入式方案的 Web2 级登录——钱包连接层的进化正不断消弭普通用户进入链上的摩擦。
后续章节将在此基础上延伸,深入探讨事件监听优化、多链配置切换、前端安全审计等进阶主题。
评论
0评论加载中…