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

背景

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

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

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

问题分析

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

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

typescript 复制代码
// 最初的错误写法
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。

typescript 复制代码
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 发送交易。

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

typescript 复制代码
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 参数。

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

第四步:解析 multicall 的返回值

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

typescript 复制代码
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: '解析失败',
      }
    }
  })
}

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

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

typescript 复制代码
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 的功能。

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

相关推荐
zithern_juejin1 小时前
数组扁平化
javascript
清溪5491 小时前
n8n表达式沙箱逃逸至RCE漏洞-CVE-2025-68613复现
javascript·安全
Hilaku1 小时前
多标签页并发请求导致 Token 刷新失败?只有 15行代码就能解决 !
前端·javascript·程序员
烛衔溟1 小时前
TypeScript 类的静态成员与静态方法
开发语言·javascript·typescript
Nile1 小时前
解密Palantir系列一:4. Ontology 不是哲学
开发语言·前端·javascript
Highcharts2 小时前
如何创建蛛网地图|气泡事件+全球发布+关联组合图表开发示例
javascript
xier1234562 小时前
three-instance-batch 开发笔记
javascript·three.js
王林不想说话2 小时前
TypeScript 进阶知识总结:从 extends、泛型到 infer,一篇打通 TS 类型系统
前端·javascript·typescript
罗超驿2 小时前
15.JavaScript 函数与作用域完全指南:语法、参数、表达式与作用域链实战
开发语言·前端·javascript