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

23 阅读5分钟

从 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 内部对 domainchainId 做了特殊处理。如果网络是 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 不能同时用,因为它们的 BigIntBigNumber 处理方式不同,容易混淆。我直接把 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;
}

这里有个坑:verifyTypedDataaddress 参数是你期望的签名者地址,不是合约地址。我开始传错了,传成了合约地址,结果一直返回 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>
  );
}

踩坑记录

  1. verifyTypedDataaddress 参数:我开始传了合约地址,结果验证永远返回 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 模块,里面有很多实用的链操作工具函数。