从 ethers.js 迁移到 Viem:一个签名验证 Bug 让我彻底放弃旧爱
摘要
做 NFT 跨链桥时,ethers.js 的 EIP-712 签名在合约端反复验证失败,排查两天无果。换成 Viem 重写签名逻辑后,一次通过。本文记录我用 Viem 替代 ethers.js 的真实过程,包括迁移踩坑和完整代码。
背景
上个月,我在做一个基于 LayerZero 的 NFT 跨链桥项目。用户需要在源链签名授权跨链操作,合约端用 EIP-712 验证签名后,再在目标链铸造 NFT。前端用的是 ethers.js v5,配合 RainbowKit 做钱包连接。
项目做到签名验证环节时,我遇到了一个诡异的问题:前端的签名数据,在合约里用 ECDSA.recover 恢复签名者地址时,总是对不上。测试网上的 ETH 花了十几刀,交易回滚了二十多次,签名验证就是不通过。
一开始我以为是合约代码写错了,反复检查了 _hashTypedDataV4 的拼写和参数顺序。后来把合约简化成一个纯验证签名的合约,还是不行。我甚至怀疑是 MetaMask 的 bug,换了 WalletConnect 也是一样。
问题分析
最初我用的 ethers.js 的 signTypedData 方法,代码大概长这样:
import { ethers } from 'ethers';
const domain = {
name: 'NFTBridge',
version: '1',
chainId: 5, // Goerli
verifyingContract: '0x...'
};
const types = {
BridgeRequest: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'tokenId', type: 'uint256' },
{ name: 'nonce', type: 'uint256' }
]
};
const value = {
from: userAddress,
to: targetAddress,
tokenId: 123,
nonce: 456
};
const signature = await signer.signTypedData(domain, types, value);
然后在合约端用 OpenZeppelin 的 ECDSA 库验证:
bytes32 structHash = keccak256(abi.encode(
BRIDGE_REQUEST_TYPEHASH,
from, to, tokenId, nonce
));
bytes32 digest = _hashTypedDataV4(structHash);
address recovered = ECDSA.recover(digest, signature);
require(recovered == from, "Invalid signature");
结果 recovered 地址总是 0x0 或者完全随机的地址。我打印了 digest,和前端的 _hashTypedDataV4 计算结果对比,发现 hash 值竟然不一样!
这里有个坑:ethers.js 的 signTypedData 内部对 domain 的 chainId 做了特殊处理。如果网络是 Goerli(chainId=5),但钱包连接的链是其他网络,ethers.js 会自动用钱包当前网络的 chainId 覆盖你传入的 chainId。我当时传了 5,但钱包切到了 Sepolia(chainId=11155111),导致签名用的 chainId 和合约验证用的 chainId 不一致,hash 自然对不上。
这个问题在 ethers.js 的 issue 里有人提过,但官方文档没有明确说明这个行为。我排查了两天,试了各种参数组合,最后才发现是这个 "隐形覆盖" 搞的鬼。
核心实现:用 Viem 重写签名逻辑
第一步:安装和初始化 Viem
既然 ethers.js 在签名细节上给我埋了坑,我决定换成 Viem。Viem 是 wagmi 团队开发的底层库,对 EIP-712 的支持更透明,没有那些 "智能" 的隐形处理。
安装依赖:
npm install viem wagmi @wagmi/core
注意这里有个坑:Viem 和 ethers.js 不能同时用,因为它们的 BigInt 和 BigNumber 处理方式不同,容易混淆。我直接把 ethers.js 从项目里移除了。
初始化 Viem 客户端:
import { createWalletClient, custom, createPublicClient, http } from 'viem';
import { goerli } from 'viem/chains';
// 创建公共客户端(用于读链上数据)
export const publicClient = createPublicClient({
chain: goerli,
transport: http()
});
// 创建钱包客户端(用于签名和交易)
export function getWalletClient() {
// 假设 window.ethereum 已存在
return createWalletClient({
chain: goerli,
transport: custom(window.ethereum)
});
}
这里要注意:Viem 的 createWalletClient 不会自动连接钱包,你需要先调用 walletClient.requestAddresses() 来触发 MetaMask 的授权弹窗。
第二步:实现 EIP-712 签名
Viem 的 signTypedData 方法更纯粹,它只做签名,不做任何隐形的字段覆盖:
import { signTypedData } from 'viem/actions';
import { getWalletClient } from './client';
export async function signBridgeRequest({
from,
to,
tokenId,
nonce
}: {
from: `0x${string}`;
to: `0x${string}`;
tokenId: bigint;
nonce: bigint;
}) {
const walletClient = getWalletClient();
const [account] = await walletClient.requestAddresses();
// domain 里的 chainId 必须手动设置,Viem 不会自动覆盖
const domain = {
name: 'NFTBridge',
version: '1',
chainId: BigInt(goerli.id), // 明确指定,且用 BigInt
verifyingContract: '0x...' as `0x${string}`
};
const types = {
BridgeRequest: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'tokenId', type: 'uint256' },
{ name: 'nonce', type: 'uint256' }
]
};
const message = {
from,
to,
tokenId,
nonce
};
// 这里有个坑:Viem 的 signTypedData 返回的是 0x 开头的 hex 字符串
const signature = await signTypedData(walletClient, {
account,
domain,
types,
primaryType: 'BridgeRequest',
message
});
return signature; // 例如 "0x..."
}
注意这个细节:Viem 的 signTypedData 要求传入 primaryType 参数,而 ethers.js 是自动推断的。如果你忘了传这个参数,会得到一个 "Primary type not specified" 的错误。
第三步:在合约端验证签名
合约端的验证逻辑不需要变,但需要确保前端的 domain 和合约的 EIP712Domain 完全一致。我用 Viem 后,手动打印了 domain 的 hash,和合约的对比,终于一致了:
// 合约代码不变
function verifyBridgeRequest(
address from,
address to,
uint256 tokenId,
uint256 nonce,
bytes calldata signature
) external view returns (bool) {
bytes32 structHash = keccak256(abi.encode(
BRIDGE_REQUEST_TYPEHASH,
from, to, tokenId, nonce
));
bytes32 digest = _hashTypedDataV4(structHash);
address recovered = ECDSA.recover(digest, signature);
return recovered == from;
}
第四步:在前端验证签名(可选)
为了调试方便,我还在前端用 Viem 的 verifyTypedData 方法验证签名,确认签名正确后再提交到合约:
import { verifyTypedData } from 'viem/actions';
import { publicClient } from './client';
export async function verifySignature(
signature: `0x${string}`,
message: { from: `0x${string}`; to: `0x${string}`; tokenId: bigint; nonce: bigint }
) {
const domain = {
name: 'NFTBridge',
version: '1',
chainId: BigInt(goerli.id),
verifyingContract: '0x...' as `0x${string}`
};
const types = {
BridgeRequest: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'tokenId', type: 'uint256' },
{ name: 'nonce', type: 'uint256' }
]
};
// verifyTypedData 返回 true/false
const isValid = await verifyTypedData(publicClient, {
address: message.from,
domain,
types,
primaryType: 'BridgeRequest',
message,
signature
});
return isValid;
}
这里有个坑:verifyTypedData 的 address 参数是你期望的签名者地址,不是合约地址。我开始传错了,传成了合约地址,结果一直返回 false。
完整代码:React 组件中的签名流程
下面是一个完整的 React 组件,集成了钱包连接、签名生成和提交:
import { useAccount, useWalletClient } from 'wagmi';
import { signTypedData, verifyTypedData } from 'viem/actions';
import { goerli } from 'viem/chains';
import { useState } from 'react';
const DOMAIN = {
name: 'NFTBridge',
version: '1',
chainId: BigInt(goerli.id),
verifyingContract: '0x...' as `0x${string}`
};
const TYPES = {
BridgeRequest: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'tokenId', type: 'uint256' },
{ name: 'nonce', type: 'uint256' }
]
} as const;
export function BridgeForm() {
const { address } = useAccount();
const { data: walletClient } = useWalletClient();
const [signature, setSignature] = useState<`0x${string}` | null>(null);
const [error, setError] = useState<string | null>(null);
const handleSign = async () => {
if (!walletClient || !address) return;
setError(null);
try {
const nonce = BigInt(Math.floor(Math.random() * 1000000));
const message = {
from: address,
to: '0xRecipientAddressHere' as `0x${string}`,
tokenId: BigInt(123),
nonce
};
// 生成签名
const sig = await signTypedData(walletClient, {
account: address,
domain: DOMAIN,
types: TYPES,
primaryType: 'BridgeRequest',
message
});
// 可选:前端验证
const isValid = await verifyTypedData(walletClient, {
address,
domain: DOMAIN,
types: TYPES,
primaryType: 'BridgeRequest',
message,
signature: sig
});
if (!isValid) {
throw new Error('签名验证失败');
}
setSignature(sig);
console.log('签名成功:', sig);
} catch (err: any) {
setError(err.message || '签名失败');
}
};
return (
<div>
<button onClick={handleSign} disabled={!walletClient}>
{signature ? '已签名' : '签名授权'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
{signature && <p>签名: {signature.slice(0, 20)}...</p>}
</div>
);
}
踩坑记录
-
verifyTypedData的address参数:我开始传了合约地址,结果验证永远返回 false。后来看了 Viem 的源码才发现,这个参数是签名者地址,不是合约地址。文档里写的是 "The address of the signer",但我惯性思维以为是合约地址。 -
BigInt vs number:ethers.js 用
BigNumber,Viem 用原生BigInt。我在定义 domain 的 chainId 时,一开始写成了goerli.id返回的是 number(5),但 Viem 要求 BigInt。报错信息是 "Type 'number' is not assignable to type 'bigint'",排查了好久才发现这个类型不匹配。 -
primaryType 必须显式指定:ethers.js 会自动从 types 中推断 primaryType,但 Viem 要求你手动传。如果你只传了 types 没传 primaryType,会报 "Missing primary type" 错误。我开始没注意,以为和 ethers.js 一样,结果签名一直失败。
-
钱包连接后需要手动 requestAddresses:Viem 的
createWalletClient不会自动请求钱包授权,你需要调用walletClient.requestAddresses()才会弹出 MetaMask 窗口。如果忘了这一步,后面的签名操作会报 "Account not found" 错误。
小结
从 ethers.js 迁移到 Viem 后,EIP-712 签名验证问题彻底解决。核心收获是:Viem 更透明、更类型安全,没有 ethers.js 那些 "隐形覆盖" 的坑。如果你也在做 DApp 签名相关开发,建议直接上 Viem,值得深入看看它的 actions 模块,里面有很多实用的链操作工具函数。