阅读视图

发现新文章,点击刷新页面。

用 wagmi v2 和 viem 手写 NFT 市场批量上架功能,我踩遍了所有异步坑

背景

上个月我接了个外包项目,做一个基于 ERC-721 的 NFT 交易市场前端。需求很简单:用户可以批量选择自己的 NFT,然后一次签名、批量上架到市场合约。项目用 Next.js 14 + wagmi v2 + viem,钱包连接用 RainbowKit。

我一开始觉得这有啥难的,不就是遍历数组调合约嘛。结果真正动手才发现,批量上架这件事在 Web3 前端里是个典型的“异步地狱”:你不仅要处理交易发送,还要等每个交易确认,同时还要处理用户拒绝签名、交易失败、Gas 不足等各种边界情况。

这篇文章就是我在解决“批量上架 NFT”这个具体问题时的完整踩坑记录。

问题分析

最初的思路:for 循环逐个发交易

我最初的想法很简单:用户选择 n 个 NFT,然后 for 循环里逐个调用合约的 listItem 方法,每次调用都等交易确认后再调用下一个。

// 最初的错误写法
async function batchList(tokenIds: number[]) {
  for (const id of tokenIds) {
    const { hash } = await writeContract({
      address: marketAddress,
      abi: marketABI,
      functionName: 'listItem',
      args: [nftAddress, id, price],
    })
    await waitForTransactionReceipt({ hash })
  }
}

为什么行不通

  1. 用户要签 n 次 MetaMask:每调用一次 writeContract,钱包弹一次签名,用户体验极其糟糕。
  2. 单点失败问题:如果第 3 个 NFT 上架失败,前面的已经上架了,后面的没法继续,状态不一致。
  3. Gas 浪费:每个交易都需要单独支付 Gas,对于批量上架来说成本太高。

后来我查了下 wagmi v2 的文档,发现 writeContract 其实支持批量操作,但需要合约层面支持。我合作的合约用的是 OpenSea 的 Seaport 协议风格,需要一个 bulkListItems 函数。但问题是合约没有这个函数,只能一个个调用。

真正的痛点

我的合约不支持批量函数,但用户又要求“一键批量上架”。这就意味着我必须在前端层面做两件事:

  • 用一个交易完成多个 NFT 的上架
  • 或者用一个签名授权多个操作

核心实现

方案选择:使用 multicall 模式

和合约开发者沟通后,我们决定在合约里加一个 multicall 函数(其实就是 OpenZeppelin 的 Multicall 扩展)。这样前端可以一次性打包多个 listItem 调用,用一个交易发送出去。

合约端的改动不归我管,我只需要前端构造好 calldata 数组传给 multicall 就行。

第一步:构造 multicall 的 calldata

这里有个坑:multicall 接收的参数是 bytes[],即每个子调用的编码数据。我必须用 viem 的 encodeFunctionData 来生成每个 listItem 调用的 calldata。

import { encodeFunctionData } from 'viem'
import { marketABI } from './abis'

// 构造 multicall 的 calldata
function buildMulticallData(tokenIds: number[], price: bigint) {
  return tokenIds.map((id) => {
    // 注意:这里用 encodeFunctionData 生成每个子调用的 calldata
    return encodeFunctionData({
      abi: marketABI,
      functionName: 'listItem',
      args: [nftAddress, id, price],
    })
  })
}

这里有个坑encodeFunctionDataargs 参数必须和合约函数的参数顺序完全一致。我当时把 nftAddressid 的顺序搞反了,结果合约一直报错,排查了半天才发现。

第二步:发送 multicall 交易

用 wagmi 的 useWriteContract hook 发送交易。

import { useWriteContract } from 'wagmi'

function BatchListButton({ tokenIds, price }: { tokenIds: number[], price: bigint }) {
  const { writeContract, isPending } = useWriteContract()

  const handleBatchList = async () => {
    const calldata = buildMulticallData(tokenIds, price)
    
    try {
      const hash = await writeContract({
        address: marketAddress,
        abi: marketABI,
        functionName: 'multicall',
        args: [calldata],
      })
      // hash 返回后,交易已经提交,等待确认
      console.log('交易已发送,hash:', hash)
    } catch (error) {
      console.error('发送失败:', error)
    }
  }

  return (
    <button onClick={handleBatchList} disabled={isPending}>
      {isPending ? '上架中...' : `批量上架 ${tokenIds.length} 个 NFT`}
    </button>
  )
}

注意这个细节writeContract 返回的是一个 Promise,resolve 时得到的是交易 hash,而不是交易确认。这意味着交易已经提交到链上,但还没被挖矿。如果用户此时关闭页面,交易可能失败。

第三步:等待交易确认并处理结果

为了给用户更好的反馈,我需要等待交易确认,然后检查每个子调用是否成功。

wagmi v2 提供了 useWaitForTransactionReceipt hook,但它是声明式的。我需要用命令式的方式等待,所以用了 viem 的 waitForTransactionReceipt

import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'

const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(),
})

// 等待交易确认
async function waitForTx(hash: `0x${string}`) {
  const receipt = await publicClient.waitForTransactionReceipt({ hash })
  return receipt
}

这里有个坑waitForTransactionReceipt 默认超时时间是 30 秒,如果网络拥堵,交易可能 30 秒内没确认,就会抛出超时异常。需要设置 timeout 参数。

const receipt = await publicClient.waitForTransactionReceipt({ 
  hash, 
  timeout: 120_000 // 延长到 2 分钟
})

第四步:解析 multicall 的返回值

multicall 函数返回一个 bytes[],每个元素对应子调用的返回值。我需要解析这些返回值来判断每个 NFT 是否上架成功。

import { decodeFunctionResult } from 'viem'

// 解析 multicall 返回值
function parseMulticallResult(results: `0x8`[], tokenIds: number[]) {
  return results.map((result, index) => {
    try {
      // 每个子调用的返回值类型是 bool
      const decoded = decodeFunctionResult({
        abi: marketABI,
        functionName: 'listItem',
        data: result,
      })
      return {
        tokenId: tokenIds[index],
        success: decoded as boolean,
      }
    } catch {
      return {
        tokenId: tokenIds[index],
        success: false,
        error: '解析失败',
      }
    }
  })
}

第五步:完整的批量上架流程

把上面所有步骤组合起来,加上错误处理和用户反馈。

import { useState } from 'react'
import { useWriteContract } from 'wagmi'
import { encodeFunctionData, decodeFunctionResult, createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { marketABI } from './abis'

const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(),
})

export function useBatchList() {
  const { writeContract } = useWriteContract()
  const [status, setStatus] = useState<'idle' | 'signing' | 'pending' | 'success' | 'error'>('idle')
  const [results, setResults] = useState<Array<{ tokenId: number; success: boolean; error?: string }>>([])

  const batchList = async (tokenIds: number[], price: bigint) => {
    setStatus('signing')
    setResults([])

    try {
      // 1. 构造 calldata
      const calldata = tokenIds.map((id) =>
        encodeFunctionData({
          abi: marketABI,
          functionName: 'listItem',
          args: [nftAddress, id, price],
        })
      )

      // 2. 发送交易
      setStatus('pending')
      const hash = await writeContract({
        address: marketAddress,
        abi: marketABI,
        functionName: 'multicall',
        args: [calldata],
      })

      // 3. 等待交易确认
      const receipt = await publicClient.waitForTransactionReceipt({
        hash,
        timeout: 120_000,
      })

      // 4. 解析返回值
      // 注意:multicall 的返回值在 receipt.logs 里,需要解析事件
      // 这里简化处理,实际需要根据合约事件解析
      const parsedResults = tokenIds.map((id) => ({
        tokenId: id,
        success: receipt.status === 'success',
      }))

      setResults(parsedResults)
      setStatus('success')
    } catch (error: any) {
      console.error('批量上架失败:', error)
      setStatus('error')
      // 如果用户拒绝了签名,error.message 包含 "User rejected"
      if (error.message?.includes('User rejected')) {
        alert('你取消了签名')
      } else {
        alert('上架失败,请重试')
      }
    }
  }

  return { batchList, status, results }
}

完整代码

以下是一个完整的 Next.js 页面组件,实现了批量上架 NFT 的功能。

// pages/batch-list.tsx
'use client'
import { useState } from 'react'
import { useAccount, useWriteContract } from 'wagmi'
import { encodeFunctionData, createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { marketABI, nftABI } from '@/abis'

const marketAddress = '0x...' // 替换为实际地址
const nftAddress = '0x...'    // 替换为实际地址
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(),
})

export default function BatchListPage() {
  const { address, isConnected } = useAccount()
  const { writeContract } = useWriteContract()
  const [selectedIds, setSelectedIds] = useState<number[]>([])
  const [price, setPrice] = useState('')
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')

  const handleBatchList = async () => {
    if (!address || selectedIds.length === 0 || !price) return

    setStatus('loading')
    const priceBigInt = BigInt(price) // 注意:价格需要是 wei 单位

    try {
      // 构造 multicall calldata
      const calldata = selectedIds.map((id) =>
        encodeFunctionData({
          abi: marketABI,
          functionName: 'listItem',
          args: [nftAddress, id, priceBigInt],
        })
      )

      // 发送交易
      const hash = await writeContract({
        address: marketAddress,
        abi: marketABI,
        functionName: 'multicall',
        args: [calldata],
      })

      // 等待确认
      await publicClient.waitForTransactionReceipt({
        hash,
        timeout: 120_000,
      })

      setStatus('success')
      alert(`成功上架 ${selectedIds.length} 个 NFT`)
    } catch (error: any) {
      console.error(error)
      setStatus('error')
      if (error.message?.includes('User rejected')) {
        alert('已取消操作')
      } else {
        alert('上架失败,请检查网络和 Gas')
      }
    }
  }

  if (!isConnected) return <div>请先连接钱包</div>

  return (
    <div>
      <h1>批量上架 NFT</h1>
      <input
        type="text"
        placeholder="输入价格 (wei)"
        value={price}
        onChange={(e) => setPrice(e.target.value)}
      />
      <div>
        {/* 这里应该显示用户的 NFT 列表,允许选择 */}
        <p>已选择 {selectedIds.length} 个 NFT</p>
      </div>
      <button
        onClick={handleBatchList}
        disabled={status === 'loading'}
      >
        {status === 'loading' ? '上架中...' : '批量上架'}
      </button>
    </div>
  )
}

踩坑记录

  1. encodeFunctionData 参数顺序错误:我把 nftAddresstokenId 的顺序写反了,导致合约报错 revert。后来用 console.log 打印 calldata 对比合约 ABI 才找到问题。

  2. waitForTransactionReceipt 超时:默认 30 秒超时,在以太坊主网拥堵时经常超时。需要设置 timeout 参数,我改成了 120 秒。

  3. 用户拒绝签名的处理writeContract 如果用户取消签名,会抛出一个错误,但错误对象的格式在不同钱包不同。MetaMask 返回 { code: 4001, message: 'User rejected' },而 WalletConnect 返回的格式不一样。我用了 error.message?.includes('User rejected') 来兼容。

  4. Gas 估算失败:当批量上架的 NFT 数量太多时,Gas 估算可能失败。我加了 try-catch,如果估算失败就提示用户手动设置 Gas。

小结

核心收获:批量操作 Web3 交易时,优先考虑合约层面的 multicall 模式,前端只需要用 encodeFunctionData 构造 calldata 数组。如果合约不支持,可以和合约开发者沟通添加。另外,处理异步交易一定要考虑超时、用户拒绝、网络异常等边界情况。

可以继续深挖的方向:如何用 useSimulateContract 在发送前模拟交易,以及如何用 usePublicClient 替代手动创建 client。

用 wagmi v2 + Next.js 14 搞 NFT 交易市场前端:从合约调用失败到顺利上架,我踩了哪些坑

用 wagmi v2 + Next.js 14 搞 NFT 交易市场前端:从合约调用失败到顺利上架,我踩了哪些坑

背景

上周我接了一个 NFT 交易市场的前端开发任务。项目用的是 Next.js 14,钱包连接用的是 wagmi v2 + RainbowKit。需求很简单:用户能连接钱包,查看自己持有的 NFT,把 NFT 上架到市场,设定价格,也能购买别人上架的 NFT,以及取消自己的上架。

听起来是不是很常规?我当时也这么想。之前在别的项目里用过 ethers.js 直接调合约,感觉没什么难度。结果从第一天就开始踩坑——签名失败、交易回滚、gas 估算不准、元数据不显示……整整折腾了两天多才把核心流程跑通。

这篇文章就是把我解决这些问题的过程完整记录下来。如果你也在用 wagmi v2 做 NFT 市场前端,希望你能少走我这些弯路。

问题分析

我的第一版思路很简单:用 wagmi 的 useWriteContract 直接调用合约的 listItem 函数,传入 tokenId 和价格。代码写起来确实很短:

const { writeContract } = useWriteContract();
writeContract({
  address: marketAddress,
  abi: marketAbi,
  functionName: 'listItem',
  args: [nftAddress, tokenId, ethers.parseEther('0.1')],
});

结果一跑,控制台直接报错:User rejected the request。我明明在 MetaMask 里点了确认,怎么还拒绝?后来发现是我没理解 wagmi v2 的 writeContract 签名方式——它默认用的是 eth_sendTransaction,但很多 NFT 市场合约要求先调用 NFT 合约的 approve,然后才能调用 listItem。而且 wagmi v2 的 useWriteContract 返回的是交易哈希,不是回调确认,我之前的处理方式完全不对。

更坑的是,我后来改用 useSimulateContract 做 gas 估算,结果又遇到了 insufficient funds for gas * price + value 的错误。当时我的钱包里确实有 ETH,但 gas 估算值跑飞了。排查了很久才发现,是因为我没有正确设置 account 参数。

核心实现

1. 先调 approve,再调 listItem:两笔交易的顺序坑

NFT 交易市场的标准流程是:用户先调用 NFT 合约的 approve 把某个 tokenId 授权给市场合约,然后市场合约才能把 NFT 转移走。所以前端必须发两笔交易。

我当时的第一反应是:用户点一次"上架"按钮,先发 approve,等 approve 确认后再发 listItem。但在 wagmi v2 里,useWriteContract 是异步的,而且没有内置的等待确认回调。我试了用 waitForTransactionReceipt 来等确认,但发现这样会导致 UI 状态混乱——用户可能以为已经完成了,但实际上第二笔交易还没发。

我的解决方案是:用一个状态机来控制流程。status 有四种状态:idleapprovingapproveDonelisting。用户点击上架后,先发 approve,等交易确认后自动切换到 listing 状态,再发 listItem。

// 状态枚举
type ListStatus = 'idle' | 'approving' | 'approveDone' | 'listing';

const [status, setStatus] = useState<ListStatus>('idle');

// 第一步:调用 approve
const { writeContract: approveWrite } = useWriteContract();
const { data: approveHash } = useWaitForTransactionReceipt({
  hash: status === 'approving' ? pendingHash : undefined,
});

const handleApprove = async (tokenId: bigint) => {
  setStatus('approving');
  approveWrite({
    address: nftAddress as `0x${string}`,
    abi: erc721Abi,
    functionName: 'approve',
    args: [marketAddress, tokenId],
  });
};

// 监听 approve 确认
useEffect(() => {
  if (approveHash && status === 'approving') {
    setStatus('approveDone');
  }
}, [approveHash, status]);

这里有个坑:wagmi v2 的 useWaitForTransactionReceipt 必须传入一个 hash 参数,而且这个 hash 必须是 useWriteContract 返回的 data。但 useWriteContractdata 是在交易提交后才有的,所以一开始 hashundefined。我一开始没处理这个初始状态,导致 useWaitForTransactionReceipt 永远不触发。后来加了个条件判断,只有 status === 'approving' 时才传入 hash,才正常。

2. 处理 listItem 的 gas 估算问题

approve 成功后,接下来调用 listItem。但我在这一步又遇到了 gas 估算不准的问题。

wagmi v2 提供了 useSimulateContract 来做 gas 估算,但我发现它返回的 request 有时候会报错 insufficient funds。排查后发现,是因为 useSimulateContract 默认使用当前连接的钱包地址作为 account,但如果用户的钱包在另一个链上,或者合约地址写错了,就会导致估算失败。

我的做法是:先检查链 ID 是否匹配,然后用 useSimulateContract 的返回值来构造交易,最后用 useWriteContract 发送。如果估算失败,就 fallback 到默认 gas limit。

const { chain } = useAccount();
const marketChainId = 11155111; // Sepolia

// 检查链是否匹配
const isCorrectChain = chain?.id === marketChainId;

// 估算 listItem 的 gas
const { data: simulateData, error: simulateError } = useSimulateContract({
  address: marketAddress as `0x${string}`,
  abi: marketAbi,
  functionName: 'listItem',
  args: [nftAddress, tokenId, price],
  query: {
    enabled: isCorrectChain && status === 'approveDone',
  },
});

// 发送 listItem 交易
const { writeContract: listWrite } = useWriteContract();

const handleList = () => {
  if (simulateError) {
    // 如果估算失败,用默认 gas limit
    listWrite({
      address: marketAddress as `0x${string}`,
      abi: marketAbi,
      functionName: 'listItem',
      args: [nftAddress, tokenId, price],
      gas: 200000n, // 硬编码一个安全值
    });
  } else if (simulateData?.request) {
    listWrite(simulateData.request);
  }
};

注意这个细节useSimulateContractquery.enabled 很重要。如果链不对或者状态不对,就不要去估算,否则会一直报错。而且 simulateError 不一定是 gas 问题,也可能是参数格式不对。我遇到过一次 args 里的 price 忘记用 parseEther 转换,导致合约报错 revert

3. 读取已上架的 NFT 列表:处理 BigInt 和元数据

上架成功后,用户需要在市场页面上看到所有已上架的 NFT。这里我用 wagmi 的 useReadContract 来读取合约的 getListedItems 函数。

但读出来的数据全是 bigint 类型,包括 tokenIdprice。直接显示在 UI 上会变成 12345678901234567890n 这种形式。而且每个 NFT 的元数据(图片、名称、描述)需要从 NFT 合约的 tokenURI 获取,这是一个异步的 HTTP 请求。

我的做法是:把 useReadContract 的结果映射成一个数组,然后对每个 item 调用 useReadContract 读取 tokenURI,再用 useEffect 去 fetch 元数据 JSON。

// 读取所有上架的 NFT
const { data: listedItems, isLoading: itemsLoading } = useReadContract({
  address: marketAddress as `0x${string}`,
  abi: marketAbi,
  functionName: 'getListedItems',
});

// 对每个 item 读取 tokenURI
const itemsWithMetadata = listedItems?.map((item: any) => {
  const { data: tokenUri } = useReadContract({
    address: item.nftAddress as `0x${string}`,
    abi: erc721Abi,
    functionName: 'tokenURI',
    args: [item.tokenId],
  });

  // 这里有个坑:useReadContract 不能在 map 里用,因为 hooks 数量必须固定
  // 正确做法:用另一个组件或者用 useContractReads 批量读取
});

我踩的这个坑特别大useReadContract 是 React Hook,不能在循环或条件语句里调用。我一开始在 map 里直接调用,结果报错 Rendered more hooks than during the previous render。后来改用 useContractReads 批量读取所有 tokenURI,但 useContractReads 在 wagmi v2 里改成了 useReadContracts,参数格式也不一样。

最终方案:用 useReadContracts 一次性读取所有 NFT 的 tokenURI:

const { data: tokenUris } = useReadContracts({
  contracts: listedItems?.map((item: any) => ({
    address: item.nftAddress as `0x${string}`,
    abi: erc721Abi,
    functionName: 'tokenURI',
    args: [item.tokenId],
  })) || [],
});

// 然后 fetch 每个 URI 获取元数据
const [metadataList, setMetadataList] = useState<any[]>([]);

useEffect(() => {
  if (!tokenUris) return;
  const fetchAll = async () => {
    const results = await Promise.all(
      tokenUris.map(async (result: any) => {
        if (result.status === 'success') {
          const response = await fetch(result.result);
          return response.json();
        }
        return null;
      })
    );
    setMetadataList(results);
  };
  fetchAll();
}, [tokenUris]);

注意这个细节tokenURI 返回的可能是 IPFS 地址(如 ipfs://xxx),前端直接 fetch 会失败。需要先解析成 HTTP 网关地址。我用了 ipfs-utils 库,或者简单替换 ipfs://https://ipfs.io/ipfs/

4. 购买功能的实现:处理 ETH 转账和回调

购买功能的逻辑更简单:用户点击"购买"按钮,调用市场合约的 buyItem 函数,同时发送 ETH(价格)。但这里有两个坑:

  1. buyItem 通常需要 payable,所以 writeContract 要带上 value 参数。
  2. 购买成功后需要刷新列表,但 wagmi v2 没有内置的 refetch 机制。

我的做法是:用 useWriteContract 发送交易,然后用 useWaitForTransactionReceipt 监听确认,确认后手动调用 refetch 刷新列表。

const { writeContract: buyWrite, data: buyHash } = useWriteContract();
const { isSuccess: buySuccess } = useWaitForTransactionReceipt({
  hash: buyHash,
});

const handleBuy = (item: ListedItem) => {
  buyWrite({
    address: marketAddress as `0x${string}`,
    abi: marketAbi,
    functionName: 'buyItem',
    args: [item.nftAddress, item.tokenId],
    value: item.price, // 发送 ETH
  });
};

// 购买成功后刷新列表
useEffect(() => {
  if (buySuccess) {
    refetchListedItems(); // 假设这个函数是 useReadContract 返回的 refetch
    toast.success('购买成功!');
  }
}, [buySuccess]);

这里有个坑value 的单位是 wei,而 item.price 从合约读出来就是 bigint 类型的 wei 值,所以直接传就行。但如果你从 UI 输入框获取价格,记得用 parseEther 转换。我当时就是忘了转换,导致发送了 0.000000000000000001 ETH,合约直接 revert。

完整代码

下面是一个可运行的完整示例(基于 Next.js 14 + wagmi v2 + RainbowKit):

// app/components/NFTMarket.tsx
'use client';

import { useState, useEffect } from 'react';
import { useAccount, useWriteContract, useWaitForTransactionReceipt, useReadContract, useReadContracts } from 'wagmi';
import { parseEther, formatEther } from 'viem';
import { erc721Abi } from './abis/erc721Abi';
import { marketAbi } from './abis/marketAbi';

type ListStatus = 'idle' | 'approving' | 'approveDone' | 'listing';
type ListedItem = {
  seller: string;
  nftAddress: string;
  tokenId: bigint;
  price: bigint;
};

export default function NFTMarket() {
  const { address, chain } = useAccount();
  const [status, setStatus] = useState<ListStatus>('idle');
  const [selectedTokenId, setSelectedTokenId] = useState<bigint>(0n);
  const [price, setPrice] = useState<string>('0.1');
  const [listedItems, setListedItems] = useState<ListedItem[]>([]);

  // 合约地址(请替换为实际部署的地址)
  const marketAddress = '0xYourMarketAddress' as `0x${string}`;
  const nftAddress = '0xYourNFTAddress' as `0x${string}`;

  // 读取所有上架的 NFT
  const { data: rawItems, refetch: refetchItems } = useReadContract({
    address: marketAddress,
    abi: marketAbi,
    functionName: 'getListedItems',
  });

  // 批量读取 tokenURI
  const { data: tokenUris } = useReadContracts({
    contracts: (rawItems as ListedItem[] || []).map((item) => ({
      address: item.nftAddress as `0x${string}`,
      abi: erc721Abi,
      functionName: 'tokenURI',
      args: [item.tokenId],
    })),
  });

  // 获取元数据
  const [metadataList, setMetadataList] = useState<any[]>([]);
  useEffect(() => {
    if (!tokenUris) return;
    const fetchAll = async () => {
      const results = await Promise.all(
        tokenUris.map(async (result: any) => {
          if (result.status === 'success') {
            const uri = result.result.replace('ipfs://', 'https://ipfs.io/ipfs/');
            try {
              const res = await fetch(uri);
              return res.json();
            } catch {
              return null;
            }
          }
          return null;
        })
      );
      setMetadataList(results);
    };
    fetchAll();
  }, [tokenUris]);

  // approve 交易
  const { writeContract: approveWrite, data: approveHash } = useWriteContract();
  const { isSuccess: approveSuccess } = useWaitForTransactionReceipt({
    hash: approveHash,
  });

  // approve 成功后自动进入 listing 状态
  useEffect(() => {
    if (approveSuccess && status === 'approving') {
      setStatus('approveDone');
    }
  }, [approveSuccess, status]);

  // listItem 交易
  const { writeContract: listWrite, data: listHash } = useWriteContract();
  const { isSuccess: listSuccess } = useWaitForTransactionReceipt({
    hash: listHash,
  });

  // 上架成功后刷新
  useEffect(() => {
    if (listSuccess) {
      setStatus('idle');
      refetchItems();
      alert('上架成功!');
    }
  }, [listSuccess, refetchItems]);

  // 处理上架按钮点击
  const handleList = async () => {
    if (!address) return;
    const tokenId = selectedTokenId;
    const priceWei = parseEther(price);

    // 第一步:approve
    setStatus('approving');
    approveWrite({
      address: nftAddress,
      abi: erc721Abi,
      functionName: 'approve',
      args: [marketAddress, tokenId],
    });
  };

  // 当 status 变为 approveDone 时,自动调用 listItem
  useEffect(() => {
    if (status === 'approveDone') {
      const priceWei = parseEther(price);
      listWrite({
        address: marketAddress,
        abi: marketAbi,
        functionName: 'listItem',
        args: [nftAddress, selectedTokenId, priceWei],
      });
    }
  }, [status, price, selectedTokenId, marketAddress, nftAddress, listWrite]);

  // 购买功能
  const handleBuy = (item: ListedItem) => {
    buyWrite({
      address: marketAddress,
      abi: marketAbi,
      functionName: 'buyItem',
      args: [item.nftAddress, item.tokenId],
      value: item.price,
    });
  };

  const { writeContract: buyWrite, data: buyHash } = useWriteContract();
  const { isSuccess: buySuccess } = useWaitForTransactionReceipt({
    hash: buyHash,
  });

  useEffect(() => {
    if (buySuccess) {
      refetchItems();
      alert('购买成功!');
    }
  }, [buySuccess, refetchItems]);

  return (
    <div className="p-4">
      <h1 className="text-2xl font-bold mb-4">NFT 交易市场</h1>

      {/* 上架区域 */}
      <div className="border p-4 rounded mb-4">
        <h2 className="text-lg font-semibold mb-2">上架 NFT</h2>
        <input
          type="number"
          placeholder="Token ID"
          value={selectedTokenId.toString()}
          onChange={(e) => setSelectedTokenId(BigInt(e.target.value || '0'))}
          className="border p-2 mr-2"
        />
        <input
          type="text"
          placeholder="价格 (ETH)"
          value={price}
          onChange={(e) => setPrice(e.target.value)}
          className="border p-2 mr-2"
        />
        <button
          onClick={handleList}
          disabled={status !== 'idle'}
          className="bg-blue-500 text-white p-2 rounded disabled:opacity-50"
        >
          {status === 'approving' ? '授权中...' : status === 'listing' ? '上架中...' : '上架'}
        </button>
      </div>

      {/* 已上架列表 */}
      <div className="grid grid-cols-3 gap-4">
        {(rawItems as ListedItem[] || []).map((item, index) => (
          <div key={index} className="border p-4 rounded">
            {metadataList[index] && (
              <img src={metadataList[index].image} alt={metadataList[index].name} className="w-full h-48 object-cover mb-2" />
            )}
            <p>Token ID: {item.tokenId.toString()}</p>
            <p>价格: {formatEther(item.price)} ETH</p>
            <p>卖家: {item.seller.slice(0, 6)}...{item.seller.slice(-4)}</p>
            {item.seller !== address && (
              <button onClick={() => handleBuy(item)} className="bg-green-500 text-white p-2 rounded mt-2">
                购买
              </button>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

踩坑记录

  1. useReadContract 在 map 里调用导致 hooks 数量不固定:这个错误特别隐蔽,因为编译不报错,运行才报。后来查文档才知道 wagmi v2 的 hooks 必须遵守 React 规则。解决方案是改用 useReadContracts 批量读取。

  2. useSimulateContractquery.enabled 没设置导致无限估算:一开始没加 enabled 条件,结果页面一加载就估算,链不对时一直报错。加了 isCorrectChain 判断后就好了。

  3. tokenURI 返回 IPFS 地址,前端直接 fetch 失败:这个问题在测试网上很常见。我一开始没处理,以为合约返回的是 HTTP 地址。后来加了个 replaceipfs:// 换成网关地址。

  4. 购买时 value 忘记用 parseEther 转换:UI 输入的是 0.1,但合约需要 wei。我直接把字符串传进去了,结果交易一直 revert。后来用 parseEther 转换才正常。

小结

这次做 NFT 交易市场前端,最大的感悟是:用 wagmi v2 开发时,一定要搞清楚 hooks 的规则和参数格式。特别是 useWriteContractuseReadContract 的调用方式,跟 ethers.js 差别很大。另外,处理 IPFS 地址和 BigInt 显示也是经常被忽略的细节。如果你也想深入,可以继续研究 wagmi v2 的 useAccountEffect 来做链切换监听,或者用 useSendTransaction 处理更复杂的交易。

从ethers.js迁移到Viem:我在DeFi Dashboard项目中踩过的坑与最终方案

背景

上个月,老板扔给我一个“历史遗留”的DeFi Dashboard项目,说是要加几个新功能。我打开代码一看,好家伙,ethers.js v5,没有TypeScript严格模式,合约调用全是 any 类型,交易状态管理靠手动 setInterval 轮询。最要命的是,用户反馈说在某个链上查余额总是超时,我排查了半天,发现是ethers.js的Provider连接池管理有问题。

我当时就想,与其修修补补,不如直接用Viem重写这部分。Viem是Wagmi团队的新作品,TypeScript原生支持,API设计更现代。但迁移过程远比我想象的复杂——Viem的 PublicClientWalletClient 跟ethers.js的 ProviderSigner 完全是两套逻辑。这篇文章就是我踩坑两天的完整记录。

问题分析

我的最初思路很简单:把 new ethers.providers.JsonRpcProvider(rpcUrl) 替换成 createPublicClient,把 signer.connect(provider) 替换成 createWalletClient。结果一跑就报错——Viem不直接支持EIP-1193之外的Provider格式。

更坑的是,ethers.js里 contract.connect(signer) 那种链式调用,在Viem里完全不存在。Viem要求你把钱包客户端和合约配置分开传。我当时就懵了:难道要每个合约调用都重复传参?

排查后发现,Viem的哲学是“显式优于隐式”。它不搞 provider 那种全局状态,而是让你明确指定每一步用哪个客户端。这虽然更安全,但代码结构需要大改。

核心实现

第一步:创建Viem客户端,告别Provider全局变量

在ethers.js里,我习惯这样写:

// ethers.js 旧代码
const provider = new ethers.providers.JsonRpcProvider('https://eth-mainnet.g.alchemy.com/v2/xxx');
const signer = new ethers.Wallet('privateKey', provider);

Viem里可不行。你必须区分“只读”的公共客户端和“可写”的钱包客户端:

// Viem 新代码
import { createPublicClient, createWalletClient, http } from 'viem';
import { mainnet } from 'viem/chains';

// 公共客户端:只用来查链上数据
export const publicClient = createPublicClient({
  chain: mainnet,
  transport: http('https://eth-mainnet.g.alchemy.com/v2/xxx'),
});

// 钱包客户端:用来发交易,需要钱包适配器
export const walletClient = createWalletClient({
  chain: mainnet,
  transport: http('https://eth-mainnet.g.alchemy.com/v2/xxx'),
});

这里有个坑:如果你直接用 createWalletClient 并传了 account 参数,那它就是“本地签名”模式。但在前端项目里,通常我们用的是浏览器钱包(如MetaMask),所以不传 account,而是在调用时动态传入。

第二步:合约交互——从Contract对象到直接调用

ethers.js里,合约交互是这样的:

const contract = new ethers.Contract(address, abi, signer);
const balance = await contract.balanceOf(userAddress);

Viem完全不一样。它没有 Contract 类,而是通过 readContractwriteContract 方法直接调用:

import { getContract, parseAbi } from 'viem';

const abi = parseAbi([
  'function balanceOf(address owner) view returns (uint256)',
  'function transfer(address to, uint256 amount) returns (bool)',
]);

// 读操作
const balance = await publicClient.readContract({
  address: '0x...',
  abi,
  functionName: 'balanceOf',
  args: [userAddress],
});

// 写操作
const hash = await walletClient.writeContract({
  address: '0x...',
  abi,
  functionName: 'transfer',
  args: [toAddress, amount],
  account: userAddress, // 必须显式传入
});

注意这个细节:Viem的 readContract 返回的是 bigint 而不是 BigNumber。我当时没注意,直接拿去渲染UI,结果发现数字显示不对。后来用 formatUnits 转换才解决。

第三步:交易状态监听——从轮询到等待确认

ethers.js里监听交易确认,我原来是这样写的:

const tx = await contract.transfer(to, amount);
const receipt = await tx.wait(1); // 等1个确认

Viem提供了更细粒度的控制:

import { waitForTransactionReceipt } from 'viem/actions';

// 发送交易
const hash = await walletClient.sendTransaction({
  to: '0x...',
  value: parseEther('0.1'),
  account: userAddress,
});

// 等待确认
const receipt = await waitForTransactionReceipt(publicClient, {
  hash,
  confirmations: 1,
  pollingInterval: 1000, // 每秒轮询一次
});

console.log('交易确认,区块号:', receipt.blockNumber);

这里有个坑waitForTransactionReceipt 默认只等一个确认,如果你需要多个确认,必须显式传 confirmations。而且它返回的 receipt 类型是 TransactionReceipt,比ethers.js的 TransactionReceipt 字段名有差异——Viem里是 blockNumber 而不是 blockNumber(注意大小写,Viem是驼峰)。

第四步:多链支持——从手动切RPC到链配置

原来的项目支持以太坊和Polygon,代码里是:

const providers = {
  ethereum: new ethers.providers.JsonRpcProvider(rpcEthereum),
  polygon: new ethers.providers.JsonRpcProvider(rpcPolygon),
};

Viem把链信息封装成了对象,切换起来更优雅:

import { mainnet, polygon } from 'viem/chains';

export function getClients(chainId: number) {
  const chain = chainId === 1 ? mainnet : polygon;
  const rpcUrl = chainId === 1 ? 'https://eth-mainnet.g.alchemy.com/v2/xxx' : 'https://polygon-mainnet.g.alchemy.com/v2/xxx';

  return {
    publicClient: createPublicClient({ chain, transport: http(rpcUrl) }),
    walletClient: createWalletClient({ chain, transport: http(rpcUrl) }),
  };
}

使用的时候:

const { publicClient } = getClients(1);
const balance = await publicClient.readContract(...);

注意这个细节:Viem的 chain 对象里包含了原生币信息、区块浏览器URL等,如果你要用这些元数据,可以直接从 chain.nativeCurrency 获取,不用自己维护配置表了。

完整代码

下面是一个可直接运行的React组件示例,它用Viem读取用户ERC20余额,并支持发送交易:

// ViemDeFiComponent.tsx
import React, { useState, useEffect } from 'react';
import { createPublicClient, createWalletClient, http, parseErc20Abi, formatUnits, parseEther } from 'viem';
import { mainnet } from 'viem/chains';
import { waitForTransactionReceipt } from 'viem/actions';

// 配置公共客户端
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY'), // 替换为你的API key
});

const TOKEN_ADDRESS = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; // USDT合约地址

interface Props {
  userAddress: `0x${string}`;
}

export default function ViemDeFiComponent({ userAddress }: Props) {
  const [balance, setBalance] = useState<string>('0');
  const [txHash, setTxHash] = useState<string>('');
  const [isLoading, setIsLoading] = useState(false);

  // 读取余额
  useEffect(() => {
    async function fetchBalance() {
      try {
        const rawBalance = await publicClient.readContract({
          address: TOKEN_ADDRESS,
          abi: parseErc20Abi,
          functionName: 'balanceOf',
          args: [userAddress],
        });
        // 注意:rawBalance是bigint,需要格式化
        setBalance(formatUnits(rawBalance, 6)); // USDT是6位小数
      } catch (error) {
        console.error('读取余额失败:', error);
      }
    }
    fetchBalance();
  }, [userAddress]);

  // 发送交易(需要钱包连接)
  async function handleSendTransaction() {
    setIsLoading(true);
    try {
      // 假设用户已连接钱包,我们通过window.ethereum获取账户
      const [account] = await window.ethereum.request({ method: 'eth_requestAccounts' });
      
      const walletClient = createWalletClient({
        chain: mainnet,
        transport: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY'),
      });

      // 发送ETH
      const hash = await walletClient.sendTransaction({
        to: '0x...', // 替换为接收地址
        value: parseEther('0.01'),
        account: account as `0x${string}`,
      });

      setTxHash(hash);

      // 等待1个确认
      const receipt = await waitForTransactionReceipt(publicClient, {
        hash,
        confirmations: 1,
      });

      console.log('交易确认,消耗gas:', receipt.gasUsed);
    } catch (error) {
      console.error('交易失败:', error);
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <div>
      <h2>USDT余额: {balance}</h2>
      <button onClick={handleSendTransaction} disabled={isLoading}>
        {isLoading ? '交易进行中...' : '发送0.01 ETH'}
      </button>
      {txHash && <p>交易哈希: {txHash}</p>}
    </div>
  );
}

注意:上面的 parseErc20Abi 是Viem内置的,它只包含 balanceOftotalSupplytransfer 等标准ERC20方法。如果你需要自定义方法,得用 parseAbi 自己写。

踩坑记录

  1. window.ethereum 类型问题:Viem要求地址类型是 0x${string},但 window.ethereum.request 返回的是 string[]。我一开始直接 as 断言,结果类型检查没过。后来用 account as \0x${string}`` 才解决。

  2. formatUnits 的精度丢失:用 formatUnits(1000000n, 6) 返回 "1" 没问题,但 formatUnits(123456789n, 6) 返回 "123.456789",如果直接渲染到UI会显示很多小数位。我后来用 Number(balance).toFixed(2) 做二次格式化。

  3. waitForTransactionReceipt 超时:默认超时是30秒,如果网络拥堵,交易一直pending,会抛异常。我加了 maxWaitTime 参数:waitForTransactionReceipt(publicClient, { hash, maxWaitTime: 120_000 })

  4. http 传输的速率限制:我在测试时频繁调用 readContract,结果被RPC限流了。Viem支持轮询,但默认没有内置重试逻辑。我后来用 viem/actionsgetBlockNumber 配合 setInterval 手动控制频率。

小结

Viem的核心优势是类型安全和显式API设计,迁移成本主要在理解“客户端分离”的理念。如果你也在做迁移,建议先把公共客户端和钱包客户端拆清楚,再逐个合约替换。想继续深挖的话,可以研究Viem的 Actions 架构,它能让你自定义交易流程,比ethers.js灵活得多。

用ethers.js连接MetaMask实现Web3钱包登录:从踩坑到稳定运行的完整记录

前言:一个看似简单的需求,让我折腾了两天

事情是这样的:上个月我在做一个DeFi看板项目,核心功能是让用户连接MetaMask钱包后,能看到自己的资产和交易记录。老板说"钱包登录很简单,就是调个接口嘛",我当时也觉得——不就是调个window.ethereum吗?结果真正动手时,踩坑踩到怀疑人生。

从连接失败、签名验证错误,到用户拒绝连接后状态混乱,再到不同链之间切换导致数据错乱……每一个问题都让我抓狂。但最终,我总结出了一套稳定可用的方案。这篇文章就是把我这两天的踩坑经历完整记录下来,希望对你有帮助。


背景:一个DeFi看板项目的前端需求

当时我正在开发一个叫"DeFiDash"的项目,本质上就是一个聚合看板,用户连接钱包后能查看自己在多个链上的资产、持仓和交易记录。前端用的是React + TypeScript,后端是Node.js。

核心需求其实就三个:

  1. 用户点击"连接钱包"按钮,弹出MetaMask授权窗口
  2. 用户签名一条消息,后端验证签名后返回JWT token
  3. 页面根据用户地址展示对应的链上数据

看起来很简单对吧?但真正实现时,我遇到了第一个大坑——连接过程的状态管理。


问题分析:为什么"一行代码"搞不定?

我最初的思路是直接写一个connectWallet函数:

// 第一版代码,天真到不行
async function connectWallet() {
  const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
  setAddress(accounts[0]);
}

然后我发现了三个问题:

问题1:用户拒绝连接时,代码会直接崩溃。 如果用户点击了MetaMask弹窗的"取消"按钮,request会抛出一个错误,但我的代码没有捕获它,导致页面白屏。

问题2:连接后没有验证链ID。 用户可能连接的是以太坊主网,也可能连接的是Goerli测试网,但我根本没有检查。后来用户反馈说"连接后看不到资产",排查了半天才发现是链ID不匹配。

问题3:页面刷新后连接状态丢失。 用户连接成功后,刷新页面就需要重新连接。这体验太差了,而且每次刷新都弹MetaMask窗口,用户会疯的。

这三个问题让我意识到,钱包登录远不止"调一个接口"那么简单。我需要一个完整的连接流程,包括状态管理、错误处理、链ID验证和持久化。


核心实现:一步步搭建稳定的钱包登录

第一步:初始化Provider和检测MetaMask

在React中,我习惯把所有Web3相关的逻辑封装在一个自定义Hook里。首先,我需要一个provider——这是与区块链交互的底层对象。

// hooks/useWallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useState, useEffect, useCallback } from 'react';

export function useWallet() {
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
  const [address, setAddress] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [error, setError] = useState<string>('');

  // 初始化:检测MetaMask是否安装
  useEffect(() => {
    if (typeof window.ethereum === 'undefined') {
      setError('请安装MetaMask浏览器插件');
      return;
    }
    // 注意:这里不要自动请求连接,只在用户点击按钮时才触发
    const ethersProvider = new BrowserProvider(window.ethereum);
    setProvider(ethersProvider);
  }, []);
}

这里有个坑: 不要一加载页面就调用eth_requestAccounts。有些用户不想连接钱包,只是浏览页面,自动弹窗会吓到他们。正确的做法是只检测MetaMask是否存在,连接操作交给用户点击按钮触发。

第二步:实现连接逻辑,处理所有异常

连接钱包的核心是调用eth_requestAccounts,但必须做好错误处理。我当时用了一个笨办法——直接在catch里打印错误信息,后来发现不同错误类型需要不同处理方式。

// hooks/useWallet.ts(续)
const connect = useCallback(async () => {
  if (!provider) {
    setError('Provider未初始化');
    return;
  }

  try {
    // 关键:先请求账户,再获取签名者
    const accounts = await provider.send('eth_requestAccounts', []);
    if (accounts.length === 0) {
      throw new Error('没有获取到账户');
    }

    const userAddress = accounts[0];
    const userSigner = await provider.getSigner();
    const network = await provider.getNetwork();
    const userChainId = Number(network.chainId);

    // 验证链ID是否在支持的范围内
    const SUPPORTED_CHAIN_IDS = [1, 5, 137]; // 以太坊主网、Goerli、Polygon
    if (!SUPPORTED_CHAIN_IDS.includes(userChainId)) {
      // 这里可以提示用户切换网络,但先保存状态
      console.warn(`当前链ID ${userChainId} 不在支持列表中`);
    }

    setAddress(userAddress);
    setSigner(userSigner);
    setChainId(userChainId);
    setError('');

    // 持久化:把地址存到localStorage,下次刷新时自动恢复
    localStorage.setItem('walletAddress', userAddress);
    localStorage.setItem('walletChainId', userChainId.toString());

  } catch (err: any) {
    // 处理不同类型的错误
    if (err.code === 4001) {
      // 用户拒绝了连接请求
      setError('用户拒绝了连接请求');
    } else if (err.code === -32002) {
      // MetaMask正在处理另一个请求
      setError('请先处理MetaMask中的其他请求');
    } else {
      setError(err.message || '连接钱包失败');
    }
  }
}, [provider]);

注意这个细节: 错误码4001是用户拒绝,-32002是重复请求。这两个错误码我查了MetaMask文档才搞清楚,之前一直用err.message判断,结果发现不同版本的MetaMask返回的消息格式不一样。

第三步:消息签名与后端验证

登录不只是连接钱包,还需要让后端验证用户身份。最常用的方式是"消息签名"——前端让用户签名一条包含随机数(nonce)的消息,后端用公钥验证签名。

// hooks/useWallet.ts(续)
const signMessage = useCallback(async (message: string): Promise<string> => {
  if (!signer) {
    throw new Error('请先连接钱包');
  }

  try {
    // 注意:message应该包含一个nonce,防止重放攻击
    const signature = await signer.signMessage(message);
    return signature;
  } catch (err: any) {
    if (err.code === 4001) {
      throw new Error('用户取消了签名');
    }
    throw new Error('签名失败: ' + err.message);
  }
}, [signer]);

// 实际登录流程
const login = useCallback(async () => {
  if (!address) {
    setError('请先连接钱包');
    return;
  }

  try {
    // 1. 从后端获取nonce
    const nonceResponse = await fetch('/api/auth/nonce?address=' + address);
    const { nonce } = await nonceResponse.json();

    // 2. 让用户签名nonce
    const message = `欢迎登录DeFiDash,本次登录的随机码为:${nonce}`;
    const signature = await signMessage(message);

    // 3. 发送地址和签名到后端验证
    const loginResponse = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ address, signature, message }),
    });

    const { token } = await loginResponse.json();
    // 存储token,后续API请求带上
    localStorage.setItem('authToken', token);
    setError('');

  } catch (err: any) {
    setError(err.message || '登录失败');
  }
}, [address, signMessage]);

这里有个坑: 签名消息的格式很重要。有些项目直接签address,这太不安全了,因为任何网站都可以伪造。一定要包含nonce,并且最好加上一些上下文信息(比如"欢迎登录XXX"),让用户在MetaMask里能看到清晰的内容。

第四步:页面刷新后自动恢复连接状态

用户连接成功后刷新页面,如果直接显示"未连接",体验很差。我通过localStorage保存地址,在页面加载时尝试恢复。

// hooks/useWallet.ts(续)
// 页面加载时恢复连接
useEffect(() => {
  const savedAddress = localStorage.getItem('walletAddress');
  const savedChainId = localStorage.getItem('walletChainId');

  if (savedAddress && provider) {
    // 恢复时只设置地址,不主动请求连接
    setAddress(savedAddress);
    setChainId(Number(savedChainId));
    // 注意:这里不设置signer,因为signer需要用户授权
    // 实际使用时,如果用户需要签名,再调用connect获取signer
  }
}, [provider]);

// 监听账户变化和链变化
useEffect(() => {
  if (!window.ethereum) return;

  const handleAccountsChanged = (accounts: string[]) => {
    if (accounts.length === 0) {
      // 用户断开了连接
      disconnect();
    } else {
      setAddress(accounts[0]);
      localStorage.setItem('walletAddress', accounts[0]);
    }
  };

  const handleChainChanged = (chainIdHex: string) => {
    const newChainId = parseInt(chainIdHex, 16);
    setChainId(newChainId);
    localStorage.setItem('walletChainId', newChainId.toString());
    // 链变化后,signer需要重新获取
    if (provider) {
      provider.getSigner().then(setSigner);
    }
  };

  window.ethereum.on('accountsChanged', handleAccountsChanged);
  window.ethereum.on('chainChanged', handleChainChanged);

  return () => {
    window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
    window.ethereum.removeListener('chainChanged', handleChainChanged);
  };
}, [provider]);

const disconnect = useCallback(() => {
  setAddress('');
  setSigner(null);
  setChainId(0);
  localStorage.removeItem('walletAddress');
  localStorage.removeItem('walletChainId');
  localStorage.removeItem('authToken');
}, []);

注意这个细节: chainChanged事件返回的是十六进制字符串(比如"0x5"),需要转成十进制。我当时直接用了parseInt,但忘记加基数参数,导致"0x5"被解析成0。排查了半天才发现。


完整代码:可直接运行的React Hook

我把上面所有代码整合成一个完整的useWallet Hook,你可以直接复制到项目中使用。

// hooks/useWallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useState, useEffect, useCallback } from 'react';

interface WalletState {
  provider: BrowserProvider | null;
  signer: JsonRpcSigner | null;
  address: string;
  chainId: number;
  error: string;
  isConnecting: boolean;
}

export function useWallet() {
  const [state, setState] = useState<WalletState>({
    provider: null,
    signer: null,
    address: '',
    chainId: 0,
    error: '',
    isConnecting: false,
  });

  // 初始化:检测MetaMask
  useEffect(() => {
    if (typeof window.ethereum === 'undefined') {
      setState(prev => ({ ...prev, error: '请安装MetaMask插件' }));
      return;
    }
    const ethersProvider = new BrowserProvider(window.ethereum);
    setState(prev => ({ ...prev, provider: ethersProvider }));
  }, []);

  // 恢复上次连接
  useEffect(() => {
    const savedAddress = localStorage.getItem('walletAddress');
    if (savedAddress && state.provider) {
      setState(prev => ({ ...prev, address: savedAddress }));
    }
  }, [state.provider]);

  // 连接钱包
  const connect = useCallback(async () => {
    if (!state.provider) {
      setState(prev => ({ ...prev, error: 'Provider未初始化' }));
      return;
    }

    setState(prev => ({ ...prev, isConnecting: true, error: '' }));

    try {
      const accounts = await state.provider.send('eth_requestAccounts', []);
      if (accounts.length === 0) {
        throw new Error('没有获取到账户');
      }

      const userAddress = accounts[0];
      const userSigner = await state.provider.getSigner();
      const network = await state.provider.getNetwork();
      const userChainId = Number(network.chainId);

      localStorage.setItem('walletAddress', userAddress);
      localStorage.setItem('walletChainId', userChainId.toString());

      setState(prev => ({
        ...prev,
        address: userAddress,
        signer: userSigner,
        chainId: userChainId,
        isConnecting: false,
      }));
    } catch (err: any) {
      let errorMsg = '连接钱包失败';
      if (err.code === 4001) errorMsg = '用户拒绝了连接请求';
      else if (err.code === -32002) errorMsg = '请先处理MetaMask中的其他请求';
      else if (err.message) errorMsg = err.message;

      setState(prev => ({ ...prev, error: errorMsg, isConnecting: false }));
    }
  }, [state.provider]);

  // 断开连接
  const disconnect = useCallback(() => {
    localStorage.removeItem('walletAddress');
    localStorage.removeItem('walletChainId');
    localStorage.removeItem('authToken');
    setState(prev => ({
      ...prev,
      address: '',
      signer: null,
      chainId: 0,
      error: '',
    }));
  }, []);

  // 监听事件
  useEffect(() => {
    if (!window.ethereum) return;

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        disconnect();
      } else {
        setState(prev => ({ ...prev, address: accounts[0] }));
        localStorage.setItem('walletAddress', accounts[0]);
      }
    };

    const handleChainChanged = (chainIdHex: string) => {
      const newChainId = parseInt(chainIdHex, 16);
      setState(prev => ({ ...prev, chainId: newChainId }));
      localStorage.setItem('walletChainId', newChainId.toString());
      // 重新获取signer
      if (state.provider) {
        state.provider.getSigner().then(signer => {
          setState(prev => ({ ...prev, signer }));
        });
      }
    };

    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);

    return () => {
      window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum.removeListener('chainChanged', handleChainChanged);
    };
  }, [state.provider, disconnect]);

  // 签名消息
  const signMessage = useCallback(async (message: string): Promise<string> => {
    if (!state.signer) {
      throw new Error('请先连接钱包');
    }
    try {
      return await state.signer.signMessage(message);
    } catch (err: any) {
      if (err.code === 4001) throw new Error('用户取消了签名');
      throw new Error('签名失败: ' + err.message);
    }
  }, [state.signer]);

  return {
    ...state,
    connect,
    disconnect,
    signMessage,
  };
}

使用示例:

// App.tsx
import { useWallet } from './hooks/useWallet';

function App() {
  const { address, chainId, error, isConnecting, connect, disconnect, signMessage } = useWallet();

  const handleLogin = async () => {
    try {
      // 假设后端返回nonce
      const signature = await signMessage('登录nonce: 123456');
      // 发送signature到后端验证
      console.log('签名结果:', signature);
    } catch (err: any) {
      console.error(err.message);
    }
  };

  return (
    <div>
      {address ? (
        <div>
          <p>已连接: {address.slice(0, 6)}...{address.slice(-4)}</p>
          <p>链ID: {chainId}</p>
          <button onClick={handleLogin}>签名登录</button>
          <button onClick={disconnect}>断开连接</button>
        </div>
      ) : (
        <button onClick={connect} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接钱包'}
        </button>
      )}
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

踩坑记录:我实际遇到的4个问题

1. ethers.getDefaultProvider() 在浏览器端报错

  • 报错信息:Error: network error: The method eth_getBlockByNumber does not exist/is not available
  • 原因:getDefaultProvider() 会尝试连接以太坊节点,但在浏览器端,应该使用MetaMask注入的provider。
  • 解决:统一使用 new BrowserProvider(window.ethereum)

2. 用户拒绝连接后,再次点击连接按钮无反应

  • 现象:用户第一次点击"连接钱包"时取消了MetaMask弹窗,再次点击按钮,弹窗不出现了。
  • 原因:MetaMask检测到已有挂起的请求,返回错误码-32002
  • 解决:在catch中处理这个错误,提示用户"请先处理MetaMask中的其他请求",并建议用户刷新页面。

3. 签名时MetaMask弹窗不显示消息内容

  • 现象:调用signer.signMessage()时,MetaMask弹窗只显示"签名请求",没有显示具体的消息内容。
  • 原因:消息是纯字符串,没有格式化为可读的EIP-712类型数据。
  • 解决:对于简单登录,可以使用personal_sign方法,或者确保消息中包含可读的上下文(如"登录XXX")。

4. 链切换后,signer对象失效

  • 现象:用户在MetaMask中切换了网络,但调用signer.getAddress()时返回了旧地址。
  • 原因:signer是在连接时创建的,链变化后需要重新获取signer。
  • 解决:在chainChanged事件监听中,重新调用provider.getSigner()

小结

连接MetaMask实现钱包登录,核心就三点:正确处理用户授权、严格管理状态变化、做好异常处理。不要被"一行代码"的错觉迷惑,真正的坑都在细节里——比如错误码、链ID格式、事件监听的生命周期。

如果你继续深入,可以研究一下:

  • 使用wagmiRainbowKit等库简化钱包连接
  • 实现多链支持,让用户在不同链之间切换
  • 集成EIP-712类型数据签名,提升用户体验

希望这篇文章能帮你少踩一些坑。如果你在实现过程中遇到了其他问题,欢迎在评论区交流。

用 wagmi v2 和 Next.js 14 硬扛 NFT 市场前端:从合约调用失败到批量上架,我踩了这些坑

背景

上个月,团队接了一个 NFT 市场的前端单子,要求用 Next.js 14 的 App Router 搭,后端合约是 Solidity 写的,已经部署到 Sepolia 测试网。我的任务就是实现用户连接钱包、查看自己持有的 NFT、选择上架(挂单)、以及购买别人挂单的 NFT。

听起来很常规对吧?我当时也觉得,wagmi v2 都出了,RainbowKit 也有现成的组件,应该很快能搞定。结果我一头扎进去,整整两天时间都耗在了“合约调用失败”和“签名不通过”这两个坑里。后来发现,问题出在 wagmi v2 的 API 变化、EIP-712 签名的处理方式,以及 Next.js 服务端渲染时对 Web3 库的兼容性上。

这篇文章,我就把自己踩过的坑、用的笨办法、最终怎么跑通的,全部写出来。如果你也在用 wagmi v2 做 NFT 市场,或者准备上 Next.js 14,希望能帮你少走弯路。

问题分析

最初的思路

我一开始的想法很简单:用 RainbowKit 做钱包连接,用 wagmi 的 useWriteContract 直接调合约的 listItem 方法,把 NFT 上架。合约那边我已经拿到了 ABI,上架函数签名是 listItem(address nftAddress, uint256 tokenId, uint256 price)

代码大概长这样:

// 最初的错误写法
const { writeContract } = useWriteContract()

const handleListItem = async () => {
  writeContract({
    address: MARKETPLACE_ADDRESS,
    abi: marketplaceABI,
    functionName: 'listItem',
    args: [nftAddress, tokenId, price],
  })
}

看起来没问题吧?但点击按钮后,钱包弹出了 MetaMask 的交易确认,我点了确认,然后...交易一直 pending,最后直接 revert 了。

为什么行不通

我打开浏览器的控制台,发现 wagmi 抛了一个错误:

ContractFunctionExecutionError: The contract function "listItem" reverted with the following reason: "ERC721: transfer caller is not owner nor approved"

这个错误很经典:合约在执行 safeTransferFrom 的时候,发现调用者(也就是我当前的钱包地址)并没有被授权转移这个 NFT。我的合约里确实需要先 approve 市场合约,然后才能 listItem

但问题在于:wagmi v2 的 useWriteContract 默认会把当前连接的 account 作为 from 地址,而 listItem 内部会检查 msg.sender 是否拥有该 NFT 的转移权限。我虽然在前端调用了 approve,但因为交易是异步的,approve 还没被确认,我就立刻调了 listItem,导致合约认为我没有授权。

当时我就踩了这个坑:没有做交易等待和状态检查

排查过程

我花了半天时间,把交易流程拆成两步:

  1. 先调用 ERC721 的 approve 方法,授权市场合约管理这个 NFT。
  2. approve 交易被确认后,再调用市场合约的 listItem

但这样又带来一个新问题:用户需要签两次 MetaMask 交易,体验很差。而且如果用户在第一步授权后、第二步上架前刷新了页面,授权就白做了。

后来我想到可以用 useWaitForTransactionReceipt 来监听交易状态,但 wagmi v2 的 API 变了,useWaitForTransactionReceipt 返回的是 data 而不是 receipt。我一开始没看文档,直接按 v1 的写法来,结果一直拿不到交易哈希。

// 错误的写法(v1 风格)
const { data } = useWaitForTransactionReceipt({
  hash: txHash, // v2 里这个参数叫 hash,但返回结构变了
})

正确的做法是:

// wagmi v2 的正确写法
const { data: receipt, isLoading, isError } = useWaitForTransactionReceipt({
  hash: txHash,
})

receipt 才是交易收据对象,data 在 v2 里已经被废弃了。这个细节让我多花了两个小时。

核心实现

1. 搭建基本项目结构和钱包连接

我用的技术栈是 Next.js 14 + wagmi v2 + RainbowKit。首先创建项目:

npx create-next-app@latest nft-marketplace --typescript --tailwind --app
cd nft-marketplace
npm install wagmi viem @rainbow-me/rainbowkit

然后在 app/providers.tsx 里配置 wagmi 和 RainbowKit:

'use client'

import { WagmiProvider, createConfig, http } from 'wagmi'
import { sepolia } from 'wagmi/chains'
import { RainbowKitProvider, getDefaultConfig } from '@rainbow-me/rainbowkit'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import '@rainbow-me/rainbowkit/styles.css'

const config = getDefaultConfig({
  appName: 'NFT Marketplace',
  projectId: process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID!, // 需要去 WalletConnect 申请
  chains: [sepolia],
  transports: {
    [sepolia.id]: http('https://sepolia.infura.io/v3/YOUR_INFURA_KEY'),
  },
})

const queryClient = new QueryClient()

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  )
}

注意这个细节'use client' 是必须的,因为 wagmi 和 RainbowKit 都是客户端组件,不能在服务端渲染。Next.js 14 的 App Router 默认是服务端组件,所以必须用 'use client' 包裹。

然后在 app/layout.tsx 里引入:

import { Providers } from './providers'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

2. 实现 NFT 上架功能(含 EIP-712 签名)

我后来决定改用 EIP-712 离线签名 的方式来实现上架,这样用户只需要签一条消息(不需要 gas),然后由后端或者前端直接提交交易。这能避免前面说的“先授权再上架”的糟糕体验。

合约那边支持了 EIP-712 的 createListing 函数,接受一个签名和一个 Listing 结构体。前端需要构建 domaintypesvalue,然后用 wagmi 的 signTypedData 来签名。

这里有个坑:wagmi v2 的 signTypedData 返回的是 0x 开头的签名,而合约那边期望的是 bytes 类型。如果你直接用 signTypedData 的结果,合约会校验失败,因为签名格式不对。

我排查了半天,发现是因为 wagmi v2 默认使用了 viemsignTypedData,它返回的是 0x 前缀的 hex 字符串。但合约接收的是 bytes,在 Solidity 里 bytesstring 是不同的。正确的做法是:不要对签名做任何处理,直接传 0x 开头的字符串给合约,因为 viemencodeFunctionData 会自动把它转成 bytes

完整的上架逻辑如下:

// app/components/ListItem.tsx
'use client'

import { useAccount, useSignTypedData, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { useState } from 'react'
import { marketplaceABI, MARKETPLACE_ADDRESS } from '@/lib/contract'
import { parseEther } from 'viem'

export function ListItem({ nftAddress, tokenId }: { nftAddress: string; tokenId: string }) {
  const { address } = useAccount()
  const [price, setPrice] = useState('')
  const [txHash, setTxHash] = useState<`0x${string}` | undefined>(undefined)

  const { signTypedDataAsync } = useSignTypedData()
  const { writeContractAsync } = useWriteContract()

  // 监听交易确认
  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
    hash: txHash,
  })

  const handleList = async () => {
    if (!address || !price) return

    // 1. 构建 EIP-712 签名数据
    const domain = {
      name: 'NFTMarketplace',
      version: '1',
      chainId: 11155111, // Sepolia
      verifyingContract: MARKETPLACE_ADDRESS,
    }

    const types = {
      Listing: [
        { name: 'seller', type: 'address' },
        { name: 'nftAddress', type: 'address' },
        { name: 'tokenId', type: 'uint256' },
        { name: 'price', type: 'uint256' },
        { name: 'deadline', type: 'uint256' },
      ],
    }

    const deadline = Math.floor(Date.now() / 1000) + 3600 // 1小时后过期
    const value = {
      seller: address,
      nftAddress: nftAddress as `0x${string}`,
      tokenId: BigInt(tokenId),
      price: parseEther(price),
      deadline: BigInt(deadline),
    }

    // 2. 用户签名(不需要 gas)
    const signature = await signTypedDataAsync({
      domain,
      types,
      primaryType: 'Listing',
      message: value,
    })

    // 3. 调用合约的 createListing
    const hash = await writeContractAsync({
      address: MARKETPLACE_ADDRESS,
      abi: marketplaceABI,
      functionName: 'createListing',
      args: [value, signature],
    })

    setTxHash(hash)
  }

  return (
    <div>
      <input
        type="text"
        value={price}
        onChange={(e) => setPrice(e.target.value)}
        placeholder="输入价格 (ETH)"
      />
      <button onClick={handleList} disabled={isConfirming}>
        {isConfirming ? '上架中...' : '上架 NFT'}
      </button>
      {isSuccess && <p>上架成功!交易哈希:{txHash}</p>}
    </div>
  )
}

3. 实现购买 NFT 功能

购买逻辑相对简单,因为用户只需要调用市场合约的 buyItem 函数,并附上 ETH(如果合约要求支付)。但这里也有一个坑:wagmi v2 的 useWriteContract 默认不携带 value,如果你需要发送 ETH,必须显式设置 value 参数。

// app/components/BuyNFT.tsx
'use client'

import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { marketplaceABI, MARKETPLACE_ADDRESS } from '@/lib/contract'
import { parseEther } from 'viem'
import { useState } from 'react'

interface Listing {
  listingId: string
  price: string
  seller: string
}

export function BuyNFT({ listing }: { listing: Listing }) {
  const [txHash, setTxHash] = useState<`0x${string}` | undefined>(undefined)

  const { writeContractAsync } = useWriteContract()
  const { isLoading, isSuccess } = useWaitForTransactionReceipt({
    hash: txHash,
  })

  const handleBuy = async () => {
    const hash = await writeContractAsync({
      address: MARKETPLACE_ADDRESS,
      abi: marketplaceABI,
      functionName: 'buyItem',
      args: [BigInt(listing.listingId)],
      value: parseEther(listing.price), // 这里必须传 value
    })
    setTxHash(hash)
  }

  return (
    <div>
      <p>卖家:{listing.seller.slice(0, 6)}...{listing.seller.slice(-4)}</p>
      <p>价格:{listing.price} ETH</p>
      <button onClick={handleBuy} disabled={isLoading}>
        {isLoading ? '购买中...' : '立即购买'}
      </button>
      {isSuccess && <p>购买成功!</p>}
    </div>
  )
}

注意这个细节value 的单位是 wei,所以要用 parseEther 把 ETH 字符串转成 BigInt。如果你直接传字符串,合约会报错说 msg.value 不足。

4. 查询并展示所有挂单

为了展示市场上的 NFT 列表,我需要从合约读取事件或者调用 getAllListings 函数。这里我选择用 wagmi 的 useReadContract 来读取合约状态。

但有个问题:useReadContract 是同步的(在 React 里是异步的,但返回值是固定的),你不能在它返回之前做条件渲染。我一开始用 if (!data) return <Loading />,结果页面一直 loading,因为 useReadContract 在服务端渲染时会返回 undefined

正确的做法:用 isFetchingisFetched 来判断状态。

// app/components/Listings.tsx
'use client'

import { useReadContract } from 'wagmi'
import { marketplaceABI, MARKETPLACE_ADDRESS } from '@/lib/contract'
import { BuyNFT } from './BuyNFT'

interface Listing {
  listingId: bigint
  seller: string
  nftAddress: string
  tokenId: bigint
  price: bigint
  isActive: boolean
}

export function Listings() {
  const { data, isFetching, isFetched, error } = useReadContract({
    address: MARKETPLACE_ADDRESS,
    abi: marketplaceABI,
    functionName: 'getAllListings',
    args: [],
  })

  if (isFetching) return <p>加载中...</p>
  if (error) return <p>加载失败:{error.message}</p>
  if (!isFetched || !data) return <p>暂无数据</p>

  const listings = data as Listing[]

  return (
    <div>
      {listings
        .filter((l) => l.isActive)
        .map((listing) => (
          <div key={listing.listingId.toString()}>
            <p>NFT 地址:{listing.nftAddress}</p>
            <p>Token ID:{listing.tokenId.toString()}</p>
            <p>价格:{listing.price.toString()} wei</p>
            <BuyNFT
              listing={{
                listingId: listing.listingId.toString(),
                price: listing.price.toString(),
                seller: listing.seller,
              }}
            />
          </div>
        ))}
    </div>
  )
}

5. 处理批量上架

用户可能想一次上架多个 NFT,但合约只支持单个 createListing。我的方案是:Promise.all 并行签名,然后逐个提交交易。但这里要注意,signTypedDataAsync 每次调用都会弹 MetaMask 签名窗口,用户必须点很多次确认。

更好的做法:让用户只签一次,把多个 listing 打包成一个数组签名。但这需要合约支持批量签名,如果合约不支持,就只能用 Promise.allSettled 来处理部分失败的情况。

// 批量上架(逐个签名,逐个提交)
const handleBatchList = async (items: { nftAddress: string; tokenId: string; price: string }[]) => {
  const results = []
  for (const item of items) {
    try {
      const value = { seller: address, nftAddress: item.nftAddress, tokenId: BigInt(item.tokenId), price: parseEther(item.price), deadline: BigInt(deadline) }
      const signature = await signTypedDataAsync({ domain, types, primaryType: 'Listing', message: value })
      const hash = await writeContractAsync({ address: MARKETPLACE_ADDRESS, abi: marketplaceABI, functionName: 'createListing', args: [value, signature] })
      results.push({ tokenId: item.tokenId, hash, status: 'pending' })
    } catch (err) {
      results.push({ tokenId: item.tokenId, error: err, status: 'failed' })
    }
  }
  return results
}

这里有个坑:如果用户中途取消了签名,signTypedDataAsync 会抛出一个 UserRejectedRequestError,你必须用 try/catch 捕获,否则整个 Promise.allSettled 都会失败。

完整代码

由于篇幅限制,这里只给出核心组件的完整代码。完整的项目结构如下:

nft-marketplace/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   └── providers.tsx
├── components/
│   ├── ListItem.tsx
│   ├── BuyNFT.tsx
│   └── Listings.tsx
├── lib/
│   └── contract.ts
└── package.json

lib/contract.ts 内容:

import { Abi } from 'viem'

export const MARKETPLACE_ADDRESS = '0xYourMarketplaceContractAddress'

export const marketplaceABI: Abi = [
  // 这里放合约 ABI,我直接从 Hardhat 编译产物复制过来
  {
    type: 'function',
    name: 'createListing',
    inputs: [
      { name: 'listing', type: 'tuple', components: [
        { name: 'seller', type: 'address' },
        { name: 'nftAddress', type: 'address' },
        { name: 'tokenId', type: 'uint256' },
        { name: 'price', type: 'uint256' },
        { name: 'deadline', type: 'uint256' },
      ]},
      { name: 'signature', type: 'bytes' },
    ],
    outputs: [],
    stateMutability: 'nonpayable',
  },
  {
    type: 'function',
    name: 'buyItem',
    inputs: [{ name: 'listingId', type: 'uint256' }],
    outputs: [],
    stateMutability: 'payable',
  },
  {
    type: 'function',
    name: 'getAllListings',
    inputs: [],
    outputs: [{ name: '', type: 'tuple[]', components: [
      { name: 'listingId', type: 'uint256' },
      { name: 'seller', type: 'address' },
      { name: 'nftAddress', type: 'address' },
      { name: 'tokenId', type: 'uint256' },
      { name: 'price', type: 'uint256' },
      { name: 'isActive', type: 'bool' },
    ]}],
    stateMutability: 'view',
  },
] as const

踩坑记录

  1. wagmi v2 的 useWaitForTransactionReceipt 返回结构变了:v1 返回 { data: receipt },v2 返回 { data: receipt, ... },但 data 字段已废弃,应该用 receipt 变量。我一开始没看文档,直接写 data.transactionHash 报错。

  2. EIP-712 签名格式问题:wagmi v2 的 signTypedData 返回的签名是 0x 开头的 hex 字符串,合约接收 bytes 类型。最初我尝试用 viemhexToBytes 转换,结果合约校验失败。后来发现直接传 0x 字符串给合约的 bytes 参数即可,viem 内部会自动处理。

  3. Next.js 服务端渲染与 wagmi 不兼容:所有用到 wagmi hooks 的组件都必须加 'use client',否则会报 hooks can only be called inside a component 错误。我一开始没注意,把 useReadContract 放在了服务端组件里,导致页面直接白屏。

  4. useWriteContract 不携带 value:购买 NFT 时需要发送 ETH,但 useWriteContract 默认不传 value。我花了半小时排查为什么交易一直失败,最后发现合约要求 msg.value 等于价格,而我没传 value 参数。

  5. 用户取消签名时的错误处理signTypedDataAsync 如果用户取消,会抛出 UserRejectedRequestError,如果不捕获,整个流程会中断。我用 try/catch 捕获后,把失败项记录下来,让用户选择重试。

小结

这次踩坑的核心收获是:wagmi v2 的 API 变化很多,一定要看最新文档;EIP-712 签名要特别注意类型定义和格式;Next.js 14 的 App Router 强制要求所有客户端组件加 'use client'

如果你想继续深挖,可以研究一下 wagmi v2 的 useSimulateContract,它可以在调用前模拟交易,提前发现错误,避免用户浪费 gas。另外,批量签名和批量交易也是 NFT 市场常见的需求,可以看看合约是否支持 multicall

希望这篇文章能帮你少走一些弯路。如果你也在做 NFT 市场,欢迎留言交流。

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

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()一直返回空数组。

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值,签名结果就是原始格式。

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,然后手动设置。

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;

踩坑记录

  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支持,看看能不能进一步简化钱包集成。

被The Graph的GraphQL查询坑了三天,我用一个真实DeFi项目把链上数据索引彻底搞懂了

背景

上个月,我在给一个跨链DeFi协议做前端仪表盘。需求很简单:用户登录后,能看到他在以太坊、Polygon和Arbitrum三条链上的所有交易记录、当前质押的LP代币数量和历史收益。最开始我直接用ethers.js的provider.getLogsgetBalance去拉数据,结果发现三个问题:

  1. 每次切换链都要等5-10秒才能刷出数据,用户体验极差
  2. 用户交易一多(超过1000条),前端直接卡死,因为RPC节点一次只返回2000条日志,我得写递归去翻页
  3. 以太坊主网的RPC限流,免费Infura节点一分钟只能请求100次,用户多的时候直接报429

我当时想:不行,必须换个方案。正好团队后端同事提了一嘴The Graph,说可以自己搭子图来索引链上数据。我一开始以为就是把RPC换成GraphQL调用,结果一上手才发现水有多深——从子图定义、映射器写法到前端分页和实时更新,每个环节都有坑。

这篇文章就是我把整个流程走通后的完整记录,希望能帮到同样被链上数据查询折磨的你。

问题分析

最初的思路:直接用RPC + 前端缓存

我的第一版方案是:用ethers.js的getLogs拉取所有Transfer事件,然后在浏览器用localStorage缓存。但很快发现:

  • 以太坊主网一个地址可能有几千笔交易,getLogs一次最多返回2000条,我得写递归循环,每页等1-2秒
  • 跨链时,每个链的RPC节点不同,缓存逻辑要分开写,代码变得极其臃肿
  • 最致命的是:历史收益数据(比如用户某天质押了多少LP)需要聚合计算,RPC返回的是原始事件,我得在前端做大量运算,导致页面卡顿

为什么选择The Graph

The Graph本质上是把链上事件索引到PostgreSQL数据库里,然后通过GraphQL接口提供查询。好处是:

  • 索引完的数据查询速度在毫秒级,比RPC快10倍以上
  • 支持复杂过滤和聚合计算(比如按时间范围统计),这些运算在子图层面完成,前端只需拿结果
  • 有托管服务(Hosted Service)和去中心化网络,不需要自己维护服务器

但坏处是:需要写子图定义(schema.graphql)和映射器(mapping.ts),对前端开发者来说是个新领域。

核心实现:从子图搭建到前端查询

第一步:搭建本地子图开发环境

我第一次踩的坑就是直接在Hosted Service上部署,结果每次改映射器都要等10分钟同步。后来发现应该先在本地跑Graph Node。

# 安装Graph CLI
npm install -g @graphprotocol/graph-cli

# 初始化子图项目(选择以太坊主网)
graph init --product subgraph-studio
# 输入子图名称、合约地址等

初始化后生成的项目结构:

my-subgraph/
├── schema.graphql   # 定义数据模型
├── src/
│   └── mapping.ts   # 事件处理逻辑
├── subgraph.yaml    # 配置文件
└── package.json

这里有个坑graph init会让你选网络,如果选mainnet,它会自动用以太坊主网的RPC。但本地开发时最好用hardhatganache本地节点,或者用测试网。我当时选了mainnet,结果第一次同步花了3小时,因为要扫描整个链的历史事件。

最终方案:用graph init --product subgraph-studio后,手动修改subgraph.yaml里的dataSources.source.addressnetwork为测试网地址,比如Goerli。

第二步:定义数据模型(schema.graphql)

我的需求是记录用户的交易和质押信息。模型设计直接决定了查询效率。

# schema.graphql
type User @entity {
  id: ID!  # 用户地址
  transactions: [Transaction!] @derivedFrom(field: "user")
  totalStaked: BigInt!
  totalRewards: BigInt!
  lastUpdated: BigInt!
}

type Transaction @entity {
  id: ID!  # 交易哈希
  user: User!
  type: String!  # "deposit", "withdraw", "swap"
  amount: BigInt!
  token: Bytes!
  timestamp: BigInt!
  blockNumber: Int!
}

type DailyStats @entity {
  id: ID!  # 格式: "userAddress-dayTimestamp"
  user: User!
  date: BigInt!
  depositCount: Int!
  withdrawCount: Int!
  totalVolume: BigInt!
}

设计思路

  • User实体是核心,关联transactionsDailyStats
  • DailyStatsuserAddress-dayTimestamp作为ID,这样查询某用户某天的统计时直接get即可
  • 所有时间字段用BigInt存储(solidity的uint256),前端再转成Date

第三步:编写映射器(mapping.ts)

映射器是子图的核心,把链上事件转换成数据模型。这里我踩了一个大坑:映射器里不能做异步操作,比如不能用fetch请求外部API。

// src/mapping.ts
import { BigInt, Bytes } from "@graphprotocol/graph-ts"
import { 
  Transfer, 
  Deposit, 
  Withdraw 
} from "../generated/MyContract/MyContract"
import { User, Transaction, DailyStats } from "../generated/schema"

export function handleTransfer(event: Transfer): void {
  // 更新发送方和接收方的余额
  updateUserBalance(event.params.from, event.params.value.neg())
  updateUserBalance(event.params.to, event.params.value)
  
  // 记录交易
  let transaction = new Transaction(event.transaction.hash.toHex())
  transaction.user = event.params.from
  transaction.type = "transfer"
  transaction.amount = event.params.value
  transaction.token = event.address
  transaction.timestamp = event.block.timestamp
  transaction.blockNumber = event.block.number.toI32()
  transaction.save()
}

function updateUserBalance(address: Bytes, amount: BigInt): void {
  let userId = address.toHex()
  let user = User.load(userId)
  
  if (user == null) {
    user = new User(userId)
    user.totalStaked = BigInt.fromI32(0)
    user.totalRewards = BigInt.fromI32(0)
    user.lastUpdated = BigInt.fromI32(0)
  }
  
  user.totalStaked = user.totalStaked.plus(amount)
  user.lastUpdated = event.block.timestamp
  user.save()
}

这里有个坑User.load()User.save()在映射器里是同步的,但每次调用都会产生数据库读写。如果一笔交易涉及多个用户(比如Transfer事件有from和to),要避免重复加载同一个用户。我一开始没注意,导致同一个用户被加载两次,数据覆盖了。

解决办法:在handleTransfer里先检查两个地址是否相同,如果相同(自己转给自己),只更新一次。

第四步:部署子图并获取API URL

本地测试通过后,部署到The Graph的托管服务:

# 1. 在The Graph Studio创建子图
# 2. 获取部署密钥
graph auth --product subgraph-studio <YOUR_KEY>

# 3. 部署
graph deploy --product subgraph-studio <SUBGRAPH_NAME>

部署成功后,会得到一个API URL,类似: https://api.studio.thegraph.com/query/12345/my-subgraph/v0.0.1

注意:每次部署都会生成新版本,前端用的URL要更新版本号。我建议在环境变量里配置,方便切换。

第五步:前端接入Apollo Client

前端我用的是React + TypeScript + Apollo Client。这里有个关键点:Apollo默认的缓存策略会导致数据不更新,因为子图索引有延迟(通常几秒到几分钟)。

// graphql/queries.ts
import { gql } from "@apollo/client"

// 查询用户交易记录,支持分页
export const GET_USER_TRANSACTIONS = gql`
  query GetUserTransactions(
    $user: String!
    $first: Int!
    $skip: Int!
    $orderDirection: String!
  ) {
    transactions(
      where: { user: $user }
      first: $first
      skip: $skip
      orderBy: timestamp
      orderDirection: $orderDirection
    ) {
      id
      type
      amount
      token
      timestamp
      blockNumber
    }
  }
`

// 查询用户每日统计
export const GET_USER_DAILY_STATS = gql`
  query GetUserDailyStats(
    $user: String!
    $fromDate: BigInt!
    $toDate: BigInt!
  ) {
    dailyStats(
      where: { 
        user: $user
        date_gte: $fromDate
        date_lte: $toDate
      }
      orderBy: date
      orderDirection: asc
    ) {
      id
      date
      depositCount
      withdrawCount
      totalVolume
    }
  }
`
// hooks/useTransactions.ts
import { useQuery } from "@apollo/client"
import { GET_USER_TRANSACTIONS } from "../graphql/queries"

export function useTransactions(userAddress: string, page: number, pageSize: number = 20) {
  const { data, loading, error, refetch } = useQuery(GET_USER_TRANSACTIONS, {
    variables: {
      user: userAddress.toLowerCase(),  // 注意:地址必须小写!
      first: pageSize,
      skip: (page - 1) * pageSize,
      orderDirection: "desc"
    },
    // 关键:设置轮询,每30秒刷新一次,应对子图索引延迟
    pollInterval: 30000,
    // 关闭缓存,保证每次查询都从网络获取最新数据
    fetchPolicy: "network-only"
  })

  return {
    transactions: data?.transactions || [],
    loading,
    error,
    refetch
  }
}

这里有个坑:The Graph的查询中,地址字段必须是小写。如果用户输入的地址是大写或有校验和(EIP-55),查询会返回空结果。我一开始没做.toLowerCase(),debug了两小时才发现。

第六步:处理索引延迟和实时更新

子图索引不是实时的,通常有2-30秒的延迟。这意味着用户刚发起一笔交易,前端可能查不到。

我的解决方案是混合策略

  1. 对于历史数据(超过1分钟的交易),直接走The Graph查询
  2. 对于刚发生的交易(用户通过钱包确认后),先用ethers.js监听事件,等确认后再触发子图刷新
// hooks/useRealtimeTransactions.ts
import { useContractEvent } from "wagmi"
import { useTransactions } from "./useTransactions"

export function useRealtimeTransactions(userAddress: string) {
  const { transactions, loading, refetch } = useTransactions(userAddress, 1, 50)
  
  // 监听合约的Transfer事件
  useContractEvent({
    address: contractAddress,
    abi: contractABI,
    eventName: "Transfer",
    listener(from, to, value) {
      // 如果事件涉及当前用户,触发子图刷新
      if (from.toLowerCase() === userAddress.toLowerCase() || 
          to.toLowerCase() === userAddress.toLowerCase()) {
        // 延迟3秒,给子图索引留时间
        setTimeout(() => refetch(), 3000)
      }
    },
  })

  return { transactions, loading }
}

完整代码(可直接复制运行)

以下是一个完整的React组件,展示用户交易列表,支持分页和实时更新:

// components/TransactionList.tsx
import React, { useState } from "react"
import { useAccount } from "wagmi"
import { useRealtimeTransactions } from "../hooks/useRealtimeTransactions"
import { formatEther } from "ethers/lib/utils"

const PAGE_SIZE = 20

export function TransactionList() {
  const { address } = useAccount()
  const [page, setPage] = useState(1)
  
  const { transactions, loading } = useRealtimeTransactions(address || "")
  
  if (!address) return <p>请连接钱包</p>
  if (loading) return <p>加载中...</p>
  
  const totalPages = Math.ceil(transactions.length / PAGE_SIZE)
  const pageTransactions = transactions.slice(
    (page - 1) * PAGE_SIZE,
    page * PAGE_SIZE
  )

  return (
    <div>
      <h2>交易记录</h2>
      <table>
        <thead>
          <tr>
            <th>类型</th>
            <th>金额</th>
            <th>时间</th>
            <th>区块</th>
          </tr>
        </thead>
        <tbody>
          {pageTransactions.map((tx) => (
            <tr key={tx.id}>
              <td>{tx.type}</td>
              <td>{formatEther(tx.amount)}</td>
              <td>{new Date(tx.timestamp * 1000).toLocaleString()}</td>
              <td>{tx.blockNumber}</td>
            </tr>
          ))}
        </tbody>
      </table>
      
      <div>
        <button 
          disabled={page <= 1} 
          onClick={() => setPage(p => p - 1)}
        >
          上一页
        </button>
        <span>第 {page} / {totalPages} 页</span>
        <button 
          disabled={page >= totalPages} 
          onClick={() => setPage(p => p + 1)}
        >
          下一页
        </button>
      </div>
    </div>
  )
}

踩坑记录

坑1:子图部署后数据为0

现象:部署成功,GraphQL查询能返回实体结构,但所有数据都是空的。 原因:子图配置文件subgraph.yaml里的startBlock设置得太早,合约在那个区块还没部署。或者eventHandlers里的事件签名写错了。 解决:检查startBlock是否大于等于合约部署区块,用graph codegen重新生成类型,然后重新部署。

坑2:Apollo查询返回null但GraphQL Playground正常

现象:在The Graph Studio的Playground里查询正常,但前端Apollo返回null。 原因:Apollo的缓存策略。默认是cache-first,如果之前缓存过相同变量的查询,它不会重新请求网络。 解决:设置fetchPolicy: "network-only",或者每次查询时加一个随机变量(比如timestamp)来绕过缓存。

坑3:地址大小写导致查询失败

现象:用户输入0xAbC...,查询无结果。但Playground里用小写可以。 原因:The Graph的字符串比较是大小写敏感的,而以太坊地址的校验和格式(EIP-55)包含大小写。 解决:前端所有地址在传入查询前统一.toLowerCase()。子图映射器里存储地址时也要用小写。

坑4:映射器里循环调用save导致超时

现象:一笔交易涉及多个用户(比如批量转账),映射器执行超过50ms,子图索引报错。 原因:映射器有执行时间限制(AssemblyScript环境),循环里多次调用save()会累积时间。 解决:尽量合并写操作。比如批量转账事件,先收集所有用户更新,然后在事件处理函数最后一次性调用save()。或者用store.set()替代entity.save(),性能更好。

小结

用The Graph做链上数据查询,核心是把计算压力从前端转移到索引层。子图的schema设计要围绕查询场景来,不要试图把所有数据都塞进去。如果你需要更实时的数据(秒级),可以考虑结合ethers.js的事件监听做混合方案。下一步可以研究如何用The Graph的去中心化网络(Decentralized Network)替换托管服务,避免单点故障。

wagmi v2 多链钱包切换:一个 Uniswap 仿盘项目让我踩了三天坑

背景

上个月,我接手了一个"Uniswap 精简版"项目——一个支持 Ethereum、Polygon、Arbitrum 三条链的 DEX 前端。项目用 wagmi v2 + RainbowKit 做钱包连接,React + Vite 开发。需求听起来很简单:用户连接钱包后,能选择任意一条链进行交易,并且钱包会自动切换到对应链。

我当时想,wagmi 不是有 useSwitchChainuseAccount 吗?直接调用就完事了。结果呢?我花了整整三天,经历了无数个"为什么钱包没反应"、"为什么链没切换但页面状态变了"的抓狂时刻。这篇文章,就是把我踩过的坑和最终的解决方案完整记录下来。

问题分析

一开始,我的思路很直接:用 useAccount 获取当前链 ID,用 useSwitchChain 切换链。代码大概长这样:

// 我最初的错误写法
const { chain } = useAccount();
const { switchChain } = useSwitchChain();

const handleChainChange = (targetChainId: number) => {
  if (chain?.id !== targetChainId) {
    switchChain({ chainId: targetChainId });
  }
};

看起来没问题对吧?但实际运行时,问题来了:

问题 1: 在 MetaMask 上切换链后,useAccount 返回的 chain 更新了,但 UI 上的交易对信息没有更新。我明明用了 useEffect 监听 chain 变化,但页面就是不刷新。

问题 2: 切换到一条不支持的链(比如用户自己添加了 BSC)时,useSwitchChain 会报错,但错误信息非常不友好,而且 chain 状态会被污染。

问题 3: 最诡异的是——当用户手动在钱包里切换链,而不是通过我写的按钮切换时,useSwitchChain 根本不会触发,但 useAccountchain 变了。这就导致我的代码里有两套"当前链":一套来自按钮操作,一套来自钱包事件,它们经常不同步。

排查了两天,我翻遍了 wagmi 的文档和 GitHub Issues,终于发现了关键点:wagmi v2 中 useAccountchain 是只读的,它只反映钱包当前连接的链,不会触发 React 组件的重新渲染(至少在特定场景下)。而 useSwitchChain 返回的 isSuccess 状态才是可靠的切换完成标志。

核心实现

1. 重新理解 wagmi v2 的状态管理

我做的第一件事,是抛弃了"用 useAccount 驱动 UI"的思维。wagmi v2 推荐的做法是:useChainId 获取当前链 ID,用 useSwitchChain 处理切换,用 useEffect 监听切换完成事件

这里有个坑:useChainId 返回的是 wagmi 配置中的当前链 ID,而不是钱包实际连接的链 ID。如果用户手动在钱包里切换,useChainId 不会自动更新!所以,我最终决定自己维护一个"同步的链状态"。

我创建了一个自定义 hook useSyncedChain

// hooks/useSyncedChain.ts
import { useChainId, useSwitchChain, useAccount, usePublicClient } from 'wagmi';
import { useEffect, useState, useCallback } from 'react';

export function useSyncedChain() {
  // 从 wagmi 获取基础状态
  const configChainId = useChainId(); // wagmi 配置中的链 ID
  const { chain: accountChain, isConnected } = useAccount(); // 钱包实际连接的链
  const { switchChain, isPending, error } = useSwitchChain();
  const publicClient = usePublicClient(); // 用来做链验证

  // 我们自己的"权威"链 ID
  const [activeChainId, setActiveChainId] = useState<number>(configChainId);

  // 核心逻辑:同步钱包状态和配置状态
  useEffect(() => {
    if (!isConnected || !accountChain) {
      // 未连接时,使用配置默认链
      setActiveChainId(configChainId);
      return;
    }

    // 如果钱包连接的链和配置链不同,说明用户手动切换了
    if (accountChain.id !== configChainId) {
      // 这里有个坑:不要直接 setActiveChainId,因为配置链可能不支持
      // 应该检查 accountChain 是否在我们支持的链列表中
      const supportedChains = [1, 137, 42161]; // Ethereum, Polygon, Arbitrum
      if (supportedChains.includes(accountChain.id)) {
        setActiveChainId(accountChain.id);
      } else {
        // 不支持的话,尝试切回默认链
        switchChain({ chainId: configChainId });
      }
    } else {
      setActiveChainId(configChainId);
    }
  }, [configChainId, accountChain, isConnected, switchChain]);

  // 封装的切换函数
  const switchToChain = useCallback(async (targetChainId: number) => {
    try {
      await switchChain({ chainId: targetChainId });
      // switchChain 成功后,wagmi 会自动更新 configChainId
      // 但为了保险,我们手动更新
      setActiveChainId(targetChainId);
    } catch (err) {
      console.error('切换链失败:', err);
      throw err;
    }
  }, [switchChain]);

  return {
    activeChainId,
    switchToChain,
    isSwitching: isPending,
    error,
  };
}

这个 hook 的核心思路是:不要信任任何一个单一来源,而是用钱包状态、配置状态、用户操作事件三者做交叉验证

2. 处理链切换后的数据刷新

链切换后,我们需要重新获取交易对数据、用户余额等。一开始我用 useEffect 监听 activeChainId,但发现会触发两次:一次是状态更新,一次是钱包实际切换完成。

后来我用了 wagmi 的 useWatchChainId 来做精细控制:

// hooks/useChainDataRefresh.ts
import { useEffect, useRef } from 'react';
import { useChainId } from 'wagmi';

export function useChainDataRefresh(callback: (chainId: number) => void) {
  const chainId = useChainId();
  const prevChainIdRef = useRef(chainId);

  useEffect(() => {
    // 只在链真正变化时触发,避免初始化时的重复调用
    if (prevChainIdRef.current !== chainId) {
      console.log(`链已切换: ${prevChainIdRef.current} -> ${chainId}`);
      callback(chainId);
      prevChainIdRef.current = chainId;
    }
  }, [chainId, callback]);
}

然后在组件中使用:

// 在 Swap 组件中
const { activeChainId, switchToChain, isSwitching } = useSyncedChain();
const { data: pairData, refetch: refetchPair } = useQuery({
  queryKey: ['pair', activeChainId, tokenA, tokenB],
  queryFn: () => fetchPairData(activeChainId, tokenA, tokenB),
  enabled: !!activeChainId && !!tokenA && !!tokenB,
});

useChainDataRefresh((newChainId) => {
  // 链切换后,重新获取数据
  refetchPair();
  // 同时重置用户输入状态
  setTokenA('');
  setTokenB('');
});

3. 处理钱包手动切换和 UI 同步

最头疼的是用户手动在 MetaMask 里切换链。wagmi v2 的 useAccount 会更新,但 useChainId 不会。我之前的 useSyncedChain hook 已经通过 accountChain 处理了这种情况,但还有一个细节:切换完成后,需要等待钱包确认,期间 UI 应该显示加载状态

我添加了一个"切换中"的状态管理:

// 在 useSyncedChain 中增加 pendingChainId
const [pendingChainId, setPendingChainId] = useState<number | null>(null);

const switchToChain = useCallback(async (targetChainId: number) => {
  setPendingChainId(targetChainId);
  try {
    await switchChain({ chainId: targetChainId });
    setPendingChainId(null);
    setActiveChainId(targetChainId);
  } catch (err) {
    setPendingChainId(null);
    throw err;
  }
}, [switchChain]);

// 在 UI 中显示加载
const isLoading = isSwitching || pendingChainId !== null;

4. 最终的多链切换组件

把所有逻辑整合到一个组件中:

// components/ChainSwitcher.tsx
import { useSyncedChain } from '../hooks/useSyncedChain';
import { useChainDataRefresh } from '../hooks/useChainDataRefresh';
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';

const SUPPORTED_CHAINS = [
  { id: 1, name: 'Ethereum', nativeCurrency: 'ETH' },
  { id: 137, name: 'Polygon', nativeCurrency: 'MATIC' },
  { id: 42161, name: 'Arbitrum', nativeCurrency: 'ETH' },
];

export function ChainSwitcher() {
  const { activeChainId, switchToChain, isSwitching, error } = useSyncedChain();

  // 链切换后刷新数据
  useChainDataRefresh((chainId) => {
    console.log('链已切换,刷新数据');
    // 这里可以触发其他数据获取
  });

  const handleChainClick = async (chainId: number) => {
    if (chainId === activeChainId) return;
    try {
      await switchToChain(chainId);
      // 切换成功后,UI 会自动更新,因为 activeChainId 变了
    } catch (err) {
      // 显示错误 toast
      alert(`切换失败: ${(err as Error).message}`);
    }
  };

  return (
    <div>
      <h2>选择链</h2>
      {SUPPORTED_CHAINS.map((chain) => (
        <button
          key={chain.id}
          onClick={() => handleChainClick(chain.id)}
          disabled={isSwitching}
          style={{
            fontWeight: chain.id === activeChainId ? 'bold' : 'normal',
            opacity: isSwitching ? 0.5 : 1,
          }}
        >
          {chain.name} ({chain.nativeCurrency})
          {isSwitching && ' 切换中...'}
        </button>
      ))}
      {error && <p style={{ color: 'red' }}>错误: {error.message}</p>}
    </div>
  );
}

完整代码

我把所有代码整合到一个可运行的示例中。假设你使用 Vite + React + TypeScript,安装依赖:

npm install wagmi viem @tanstack/react-query react
// main.tsx - 入口文件
import { WagmiProvider, createConfig, http } from 'wagmi';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RainbowKitProvider, getDefaultConfig } from '@rainbow-me/rainbowkit';
import { ChainSwitcher } from './components/ChainSwitcher';

const config = createConfig({
  chains: [mainnet, polygon, arbitrum],
  transports: {
    [mainnet.id]: http(),
    [polygon.id]: http(),
    [arbitrum.id]: http(),
  },
});

const queryClient = new QueryClient();

function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          <ChainSwitcher />
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

export default App;
// hooks/useSyncedChain.ts - 上面已给出完整代码
// hooks/useChainDataRefresh.ts - 上面已给出完整代码
// components/ChainSwitcher.tsx - 上面已给出完整代码

踩坑记录

坑 1:useAccountchain 在切换后不会立即更新 现象:调用 switchChain 后,useAccount 返回的 chain 还是旧的,导致 UI 显示错误。解决:用 useChainId 配合 useEffect 监听,而不是依赖 useAccountchain

坑 2:useSwitchChainisSuccess 有时为 false 现象:钱包已经切换成功,但 isSuccess 一直是 false。原因:wagmi v2 中 isSuccess 只在第一次成功时为 true,后续切换不会重置。解决:用 errorisPending 做判断,或者自己维护状态。

坑 3:在非浏览器环境(如测试时)调用 switchChain 会报错 现象:在 Node.js 或 React Native 中,window.ethereum 不存在,导致切换失败。解决:用 try-catch 包裹,并在错误时回退到配置默认链。

坑 4:链切换后,之前订阅的事件没有清理 现象:切换到 Polygon 后,Ethereum 上的事件监听还在运行,导致内存泄漏。解决:在 useEffect 中返回清理函数,或者用 wagmi 的 watchContractEvent 自动管理。

小结

多链切换的核心不是调用 switchChain,而是同步钱包状态、配置状态和用户操作状态。wagmi v2 提供了基础工具,但需要自己组合成可靠的解决方案。如果你也遇到类似问题,可以试试我写的 useSyncedChain hook,或者深入看看 wagmi 的源码——里面有很多有趣的细节。

接下来,你可以探索如何用 wagmi 的 watchChainId 做更精细的控制,或者结合 viem 的 publicClient 做链验证。

❌