用Viem替代ethers.js:从一次签名失败到完整迁移的实战记录
摘要
我在开发一个跨链DeFi聚合器时,被ethers.js的签名兼容性和类型错误折磨了两天,最终决定迁移到Viem。这篇文章记录了我从踩坑、分析到用Viem重写合约交互的全过程,包括签名验证、事件监听和Gas估算等核心场景的代码实现。
背景
上个月接了一个跨链DeFi聚合器的前端开发,核心功能是让用户在一条链上签名交易,然后在另一条链上执行。项目用了ethers.js v5,配合MetaMask做钱包连接。本来一切顺利,直到我需要在Polygon上签名一个EIP-712结构化数据,然后在Optimism上验证并执行。结果签名总是对不上,不是报invalid signature就是recovered address mismatch。我排查了两天,发现ethers.js在处理某些链的签名格式时,会有奇怪的行为——尤其是当签名中的v值不是27或28时,它会自动调整,导致跨链验证失败。当时我就想,这库用了几年了,怎么还有这种坑?
问题分析
我的最初思路是:既然ethers.js对签名做了"友好"处理,那我手动把v值标准化成27/28不就行了?于是我写了段代码:
const signature = await signer._signTypedData(domain, types, value);
// 手动解析并调整v值
const { v, r, s } = ethers.utils.splitSignature(signature);
const adjustedV = v === 0 ? 27 : v === 1 ? 28 : v;
结果更糟了。因为splitSignature内部已经把v调整了一次,我再调整一次,签名直接废了。而且ethers.js v5的TypeScript类型定义不够严谨,signer._signTypedData返回的是Promise<string>,但你传进去的domain类型是TypedDataDomain,它和EIP-712规范里的字段名有细微差异(比如chainId在ethers里是number,但规范里是uint256),导致某些链(比如Arbitrum)直接报invalid argument。
我后来查了GitHub issues,发现ethers.js团队在v6里改进了签名处理,但v6的API变化太大,迁移成本高。这时我想到了Viem——一个更轻量、类型更严格的Web3库。当时我犹豫了一下:换库意味着重写所有合约交互代码,但既然已经被ethers.js坑了一次,不如彻底解决。
核心实现
第一步:搭建Viem环境,替换钱包连接
我用的React框架,之前用@web3-react/core配合ethers.js。Viem官方提供了wagmi(一个React Hooks库),但我不想引入太多依赖,所以直接用Viem的createWalletClient和createPublicClient自己封装。
这里有个坑:Viem的createWalletClient默认不包含window.ethereum,需要手动传入transport。我一开始忘了传,结果walletClient.getAddresses()一直返回空数组。
import { createWalletClient, createPublicClient, custom, http } from 'viem';
import { mainnet, polygon, optimism } from 'viem/chains';
// 初始化公共客户端(用于读链上数据)
const publicClient = createPublicClient({
chain: mainnet,
transport: http()
});
// 初始化钱包客户端(需要用户授权)
const [account] = await window.ethereum.request({ method: 'eth_requestAccounts' });
const walletClient = createWalletClient({
chain: mainnet,
transport: custom(window.ethereum)
});
注意:createWalletClient的transport参数必须用custom(window.ethereum),不能用http()。我当时用http()测试,结果报TransportError: The transport does not support signing。
第二步:用Viem实现EIP-712签名
这是我最头疼的部分。Viem的signTypedData方法要求传入的参数类型非常严格——domain里的chainId必须是number,不能是bigint或string。而且它不会像ethers.js那样自动转换v值,签名结果就是原始格式。
import { signTypedData, recoverTypedDataAddress } from 'viem';
// 定义EIP-712类型
const domain = {
name: 'CrossChainSwap',
version: '1',
chainId: 137, // Polygon的chainId,必须是number
verifyingContract: '0x...' as `0x${string}`
};
const types = {
Swap: [
{ name: 'fromToken', type: 'address' },
{ name: 'toToken', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'nonce', type: 'uint256' }
]
};
const value = {
fromToken: '0x...' as `0x${string}`,
toToken: '0x...' as `0x${string}`,
amount: BigInt('1000000000000000000'),
nonce: BigInt(Date.now())
};
// 签名
const signature = await walletClient.signTypedData({
account,
domain,
types,
primaryType: 'Swap',
message: value
});
// 验证签名(在另一条链上)
const recoveredAddress = await recoverTypedDataAddress({
domain,
types,
primaryType: 'Swap',
message: value,
signature
});
console.log('Recovered:', recoveredAddress); // 应该等于account
这里有个关键细节:Viem的signTypedData返回的签名是0x开头的十六进制字符串,长度是132个字符(包含0x),这是标准的RSV格式。ethers.js返回的也是同样的格式,但它内部对v做了处理。Viem不会——所以跨链验证时,只要你在两条链上用相同的参数签名,结果就是一致的。我当时测试了Polygon和Optimism,签名完全匹配。
第三步:合约调用和Gas估算
替换合约调用时,我遇到了第二个坑:Viem的writeContract和estimateGas是分离的,不像ethers.js那样直接在交易对象里传gasLimit。我需要先估算Gas,然后手动设置。
import { getContract } from 'viem';
// 创建合约实例
const contract = getContract({
address: '0x...' as `0x${string}`,
abi: swapAbi,
client: { public: publicClient, wallet: walletClient }
});
// 估算Gas
const gasEstimate = await publicClient.estimateContractGas({
address: contract.address,
abi: contract.abi,
functionName: 'swap',
args: [value.fromToken, value.toToken, value.amount, signature],
account
});
// 发送交易
const hash = await walletClient.writeContract({
address: contract.address,
abi: contract.abi,
functionName: 'swap',
args: [value.fromToken, value.toToken, value.amount, signature],
account,
gas: gasEstimate // 注意:Viem里gas参数是`gas`不是`gasLimit`
});
注意这个参数名:Viem用gas,ethers.js用gasLimit。我当时习惯性写了gasLimit,结果交易一直报错missing gas limit。排查了半小时才发现这个命名差异。
第四步:事件监听
事件监听是另一个让我头疼的地方。ethers.js的contract.on是回调式的,Viem用的是watchContractEvent,返回一个取消监听的函数。
// 监听Swap事件
const unwatch = publicClient.watchContractEvent({
address: contract.address,
abi: contract.abi,
eventName: 'SwapExecuted',
args: { user: account }, // 过滤条件
onLogs: (logs) => {
const [log] = logs;
console.log('Swap executed:', log.args);
// 更新UI
setTxStatus('confirmed');
}
});
// 组件卸载时取消监听
useEffect(() => {
return () => unwatch();
}, []);
这里有个坑:Viem的watchContractEvent在监听时,如果链的区块时间很短(比如Polygon每2秒一个块),回调会被频繁触发。我一开始没做防抖,结果UI刷新了几百次。后来加了throttle才解决。
第五步:链切换
跨链聚合器需要频繁切换链。Viem的switchChain比ethers.js更直观,但要注意:walletClient.switchChain只切换钱包的链,不影响publicClient。我需要同时更新两个客户端。
import { polygon, optimism } from 'viem/chains';
async function switchChain(targetChain: typeof polygon | typeof optimism) {
try {
// 切换钱包链
await walletClient.switchChain({ id: targetChain.id });
// 更新公共客户端
publicClient = createPublicClient({
chain: targetChain,
transport: http()
});
} catch (error) {
// 如果用户没有目标链,请求添加
if (error.code === 4902) {
await walletClient.addChain({ chain: targetChain });
await walletClient.switchChain({ id: targetChain.id });
publicClient = createPublicClient({
chain: targetChain,
transport: http()
});
}
}
}
注意:createPublicClient每次调用都会创建一个新的客户端实例。如果项目中有多个组件依赖同一个publicClient,需要把它放到Context里管理。我当时没注意,导致不同组件用了不同的客户端实例,有的读的是旧链的数据。
完整代码
下面是一个完整的React组件,实现了跨链签名-验证-执行的全流程:
import React, { useState, useEffect } from 'react';
import {
createWalletClient,
createPublicClient,
custom,
http,
signTypedData,
recoverTypedDataAddress,
getContract
} from 'viem';
import { polygon, optimism } from 'viem/chains';
const SWAP_ABI = [
{
inputs: [
{ name: 'fromToken', type: 'address' },
{ name: 'toToken', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'signature', type: 'bytes' }
],
name: 'swap',
outputs: [],
stateMutability: 'nonpayable',
type: 'function'
},
{
anonymous: false,
inputs: [
{ indexed: true, name: 'user', type: 'address' },
{ indexed: false, name: 'amount', type: 'uint256' }
],
name: 'SwapExecuted',
type: 'event'
}
];
const CrossChainSwap: React.FC = () => {
const [account, setAccount] = useState<`0x${string}`>();
const [status, setStatus] = useState<'idle' | 'signing' | 'executing' | 'done'>('idle');
const [error, setError] = useState<string>('');
// 初始化客户端
const [publicClient, setPublicClient] = useState(() =>
createPublicClient({ chain: polygon, transport: http() })
);
const [walletClient, setWalletClient] = useState<ReturnType<typeof createWalletClient>>();
useEffect(() => {
const init = async () => {
const [address] = await window.ethereum.request({ method: 'eth_requestAccounts' });
setAccount(address as `0x${string}`);
setWalletClient(
createWalletClient({
chain: polygon,
transport: custom(window.ethereum)
})
);
};
init();
}, []);
const handleSwap = async () => {
if (!account || !walletClient) return;
try {
setStatus('signing');
// 1. 在Polygon上签名
const domain = {
name: 'CrossChainSwap',
version: '1',
chainId: 137,
verifyingContract: '0x...' as `0x${string}`
};
const types = {
Swap: [
{ name: 'fromToken', type: 'address' },
{ name: 'toToken', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'nonce', type: 'uint256' }
]
};
const value = {
fromToken: '0x...' as `0x${string}`,
toToken: '0x...' as `0x${string}`,
amount: BigInt('1000000000000000000'),
nonce: BigInt(Date.now())
};
const signature = await walletClient.signTypedData({
account,
domain,
types,
primaryType: 'Swap',
message: value
});
// 2. 验证签名(可选,用于调试)
const recovered = await recoverTypedDataAddress({
domain,
types,
primaryType: 'Swap',
message: value,
signature
});
if (recovered !== account) {
throw new Error('Signature recovery failed');
}
// 3. 切换到Optimism执行
setStatus('executing');
await walletClient.switchChain({ id: optimism.id });
setPublicClient(createPublicClient({ chain: optimism, transport: http() }));
// 4. 估算Gas
const contract = getContract({
address: '0x...' as `0x${string}`,
abi: SWAP_ABI,
client: { public: publicClient, wallet: walletClient }
});
const gasEstimate = await publicClient.estimateContractGas({
address: contract.address,
abi: contract.abi,
functionName: 'swap',
args: [value.fromToken, value.toToken, value.amount, signature],
account
});
// 5. 发送交易
const hash = await walletClient.writeContract({
address: contract.address,
abi: contract.abi,
functionName: 'swap',
args: [value.fromToken, value.toToken, value.amount, signature],
account,
gas: gasEstimate
});
// 6. 等待确认(简化版)
await publicClient.waitForTransactionReceipt({ hash });
setStatus('done');
} catch (err) {
setError(err.message);
setStatus('idle');
}
};
return (
<div>
<p>Account: {account}</p>
<button onClick={handleSwap} disabled={!account || status !== 'idle'}>
{status === 'signing' ? 'Signing...' : status === 'executing' ? 'Executing...' : 'Start Swap'}
</button>
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
{status === 'done' && <p>Swap completed!</p>}
</div>
);
};
export default CrossChainSwap;
踩坑记录
-
v值冲突:ethers.js的splitSignature会调整v值到27/28,但Viem不会。如果你在同一个项目里混用两个库,签名验证会失败。我的解决方案是:完全切换到Viem,统一签名处理逻辑。
-
Gas参数命名:Viem用gas而不是gasLimit,这个命名差异让我排查了半小时。Viem的文档里写的是gas,但很多教程示例用的还是gasLimit,容易混淆。
-
createPublicClient实例管理:每次切换链都重新创建publicClient,导致多个组件引用了不同的实例。我把客户端放到了React Context里,用useMemo缓存实例,只在链切换时更新。
-
watchContractEvent回调频率:在Polygon上监听事件时,回调被频繁触发。我加了一个throttle函数,每500ms只处理一次日志。
小结
迁移到Viem后,签名问题彻底解决了,而且类型安全让我少了很多运行时错误。核心收获是:对于跨链场景,Viem的原始签名处理更可靠。如果你也在被ethers.js的签名兼容性折磨,可以试试Viem。下一步我打算研究Viem的account abstraction支持,看看能不能进一步简化钱包集成。