背景
上个月我接了个外包项目,做一个基于 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 })
}
}
为什么行不通
- 用户要签 n 次 MetaMask :每调用一次
writeContract,钱包弹一次签名,用户体验极其糟糕。 - 单点失败问题:如果第 3 个 NFT 上架失败,前面的已经上架了,后面的没法继续,状态不一致。
- 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],
})
})
}
这里有个坑 :encodeFunctionData 的 args 参数必须和合约函数的参数顺序完全一致。我当时把 nftAddress 和 id 的顺序搞反了,结果合约一直报错,排查了半天才发现。
第二步:发送 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>
)
}
踩坑记录
-
encodeFunctionData参数顺序错误 :我把nftAddress和tokenId的顺序写反了,导致合约报错revert。后来用console.log打印 calldata 对比合约 ABI 才找到问题。 -
waitForTransactionReceipt超时 :默认 30 秒超时,在以太坊主网拥堵时经常超时。需要设置timeout参数,我改成了 120 秒。 -
用户拒绝签名的处理 :
writeContract如果用户取消签名,会抛出一个错误,但错误对象的格式在不同钱包不同。MetaMask 返回{ code: 4001, message: 'User rejected' },而 WalletConnect 返回的格式不一样。我用了error.message?.includes('User rejected')来兼容。 -
Gas 估算失败:当批量上架的 NFT 数量太多时,Gas 估算可能失败。我加了 try-catch,如果估算失败就提示用户手动设置 Gas。
小结
核心收获:批量操作 Web3 交易时,优先考虑合约层面的 multicall 模式,前端只需要用 encodeFunctionData 构造 calldata 数组。如果合约不支持,可以和合约开发者沟通添加。另外,处理异步交易一定要考虑超时、用户拒绝、网络异常等边界情况。
可以继续深挖的方向:如何用 useSimulateContract 在发送前模拟交易,以及如何用 usePublicClient 替代手动创建 client。