从 ethers.js 迁移到 Viem:一个签名验证 Bug 让我彻底放弃旧爱

从 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 方法,代码大概长这样:

typescript 复制代码
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 库验证:

solidity 复制代码
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 的支持更透明,没有那些 "智能" 的隐形处理。

安装依赖:

bash 复制代码
npm install viem wagmi @wagmi/core

注意这里有个坑:Viem 和 ethers.js 不能同时用,因为它们的 BigInt 和 BigNumber 处理方式不同,容易混淆。我直接把 ethers.js 从项目里移除了。

初始化 Viem 客户端:

typescript 复制代码
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 方法更纯粹,它只做签名,不做任何隐形的字段覆盖:

typescript 复制代码
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,和合约的对比,终于一致了:

solidity 复制代码
// 合约代码不变
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 方法验证签名,确认签名正确后再提交到合约:

typescript 复制代码
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 组件,集成了钱包连接、签名生成和提交:

tsx 复制代码
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>
  );
}

踩坑记录

  1. verifyTypedData 的 address 参数:我开始传了合约地址,结果验证永远返回 false。后来看了 Viem 的源码才发现,这个参数是签名者地址,不是合约地址。文档里写的是 "The address of the signer",但我惯性思维以为是合约地址。

  2. BigInt vs number :ethers.js 用 BigNumber,Viem 用原生 BigInt。我在定义 domain 的 chainId 时,一开始写成了 goerli.id 返回的是 number(5),但 Viem 要求 BigInt。报错信息是 "Type 'number' is not assignable to type 'bigint'",排查了好久才发现这个类型不匹配。

  3. primaryType 必须显式指定:ethers.js 会自动从 types 中推断 primaryType,但 Viem 要求你手动传。如果你只传了 types 没传 primaryType,会报 "Missing primary type" 错误。我开始没注意,以为和 ethers.js 一样,结果签名一直失败。

  4. 钱包连接后需要手动 requestAddresses :Viem 的 createWalletClient 不会自动请求钱包授权,你需要调用 walletClient.requestAddresses() 才会弹出 MetaMask 窗口。如果忘了这一步,后面的签名操作会报 "Account not found" 错误。

小结

从 ethers.js 迁移到 Viem 后,EIP-712 签名验证问题彻底解决。核心收获是:Viem 更透明、更类型安全,没有 ethers.js 那些 "隐形覆盖" 的坑。如果你也在做 DApp 签名相关开发,建议直接上 Viem,值得深入看看它的 actions 模块,里面有很多实用的链操作工具函数。

相关推荐
FungLeo26 分钟前
成为全栈·Vue 管理后台篇·用 fetch 建一层可控的请求内核
前端·javascript·vue.js·http·fetch·formdata·接口封装
用户842981424181043 分钟前
ByteJS,让重要的 JavaScript 以字节码运行。
javascript
FungLeo2 小时前
成为全栈·Vue 管理后台篇·Vue Router如何守住多角色后台边界
前端·javascript·vue.js·rbac·vue router·路由守卫·前端权限
FungLeo3 小时前
成为全栈·Vue管理后台篇·仪表盘不是几张卡片:组合统计接口与角色视图
前端·javascript·vue.js·rbac·仪表盘·管理后台·tanstack query
程序员大阳3 小时前
HTML+CSS+JS对话式实战(7)-学生端活动报名功能开发
javascript·css·html·活动报名
西安小哥4 小时前
做 Agent 写到第三个小时,我开始给它算账
javascript
西安小哥4 小时前
让大模型操作浏览器跑了两周,我发现瓶颈根本不在模型
javascript
用户298698530144 小时前
用 Python 自动化把 JavaScript 代码转换为 Word 文档
javascript·python·api
学以智用4 小时前
正则表达式完整参考手册(JavaScript 版,兼容绝大多数语言)
javascript
北城笑笑5 小时前
Vue 109 ,从零搭建 Vue3 + Electron 工程:生态、安全桥接与打包选型( Vue + Electron )
前端·javascript·vue.js·安全·electron·vue