用Viem替代ethers.js:从一次签名失败到完整迁移的实战记录

用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不就行了?于是我写了段代码:

typescript 复制代码
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的createWalletClientcreatePublicClient自己封装。

这里有个坑:Viem的createWalletClient默认不包含window.ethereum,需要手动传入transport。我一开始忘了传,结果walletClient.getAddresses()一直返回空数组。

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

注意:createWalletClienttransport参数必须用custom(window.ethereum),不能用http()。我当时用http()测试,结果报TransportError: The transport does not support signing

第二步:用Viem实现EIP-712签名

这是我最头疼的部分。Viem的signTypedData方法要求传入的参数类型非常严格------domain里的chainId必须是number,不能是bigintstring。而且它不会像ethers.js那样自动转换v值,签名结果就是原始格式。

typescript 复制代码
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的writeContractestimateGas是分离的,不像ethers.js那样直接在交易对象里传gasLimit。我需要先估算Gas,然后手动设置。

typescript 复制代码
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,返回一个取消监听的函数。

typescript 复制代码
// 监听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。我需要同时更新两个客户端。

typescript 复制代码
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组件,实现了跨链签名-验证-执行的全流程:

typescript 复制代码
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;

踩坑记录

  1. v值冲突 :ethers.js的splitSignature会调整v值到27/28,但Viem不会。如果你在同一个项目里混用两个库,签名验证会失败。我的解决方案是:完全切换到Viem,统一签名处理逻辑。

  2. Gas参数命名 :Viem用gas而不是gasLimit,这个命名差异让我排查了半小时。Viem的文档里写的是gas,但很多教程示例用的还是gasLimit,容易混淆。

  3. createPublicClient实例管理 :每次切换链都重新创建publicClient,导致多个组件引用了不同的实例。我把客户端放到了React Context里,用useMemo缓存实例,只在链切换时更新。

  4. watchContractEvent回调频率 :在Polygon上监听事件时,回调被频繁触发。我加了一个throttle函数,每500ms只处理一次日志。

小结

迁移到Viem后,签名问题彻底解决了,而且类型安全让我少了很多运行时错误。核心收获是:对于跨链场景,Viem的原始签名处理更可靠。如果你也在被ethers.js的签名兼容性折磨,可以试试Viem。下一步我打算研究Viem的account abstraction支持,看看能不能进一步简化钱包集成。

相关推荐
之歆1 小时前
DAY08_CSS浮动与行内块布局实战指南(上)
前端·css
light blue bird2 小时前
主子端台二分法任务汇总组件
前端·数据库·.net·桌面端winform
不可能的是3 小时前
Claude Code 子 Agent 机制全解:怎么跑起来、怎么被管理、怎么互不干扰
javascript
jeffwang3 小时前
我做了个让 AI 看屏幕跑测试的工具,因为 Playwright 测不了我的 Flutter Web
前端
HSunR3 小时前
dify 搭建ai作业批改流
开发语言·前端·javascript
代码不加糖3 小时前
2026 跨境电商独立站实战:从 0 到 1 搭建高转化 SaaS 商城(附源码)
开发语言·前端·javascript
亲亲小宝宝鸭4 小时前
拖一拖控件,拖出个问卷(低代码平台)
前端·低代码
江南十四行4 小时前
ReAct Agent 基本理论与项目实战(一)
前端·react.js·前端框架
We་ct4 小时前
LeetCode 72. 编辑距离:动态规划经典题解
前端·算法·leetcode·typescript·动态规划