npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@w3lib/evm-batch-call

v0.8.0

Published

Batched EVM/Tron contract reads via Multicall3 with deployless fallback, native balances, RPC failover, auto-batching and staticCall simulation; batch transfer, collect and contract calls (Disperse / EIP-7702). ethcall compatible.

Downloads

1,265

Readme

@w3lib/evm-batch-call

基于 Multicall3 的 EVM / Tron 批量合约读取与交易预执行库,依赖 ethers v6,API 兼容 ethcall。另有批量写操作(批量转账、归集、批量调用合约)。

pnpm add @w3lib/evm-batch-call ethers

在线 demo:https://web3-lib.github.io/libs/evm/(余额、代币信息、资产列表、LP 仓位、添加 / 移除流动性(报价,连接浏览器钱包后可以发送)、NFT、价格 / 资产总值、授权(批量查询 + 撤销)、域名(ENS / SPACE ID / Basenames 正反向解析)、手续费估算、兑换(报价,连接浏览器钱包后可以发送,可填转账税)、代币风险(买卖税 / 貔貅检查),8 条链含 Tron,显示每次查询实际发出的节点请求数)。本地运行:pnpm --filter @w3lib/evm-batch-call demo(http://localhost:5174)

快速上手

常用查询

常用的批量查询都有现成函数,一次请求查完一批,结果都是字符串 / 数字 / 布尔,可以直接 JSON.stringify:

import { NATIVE_TOKEN, getAllowances, getBalances, getTokens } from '@w3lib/evm-batch-call'
import { getErc1155Balances, getNftBalances, getNftCollections, getNftOwners, getNftTokenUris } from '@w3lib/evm-batch-call/nft' // NFT 在子路径

// 余额:主币 + 代币,带 decimals 换算
await getBalances(user, [NATIVE_TOKEN, USDT], { chainId: 56 })
// [{ token: NATIVE_TOKEN, native: true, balance: '1500000000000000000', decimals: 18, formatted: '1.5', success: true }, ...]
await getBalances(user, [NATIVE_TOKEN, USDT], { chainId: 56, symbol: true }) // 额外返回 symbol
await getBalances(user, [BAYC, USDT], { chainId: 1, decimals: false })       // 只查余额,不查 decimals(没有 decimals 的合约也能查)
// [{ token: BAYC, native: false, balance: '128', success: true }, ...]

// 代币详情,字段可选:name / symbol / decimals / totalSupply(默认前三个)
await getTokens([USDT, NATIVE_TOKEN], { chainId: 56, fields: ['symbol', 'decimals', 'totalSupply'] })
// [{ address: USDT, native: false, symbol: 'USDT', decimals: 18, totalSupply: '…', totalSupplyFormatted: '…', success: true }, ...]

// 授权额度:发交易前判断是否需要 approve
const [allowance] = await getAllowances(user, router, [USDT], { chainId: 56 })
// { token: USDT, spender: router, allowance: '…', formatted: '100', unlimited: false, success: true }

// NFT
await getNftCollections([BAYC], { chainId: 1, fields: ['standard', 'name', 'totalSupply'] }) // standard 通过 ERC165 识别
await getNftBalances(user, [BAYC, MAYC], { chainId: 1 })                                     // ERC721 持有数量
await getNftOwners([{ contract: BAYC, tokenId: 1 }], { chainId: 1 })                        // ERC721 持有人
await getNftTokenUris([{ contract: BAYC, tokenId: 1 }], { chainId: 1, ipfsGateway: 'https://ipfs.io/ipfs/' })
await getErc1155Balances(user, [{ contract: ITEMS, tokenId: 7 }], { chainId: 137 })         // ERC1155 余额

| 函数 | 传 Provider 实例的写法 | 说明 | | --- | --- | --- | | getBalances(owner, tokens, opts) | multi.balances(owner, tokens, opts) | 主币 + 代币余额;symbol: true 额外返回 symbol;decimals: false 只查余额 | | getTokens(tokens, opts) | multi.tokens(tokens, opts) | ERC20 详情,fields 选择返回字段,结果类型随之收窄 | | getAllowances(owner, spender, tokens, opts) | multi.allowances(...) | 授权额度;unlimited 表示额度 ≥ uint96 最大值(覆盖 MaxUint256 及 UNI / COMP 这类截断为 uint96 的代币);decimals: false 只查额度 | | getNftCollections(contracts, opts) | nftCollections(multi, contracts, opts) | 子路径 /nft。NFT 集合 standard / name / symbol / totalSupply,fields 可选 | | getNftBalances(owner, contracts, opts) | nftBalances(multi, owner, contracts) | /nft。ERC721 持有数量 | | getNftOwners(items, opts) | nftOwners(multi, items) | /nft。ERC721 持有人,可混合多个集合;不存在的 tokenId 为 null;Tron 链上返回 T 开头的地址 | | getNftTokenUris(items, opts) | nftTokenUris(multi, items, opts) | /nft。元数据地址:自动兼容 ERC721 tokenURI 与 ERC1155 uri({id} 按规范替换),ipfsGateway 转换 ipfs://;首次查询顺带识别集合标准并缓存,之后只发对应的调用 | | getErc1155Balances(owner, items, opts) | erc1155Balances(multi, owner, items) | /nft。ERC1155 余额 | | getOwnerTokens(owner, opts) | ownerTokens(multi, owner, opts) | 子路径 /owner。资产列表:地址持有的代币(不推荐使用,结果不保证完整,见资产列表) | | getLpPositions(owner, opts) | lpPositions(multi, owner, opts) | 子路径 /lp。LP 仓位:V2 的 LP 代币、V3 / V4 的仓位 NFT,代币数量、未领取手续费、可选美元价值,见 LP 仓位 | | | addLiquidity / removeLiquidity / collectFees(build* 只构建不发送) | 子路径 /liquidity。添加 / 移除流动性、领取手续费,见添加 / 移除流动性 |

子路径:NFT 查询在 @w3lib/evm-batch-call/nft,资产列表(含代币来源、价格源、Transfer 增量扫描)在 @w3lib/evm-batch-call/owner,LP 仓位在 @w3lib/evm-batch-call/lp,添加 / 移除流动性在 @w3lib/evm-batch-call/liquidity,批量写操作在 @w3lib/evm-batch-call/write。不用这些功能时打包不会带上它们的代码(拆出后约少 15KB:只用 getBalances 时 minify 后约 50KB、gzip 约 17KB,均不含 ethers)。

共同的规则:

  • 主币:0xeeee…eeee(导出为 NATIVE_TOKEN,不区分大小写)按主币处理,不发合约调用;name / symbol / 精度来自内置链信息表(NATIVE_CURRENCIES),可用 nativeTokens / nativeName / nativeSymbol / nativeDecimals 配置
  • ⚠ 零地址 0x0000…0000 默认也按主币处理(返回主币余额,不会去调零地址上的 balanceOf)。如果你的数据里零地址代表别的含义,传 nativeTokens: [NATIVE_TOKEN] 只保留 0xeeee…eeee
  • 主币余额的读取方式:默认和合约调用放在同一次 eth_call 里。有的链在合约里读主币余额恒为 0、不报错(如 Anubis),内置表 NATIVE_BALANCE_MODES 已为这类链改用 eth_getBalance;其他链可用 nativeBalance: 'rpc' 或 { erc20: '0x…' } 配置,见主币余额与 deployless
  • 缓存:查过一次后按链缓存(各函数共用),之后只查会变的数据。decimals / NFT 标准永久缓存;name / symbol 在可升级合约上可能改,各自缓存 1 小时;最多 5 万个代币,超出淘汰最早的(与 solana-batch-call 一致)。已知精度可以直接传 { address, decimals }(主币也适用,优先于内置配置)。缓存可以持久化,见缓存持久化
  • 失败:单项失败(非合约地址、非法地址、不存在的 tokenId 等)不影响其他项,该项 success: false;整个请求失败(节点不可用等)才会抛错。getBalances / getTokens / getAllowances 的失败项还带 error(invalid-address 地址非法、invalid-argument 其他参数编码失败、no-contract 地址上没有合约、reverted 执行 revert、decode-failed 返回值无法解码、not-configured 没发请求的信息缺失,如主币不在内置链信息表里)和 errorField(哪一项没读到,如 balance / decimals),可以按原因分别处理(如地址非法报错、没有合约按 0)
  • 取消:所有查询都可以传 signal(AbortController):已取消时不发请求;查询中取消时立即 reject,合并在一起的底层请求继续完成、结果丢弃。切换代币时不必再手动比对、丢弃旧结果
  • 只查余额:decimals: false 时不查 decimals,结果不带 decimals / formatted(类型随之变化)。适合没有 decimals 的合约(ERC721 的 balanceOf)、或只需要原始数值的场景,冷启动时子调用数减半
  • 数值:原始数值是最小单位的十进制字符串,需要计算时用 BigInt(x);formatted 保留全部有效小数位、不四舍五入,整数不带小数点(单独格式化可用 formatUnits(value, decimals);旧名 formatAmount 仍可用,已标记 deprecated)
  • 老代币:symbol / name 返回 bytes32 的代币(MKR 等)也能解析

多个钱包、轮询、刚成交后刷新

import { getMultiBalances, watchBalances } from '@w3lib/evm-batch-call'

// 多个钱包一次查:同一条链上合成一次 multicall
const [a, b] = await getMultiBalances([{ owner: walletA, tokens: [NATIVE_TOKEN, USDT] }, { owner: walletB, tokens: [USDT] }], { chainId: 56 })

// 轮询:多处订阅同一个钱包的同一组代币时合并成一份轮询(代币顺序无关),只在余额变化时通知;返回取消订阅的函数
const stop = watchBalances(user, [NATIVE_TOKEN, USDT], { chainId: 56, interval: 10_000, onChange: (list, previous) => render(list) })

// 交易刚确认:要求读到的数据不早于交易所在区块(公共节点可能还落后一两个块)
const receipt = await tx.wait()
await getBalances(user, tokens, { chainId: 56, minBlock: receipt.blockNumber })
await getBalances(user, tokens, { chainId: 56, withBlock: true }) // 每项带 blockNumber:读取时的区块号
  • withBlock:区块号与数据在同一次 eth_call 里读出(Multicall3 的 getBlockNumber;deployless 合约里直接取 block.number),不多发请求。balances / tokens / allowances 及对应函数都支持
  • minBlock:先按最新状态查并顺带读区块号,节点落后(结果的区块号早于 minBlock)时当场换下一个节点,跟上的节点直接给出最新状态;所有节点都落后时按 minBlock 指定区块重查——落后的节点对未来区块报错或返回较旧区块,同样换节点(落后不算故障,不进入冷却);仍然都落后时每秒重试,最多约 10 秒后抛错,传了 signal 时取消后立即停止。重查得到的是 minBlock 那个区块的状态(已包含刚确认的交易)。只对最新状态的查询生效(指定了 blockTag 时忽略)。Tron 节点只能查最新状态,落后时等节点跟上再查。nativeBalance: 'rpc' 时主币余额在批量查询实际读取的区块上用 eth_getBalance 读取,与代币余额、blockNumber 一致
  • watchBalances:相同的节点参数 + 钱包 + 代币 + 选项共用一份轮询(多处订阅时取最小的 interval),新订阅者立即拿到最近一次的结果;查询失败调用 onError,轮询继续;上一次查询没完成时不会重叠发起;最后一个订阅者取消后停止

节点参数

以上函数的节点参数相同,chainId 和 provider 至少传一个:

await getBalances(user, tokens, { chainId: 56 })                                   // 只传 chainId:使用内置公共节点
await getBalances(user, tokens, { provider: 'https://bsc-dataseed.bnbchain.org' }) // 只传节点:chainId 自动识别
await getBalances(user, tokens, { provider: ['https://rpc-1.example', 'https://rpc-2.example'] }) // 主节点 + 备用节点
await getBalances(user, tokens, { provider: window.ethereum })                     // EVM 钱包
await getBalances(user, tokens, { provider: window.tronWeb })                      // Tron 钱包
await getBalances(user, tokens, { chainId: 56, provider: [window.ethereum, 'https://bsc-dataseed.bnbchain.org'] }) // 钱包优先,失败用公共节点
await getBalances(user, tokens, { chainId: 56, blockTag: 'pending' })              // 指定区块
  • chainId 自动识别:EVM 节点 / 钱包用 eth_chainId,ethers Provider 用 getNetwork(),Tron 读创世区块哈希的最后 4 字节(与 TronGrid 的 eth_chainId 一致);只传 URL 时先按 EVM 识别,失败再按 Tron 识别,所以 Tron 节点 URL 也不用传 chainId
  • 识别结果缓存:同时发起的识别共用一个请求,失败或超时不缓存(下次重试)。URL 按地址缓存;tronWeb 按当前连接的节点地址缓存(TronLink 切网络后自动重新识别);EIP-1193 钱包按对象缓存并监听 chainChanged,切链时失效,不支持事件监听的钱包不缓存。clearChainIdCache() 可手动清空
  • 链校验:传入的节点会确认它实际所在的链与 chainId 一致,不一致时报 NETWORK_ERROR,多节点时自动换下一个节点。所以 [钱包, 公共节点] 这类组合、配错了节点 URL、或用户自己创建的 BrowserProvider 在别的链上,都不会把另一条链的数据当成这条链的返回。校验通过已配置好的节点连接进行(带 Tron apiKey 等),与实际请求并行发出、不增加延迟,结果缓存,每个节点只多一次请求;内置公共节点不校验
  • Provider 复用:同一个 chainId + 配置 + 节点多次调用会复用同一个 Provider(共享连接和合并队列,最多缓存 32 组,超出淘汰最早的);[window.ethereum, ...urls] 这类钱包 + URL 混合列表按“钱包对象 + URL”复用。只传钱包(没传 chainId)时每次按钱包当前的链创建,切链后自动按新链查询。chainId 为 null / '' 时视为未传
  • 直接传 Provider 实例:{ provider: multi } 时直接用这个实例(chainId 和配置以实例为准,同时再传会报错),与其他直接调用 multi 的代码共享合并队列
  • 其余 Provider 配置(fallback、nativeSymbol 等,见配置)也可以直接放进这个参数

合约调用

import { Provider } from '@w3lib/evm-batch-call'

const multi = new Provider(56)                 // 只传 chainId:使用内置公共节点(多节点自动故障切换)
// const multi = await Provider.create(window.ethereum) // 只传节点:等 chainId 识别完成后返回(也可以 new Provider(window.ethereum),首次调用时识别)
// const multi = new Provider(56, rpc, config) // 都传:与 ethcall 相同

const usdt = multi.erc20('0x55d398326f99059fF775485246999027B3197955')

// 直接 await;同一 tick 内发起的调用自动合并成一次 multicall
const [symbol, balance, bnb] = await Promise.all([
  usdt.symbol(),
  usdt.balanceOf(user),
  multi.getEthBalance(user), // 主币余额也在同一次请求里
])

// 对象形式:结果是同名字段,类型自动推断
const { decimals, allowance } = await multi.all({
  decimals: usdt.decimals(),
  allowance: usdt.allowance(user, spender),
})

// 常用查询都有对应的 Provider 方法
const list = await multi.balances(user, [NATIVE_TOKEN, USDT], { symbol: true })
const infos = await multi.tokens([USDT, BUSD], { fields: ['symbol', 'decimals'] })

// 任意合约
const pair = multi.contract(pairAddress, [
  'function getReserves() view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)',
  'function token0() view returns (address)',
])
const [reserves, token0] = await Promise.all([pair.getReserves(), pair.token0()])
reserves.reserve0 // 多返回值可按名字或下标取

// 预执行(模拟交易),写法同 ethers
const router = multi.contract(routerAddress, RouterABI)
const amountOut = await router.swap.staticCall(params, { value, from: user })

资产列表

不推荐使用(已标记 @deprecated):getOwnerTokens / ownerTokens 只能尽力而为,结果不保证完整、准确,局限性见下文。已知要查哪些代币时请用 getBalances;传 alchemy / nodereal 来源能查全,但同样受第三方服务的额度和可用性约束,需要可靠的完整持仓请直接接入索引服务的接口或自己的索引。

EVM 链上没有办法只靠节点列出一个地址持有的全部代币(链上没有“某地址持有哪些代币”的索引)。getOwnerTokens 的做法是:先从“代币来源”拿到候选代币,再用 multicall 在链上核对这些代币的余额,返回有余额的部分。

import { alchemy, getOwnerTokens, nodereal, tokenList } from '@w3lib/evm-batch-call/owner'

await getOwnerTokens(user, { chainId: 56 })                                   // 默认:免费公开代币列表
await getOwnerTokens(user, { chainId: 1, prices: true, minUsd: 1 })           // 带美元价值,按价值排序,过滤 < $1 的代币
await getOwnerTokens(user, { chainId: 56, source: alchemy({ apiKey: KEY }) }) // 用 Alchemy 查全部历史持仓(需要 Key)
// [
//   { token: NATIVE_TOKEN, native: true,  symbol: 'BNB',  formatted: '1.5', price: 600, value: 900, source: 'native', … },
//   { token: '0x…',        native: false, symbol: 'CAKE', formatted: '120', price: 2,   value: 240, source: 'metamask', logo: '…', … },
// ]

代币来源

| 来源 | 需要 Key | 能发现什么 | 说明 | | --- | --- | --- | --- | | metamaskTokenList()(默认首选) | 否 | MetaMask 汇总的代币列表(Ethereum 约 9500 个、BSC 约 2 万个),默认只用至少 3 家来源收录的 | MetaMask 自用接口,非对外承诺的公开 API | | coingeckoTokenList()(默认备用) | 否 | CoinGecko 收录的代币(Ethereum 约 6000 个、BSC 约 4500 个) | 覆盖 26 条链,见 COINGECKO_PLATFORMS | | tokenList(url) | 否 | 任意 Uniswap Token List 格式的列表(自己维护的、PancakeSwap 等) | | | staticTokens(list) | 否 | 固定的代币地址 | 如之前发现过、存下来的代币 | | alchemy({ apiKey }) | 是 | 地址的全部历史持仓 | 19 条链,见 ALCHEMY_NETWORKS;可用 urls: { [chainId]: url } 按链覆盖地址 | | nodereal({ apiKey }) | 是 | 地址的全部历史持仓 | 仅 BSC、Ethereum;同样支持 urls |

  • 默认来源是 firstAvailable(metamaskTokenList(), coingeckoTokenList()):MetaMask 列表不支持这条链、为空或请求失败时,用 CoinGecko 列表
  • 来源可以组合:firstAvailable(a, b) 依次尝试,combine(a, b) 合并去重(单个来源出错不影响其他来源),例如 combine(defaultTokenSource(), staticTokens(savedTokens))
  • Key 只在调用时传入:库里没有内置任何 Key,alchemy() / nodereal() 不传 apiKey 会直接报错,不传就不能用这两个来源。出错信息里不会包含 Key。在浏览器里使用时 Key 会暴露给用户,建议在服务端调用,或使用对方控制台里的域名白名单
  • 不管代币从哪个来源发现,余额和精度都由本库在链上用 multicall 核对(来源返回的余额不使用,来源给错的精度会被链上的值纠正),链上读不到精度的合约(不是正常的 ERC20)不返回
  • 主币用 Provider 配置的 nativeTokens 里的第一个地址查询(默认 0xeeee…eeee),名称 / symbol 同样遵循 nativeName / nativeSymbol 配置;列表里出现的主币映射地址(如 Polygon 的 0x…1010、zkSync 的 0x…800A)会被排除,不会把主币算两次
  • 公开代币列表按 URL 缓存 1 小时;clearTokenListCache() 可手动清空

增量扫描(可选)

代币列表发现不了的新代币(刚发行、未被收录),可以用 scanTransfers 开关补充:开启后,从第一次调用开始,每次调用都往后扫描新区块里转给该地址的 ERC20 Transfer 事件,把发现的代币合并进来(结果的 source 为 'transfers')。默认关闭。

import { getOwnerTokens, keyValueScanStorage } from '@w3lib/evm-batch-call/owner'

await getOwnerTokens(user, { chainId: 1, scanTransfers: true })                    // 进度存在内存里
await getOwnerTokens(user, { chainId: 1, scanTransfers: { storage: keyValueScanStorage(localStorage) } }) // 刷新页面后接着扫
await getOwnerTokens(user, { chainId: 1, scanTransfers: { lookbackBlocks: 20_000 } }) // 第一次额外往回扫一段

| 选项 | 默认 | 说明 | | --- | --- | --- | | storage | 内存 | 扫描进度(起始区块、已扫到的区块、发现的代币)。内存存储在进程 / 页面结束后丢失;keyValueScanStorage(localStorage) 或自己实现 { get, set }(如存数据库)可以持久化 | | lookbackBlocks | 0 | 第一次调用时往回扫的区块数(0 表示只从现在开始) | | blockRange | 1000 | 单次 eth_getLogs 的最大区块范围。节点有上限时按报错里的上限自动缩小(报错里没写上限就减半试),记下的上限 1 天后失效再重新试;偶发错误导致缩小后,连续成功会逐步放大回去 | | maxRequests | 20 | 每次调用最多发多少个 eth_getLogs;落后很多时分多次追上 | | timeBudget | 5000 | 每次调用的扫描时间预算(毫秒),超过后不再发新请求,留到下次调用 | | confirmations | 5 | 只扫到最新区块往前这么多个区块,避开链重组和节点之间的高度差(否则可能漏掉刚出的区块) |

出错时的处理:所有节点都明确不开放 eth_getLogs 时暂停 10 分钟;能用的节点只是限频 / 超时时不缩小范围,退避后重试(连续 3 次失败留到下次调用);有节点报范围超限时缩小范围。存储里的进度格式不对(如旧版本留下的)会被丢弃,重新开始。

也可以单独当作代币来源使用:combine(defaultTokenSource(), transferScan({ storage }));getTransferScanState(chainId, owner, storage) 读取扫描进度。

增量扫描的局限:

  • 只能发现开始扫描之后收到的代币(以及 lookbackBlocks 范围内的)。更早收到、又不在代币列表里的代币仍然查不到;需要完整历史请用 alchemy / nodereal
  • 需要节点支持 eth_getLogs,免费公共节点限制很多:BSC 上内置节点只有 blockrazor 可用,且每次最多 25 个区块(会自动适配),连续请求还会被限频;节点完全不支持时会暂停 10 分钟再试。持续使用建议传自己的节点
  • 进度默认只在内存里:页面刷新、服务重启后会从头开始(重新记下当前区块),需要持久化请传 storage
  • 会增加 getOwnerTokens 的耗时:扫描和余额查询一起等待,每次调用通常多几百毫秒到 timeBudget(默认 5 秒),最坏再加上一个请求的超时时间(节点超时默认 10 秒)。对耗时敏感时可以调小 timeBudget / maxRequests,或单独用 transferScan 在后台定期扫描、展示时只读进度
  • 很久没调用时追赶较慢:每次最多 maxRequests 个请求、timeBudget 时间(如 BSC 上 20 × 25 = 500 个区块),落后很多时需要多次调用才能追上
  • 最新几个区块里收到的代币要等下一次调用(confirmations,默认 5 个区块)
  • 扫描到的代币不经过任何列表筛选,大部分会是空投垃圾币,建议配合 prices + minUsd 使用
  • 只看转入(to 为该地址)的 ERC20 Transfer 事件;不发标准 Transfer 事件的代币发现不了;Tron 不支持

价格

prices: true 用 DefiLlama 查美元价格(免费、不需要 Key),结果带 price / value,并按价值从高到低排序;minUsd 只保留价值不低于它的代币(没有价格的代币也会被去掉,主币始终保留)。只传 minUsd 时会自动开启 DefiLlama 价格;与 prices: false 同时使用会报错。也可以传自己的价格源:prices: { name, prices: async ({ chainId, tokens, native }) => Map }。

局限性

使用前请了解:

  1. 默认来源只能发现公开列表里的代币。刚发行、还没被列表收录的代币(如新上线的 meme 币)、小众代币查不到;需要完整持仓时,传 alchemy({ apiKey }) 或 nodereal({ apiKey })
  2. 默认来源依赖第三方免费服务(MetaMask、CoinGecko 的列表,DefiLlama 的价格):它们可能限流、变更格式或停止服务;MetaMask 列表接口不是对外承诺的公开 API。生产环境建议组合多个来源,或使用自己维护的列表(tokenList(url))
  3. 列表里也有垃圾币和零头。默认只用至少 3 家来源收录的代币来减少垃圾币,但仍会有不少价值很低的代币;需要干净的列表时开启 prices 并设置 minUsd
  4. 价格不一定可信。流动性很差的代币价格可能严重虚高(实测有空投代币被估值数十万美元),默认已过滤 DefiLlama 置信度低于 0.9 的价格,但展示总资产时仍建议使用自己的价格源或再做校验
  5. 需要查几千到两万个代币的余额,耗时几秒:实测 Ethereum(约 9500 个代币)首次约 11 秒、之后约 6 秒,BSC(约 4800 个)首次约 8 秒、之后约 4 秒(首次要在链上核对持有代币的精度,之后有缓存)。大量使用时建议缓存结果;列表很大时也会占用较多节点请求
  6. 只包含 ERC20 代币,不含 NFT(NFT 请用 getNftBalances 等);Tron 链没有默认来源,需要自己传 source
  7. 使用 Alchemy / NodeReal 时受它们的免费额度和限频约束,翻页次数默认最多 20 页(2000 个代币),可用 maxPages 调整
  8. 开启 scanTransfers 时另有局限,见上面「增量扫描」

LP 仓位

查一个地址在各 DEX 的流动性仓位(子路径 @w3lib/evm-batch-call/lp):V2 的 LP 代币、V3 / V4 的仓位 NFT,每个仓位的两种代币数量、未领取的手续费,可选美元价值。所有合约读取都用 multicall 批量完成(整个查询通常 4~6 次请求)。

import { getLpPositions } from '@w3lib/evm-batch-call/lp'

await getLpPositions(user, { chainId: 56 })                    // 这条链上内置的全部协议
await getLpPositions(user, { chainId: 56, prices: true })      // 带美元价值(DefiLlama),按价值排序
await getLpPositions(user, { chainId: 56, tokens: heldTokens }) // V2:再用这些代币 × 计价币找池子
await getLpPositions(user, { chainId: 1, v4: { tokenIds: [12345n] } })        // V4:已知的仓位
await getLpPositions(user, { chainId: 1, v4: { scan: { storage } } })        // V4:扫描 Transfer 事件发现仓位
// [
//   { version: 'v3', protocol: 'pancakeswap-v3', protocolName: 'PancakeSwap V3', pool: '0x…', tokenId: '123', positionManager: '0x…',
//     token0: { address, symbol: 'WBNB', decimals: 18, native: false }, token1: { symbol: 'USDT', … },
//     amount0: '1200000000000000000', amount0Formatted: '1.2', amount1Formatted: '700',
//     fees0Formatted: '0.003', fees1Formatted: '1.8', fee: 500, tickLower, tickUpper, tick, inRange: true,
//     liquidity, price: 583.2, priceLower: 500, priceUpper: 650, valueUsd: 1400, feesUsd: 3.6, chainId: 56 },
//   { version: 'v2', protocol: 'pancakeswap-v2', pool: '0x…', balance, totalSupply, share: 0.0001, amount0Formatted, amount1Formatted, valueUsd, … },
// ]

内置的协议

| 链 | V2 | V3 | V4 | | --- | --- | --- | --- | | Ethereum | Uniswap、SushiSwap、PancakeSwap | Uniswap、PancakeSwap | Uniswap | | BSC | PancakeSwap、Uniswap、SushiSwap | PancakeSwap、Uniswap | Uniswap | | Base | Uniswap、PancakeSwap | Uniswap、PancakeSwap | Uniswap | | Arbitrum | Uniswap、SushiSwap、PancakeSwap | Uniswap、PancakeSwap | Uniswap | | Optimism | Uniswap | Uniswap | Uniswap | | Polygon | Uniswap、SushiSwap | Uniswap | Uniswap | | Avalanche | | Uniswap | | | Tron | SunSwap | SunSwap | |

地址都在链上核对过(2026-10)。其他 DEX / 链传自己的协议:protocols: [{ id: 'my-dex', name: 'My DEX', version: 'v3', chainId, positionManager, factory }](V2 只需要 factory,添加 / 移除流动性还要 router;V3 需要 positionManager 和 factory,ABI 与 Uniswap V3 相同的分叉都可以;V4 需要 positionManager 和 stateView,deployBlock 可选)。protocols: ['uniswap-v3'] 只查某些协议,versions: ['v3'] 只查某些类型。

每种类型怎么查、能不能查全

| 类型 | 能否查全 | 做法 | | --- | --- | --- | | V3(仓位 NFT) | 能 | 仓位合约可以按地址列出 NFT:balanceOf → tokenOfOwnerByIndex → positions,再读池子的价格和手续费增长。仓位很多的地址只核对按持有顺序最后的 maxPositions 个(默认 500,大致是最近的) | | V2(LP 代币) | 尽量 | 链上没有“地址持有哪些代币”的索引。默认用计价币之间的组合(WETH/USDT、WBNB/USDT…)在各工厂里找池子;tokens 传入更多代币(如资产列表里的)一起组合;pools 直接传池子地址;source: alchemy({ apiKey }) 用代币来源列出地址持有的全部 ERC20(包括 LP 代币,见资产列表)。找到的 LP 代币按 token0 / token1 / factory 识别池子和协议,工厂不在协议表里的显示为 Unknown V2 | | V4(仓位 NFT) | 需要配置 | V4 的仓位合约不能按地址列出 NFT。v4.tokenIds 传已知的仓位;v4.scan 扫描仓位合约发给该地址的 NFT(Transfer 事件),扫到的再核对是否仍归这个地址(已转走 / 销毁的从进度里去掉)。扫描前先查 balanceOf:地址一个仓位 NFT 都没有时直接跳过扫描(onProgress 收到 skipped: true)。两个都不传时不查 V4;候选太多时只核对 tokenId 最大的 maxPositions 个 |

V4 扫描(v4: { scan: true } 或传选项):每次调用从上次的位置往后扫,进度默认在内存里,storage: keyValueLpScanStorage(localStorage) 可以跨页面刷新接着扫。fromBlock:'deployment'(默认,从仓位合约部署的区块开始扫全部历史;自定义协议没传 deployBlock 时等同于 'latest')、'latest'(只发现之后的新仓位)或区块号。只查仓位合约的事件,单次范围默认 10000 个区块,节点有上限时自动缩小,限频 / 超时自动退避(与增量扫描相同)。扫全部历史在公共节点上很慢(BSC 公共节点每次只能查几百个区块),每次调用只推进 maxRequests 个请求 / timeBudget 时间,建议用自己的节点或把进度存起来。没扫到最新区块之前 V4 的结果不完整(从旧往新扫,最近铸造的仓位最后才扫到):onProgress: ({ cursor, target, done, pausedUntil }) => … 可以拿到进度,没扫完时提示用户或再调用几次(节点不开放 getLogs 时会暂停一段时间:触发暂停的那次带 pausedUntil,暂停期间的调用 target 为 null;拿不到最新区块时 target 也为 null)。confirmations(默认 5)不要设为 0:刚出的区块可能被重组。已转走 / 销毁的仓位只在节点跟上扫描进度时才从进度里去掉,节点落后时照常返回结果、这次不清理。改 fromBlock 只对还没有进度的地址生效(进度按链 + 仓位合约 + 地址保存)。

数值

  • 数量(amount0 / amount1):V2 按份额(储备 × 持有 / 总量);V3 / V4 是在当前价格下撤出全部流动性能拿到的数量(与合约的 decreaseLiquidity 结果逐位一致,不含手续费)
  • 手续费(fees0 / fees1,V3 / V4):未领取的手续费,按池子的手续费增长计算,不用发交易。V3 与仓位合约记的账一致;仓位合约按每个仓位单独取整、池子按同一区间合并记账,实际能领到的偶尔会少几 wei。V2 的手续费已经计入份额,没有单独的未领取手续费
  • 价格(price / priceLower / priceUpper):token1 / token0,按精度换算的浮点数,只用于展示;inRange 表示当前价格在仓位区间内(在区间内才赚手续费)
  • 已清空的仓位:V3 / V4 撤出全部流动性、手续费也领完后 NFT 还在,默认不返回;includeClosed: true 时返回
  • 美元价值(可选):默认不查;价格源出错(服务不可用、被拦截等)时照样返回仓位,只是 valueUsd / feesUsd 为 null;传 onPriceError: (err) => … 可以知道是价格服务出错(而不是代币没有价格)。prices: true 用 DefiLlama(与资产列表相同),也可以传自己的 PriceSource。valueUsd 是仓位里两种代币的价值,feesUsd 是未领取手续费的价值;数量不为 0 的代币没有价格时为 null;开启后结果按价值从高到低排序
  • Tron:地址(池子、代币、仓位合约)都是 T 开头的格式;价格用 DefiLlama 的 Tron 数据

局限:PancakeSwap 等质押到农场(MasterChef)的仓位 NFT 不在用户名下,查不到;V2 只能尽量查(见上表);V4 需要传 tokenIds 或开启扫描。

添加 / 移除流动性

子路径 @w3lib/evm-batch-call/liquidity:V2(通过 Router)、V3(仓位 NFT)、Uniswap V4(PositionManager + Permit2),协议与 LP 仓位 的内置表相同(含 Tron 的 SunSwap V2 / V3)。

  • buildAddLiquidity / buildRemoveLiquidity / buildCollectFees:只读取、不签名,返回预计的数量和要按顺序发送的交易(授权在前)。可以当作报价(输入一边的数量,看另一边要多少),也可以把 transactions 交给钱包自己发送
  • addLiquidity / removeLiquidity / collectFees:构建后签名发送,钱包与 /write 的 batchCall 相同(私钥、ethers Signer、浏览器钱包;Tron 上还可以是 tronWeb / tronwallet-adapter)。授权不够时先发授权交易,等它确认后再发主交易;授权失败时不发主交易
import { NATIVE_TOKEN, Provider } from '@w3lib/evm-batch-call'
import { getLpPositions } from '@w3lib/evm-batch-call/lp'
import { addLiquidity, buildAddLiquidity, collectFees, removeLiquidity } from '@w3lib/evm-batch-call/liquidity'

const multi = new Provider(56, rpc)

// 报价:新建 V3 仓位,价格区间是“1 个 tokenA 值多少个 tokenB”,给一边的数量
const plan = await buildAddLiquidity(multi, owner, {
  protocol: 'pancakeswap-v3', tokenA: WBNB, tokenB: USDT, fee: 500,
  priceLower: 550, priceUpper: 650,              // 或 fullRange: true,或 tickLower / tickUpper
  amountAFormatted: '1',                         // 或 amountA(最小单位);也可以两边都给
})
// { version: 'v3', token0, token1, tickLower, tickUpper, tick, price, liquidity,
//   amount0Formatted, amount1Formatted,            // 按当前价格预计存入的数量
//   amount0Max, amount1Max, native: null, value: '0',
//   transactions: [{ kind: 'approve', to: USDT, data, value: '0', token, spender, amount }, …, { kind: 'add', to: positionManager, data, value }] }

// 发送:主币直接用 NATIVE_TOKEN(V2 / V3 换成包装币、附带主币支付;V4 是主币池子)
const { status, tokenId, results } = await addLiquidity(multi, privateKey, {
  protocol: 'uniswap-v2', tokenA: NATIVE_TOKEN, tokenB: USDT, amountAFormatted: '0.5',
})

// 已有仓位:直接用 getLpPositions 的结果(或 { protocol, tokenId } / { pool })
const [position] = await getLpPositions(owner, { chainId: 56 })
await addLiquidity(multi, privateKey, { position, amount0Formatted: '0.1' })     // 追加,另一边按当前价格算
await removeLiquidity(multi, privateKey, { position, percent: 50 })              // 按比例移除(V3 / V4 同时领取全部手续费)
await removeLiquidity(multi, privateKey, { position, burn: true }, { native: true }) // 全部移除、销毁 NFT,包装币换成主币
await collectFees(multi, privateKey, { position })                               // 只领取手续费

| | V2 | V3 | V4 | | --- | --- | --- | --- | | 新建 / 追加 | Router addLiquidity(ETH),池子不存在时创建(两边数量都要给) | mint / increaseLiquidity;池子不存在时传 initialPrice 创建 | MINT_POSITION / INCREASE_LIQUIDITY;池子没初始化时传 initialPrice | | 移除 | Router removeLiquidity(ETH),先授权 LP 代币 | decreaseLiquidity + collect(+ burn),用 multicall 合成一笔 | DECREASE_LIQUIDITY / BURN_POSITION + TAKE_PAIR | | 授权 | 代币授权给 Router | 代币授权给仓位合约 | 代币授权给 Permit2,再在 Permit2 里授权给仓位合约(30 天有效) | | 主币 | native: true 或 tokenA 传主币:包装币那一边用主币 | 同 V2(refundETH / unwrapWETH9 + sweepToken) | 主币池子(currency0 是零地址)直接用主币 | | 手续费 | 已计入 LP 份额 | 追加不领取;移除时一并领取,collectFees 单独领取 | 修改流动性时自动领取(计划里的 fees0 / fees1) |

选项(构建和发送都可以传):

  • slippage:价格滑点,默认 0.005(0.5%)。V2 是数量 × (1 − 滑点);V3 / V4 按价格在 [p × (1 − s), p × (1 + s)] 内变动时的数量算:V3 添加给出 amountMin(三个价格下合约实际会用掉的最小值),V4 添加给出 amountMax(会多授权 / 附带一点,用不完的不扣、主币退回),移除给出 amount0Min / amount1Min
  • deadline:交易有效期(秒),默认 1200;recipient:收到 LP 代币 / NFT / 移除的代币的地址,默认发送者
  • approve:'exact'(默认,本次需要的数量)或 'max'。已有非零但不够的额度直接改成新额度;只有 Ethereum 上的 USDT 等要求先归零的代币才先 approve 0(有的代币反过来不允许 approve 0)
  • native:V2 / V3 的包装币那一边用主币支付 / 收取(新建时 tokenA / tokenB 传主币地址会自动开启)
  • protocols:内置表里没有的协议(自己的 LpProtocol,V2 要带 router);hookData:V4 传给 hooks 的数据
  • 发送时还有 /write 的发送参数(wait、timeout、gasPrice、signal、onUpdate…)和 checkBalance(默认 true:代币按预计数量、主币按 value 检查余额,不够时直接报错,不发交易)

数值:

  • 流动性:V3 / V4 的 liquidity 与合约算出来的逐位一致;V2 的 LP 数量和移除数量已按协议手续费算过(feeTo 开启时每次添加 / 移除前先给手续费地址增发 LP:比例看 LpProtocol.protocolFee:内置的 Uniswap / SushiSwap / SunSwap 是 '1/6',PancakeSwap 是 '8/25';自定义协议没填时按 '8/25' 偏保守地算),Uniswap V2 和 SunSwap V2 上与实际逐位一致
  • 数量:添加时 amount0 / amount1 是按当前价格实际会用掉的(V2 与 Router 的取整一致;给了的一边可能因为取整少用几 wei),amount0Max / amount1Max 是传给合约的上限;移除时是按当前价格能拿到的(不含手续费),fees0 / fees1 是一并领取的手续费
  • 区间:价格按 tokenB / tokenA 给,换算成池子的 tick(token1 / token0 方向,tokenA 是 token1 时自动取倒数),再取最近的可用 tick(tickSpacing 的整数倍);计划里的 priceLower / priceUpper 是取整后的实际区间(token1 / token0)。价格在区间外时只需要一种代币,给错了会报错并说明需要哪一种
  • 新仓位的 tokenId:发送后从回执里读(EVM 上主交易 confirmed 时);Tron 上为 null,用 getLpPositions 查
  • 往已有仓位追加、移除、领取都会先确认发送者是仓位的所有者(V3 也可以是被授权的 operator),不是时构建就报错(V3 的合约允许任何人往别人的仓位里加流动性,tokenId 写错时代币会白送给别人)
  • V4 的上限可能超过给的数量:V4 直接指定流动性,amountMax 是价格在滑点范围内最不利时需要的数量(与 Uniswap 的 SDK 相同),授权和附带的主币按它给,实际只扣按成交价格算的部分、主币多余的退回。主币池子要求余额够 value(= amount0Max),给的数量接近全部余额时请留出这部分或调小滑点。V3 传给合约的就是给的数量,不会超出
  • dryRun: true 时如果需要授权,主交易会预估失败(授权还没上链);只想看数量用 build*

核对方式:EVM 在 Base 主网分叉(anvil)上真实发交易,覆盖 Uniswap V2 / V3 / V4 和 PancakeSwap V3 的新建、追加、部分移除、全部移除并销毁;Tron 以近期真实用户的身份在主网预执行(SunSwap V2 添加 / 移除、V3 移除 / 追加),结果与计划逐位核对。

局限:手续费转账代币(fee-on-transfer)不支持;V4 的 hooks 如果额外收取 / 返还代币,计划里的数量不含这部分(交易仍按 amountMax / amountMin 保护);PancakeSwap 质押到农场的仓位要先取出。

打包:在已经用到 Provider 的基础上,只用 build* 约增加 40KB(minify,gzip 约 13KB),加上发送约 77KB(gzip 约 26KB,其中大部分是 /write 的发送代码),均不含 ethers。

领取手续费和挖矿奖励

子路径 @w3lib/evm-batch-call/rewards:一键领取 V3 / V4 仓位未领取的手续费,以及质押在农场里的挖矿奖励。

  • getClaimable(owner, { chainId }) / claimable(provider, owner):只读,列出每个仓位能领什么(数量按精度换算,prices: true 时带美元价值,按价值排序),来源出错只记进 errors
  • buildClaimAll(provider, owner, options):合并成尽量少的交易,先模拟、剔除会失败的项,只读取、不签名
  • claimAll(provider, from, options):构建后签名发送,from 与 /write 的 batchCall 相同;mode: '7702' | '4337' | '5792' 合成一笔原子交易
import { claimAll, getClaimable } from '@w3lib/evm-batch-call/rewards'

const c = await getClaimable(user, { chainId: 56, prices: true })
// c.items: [{ id: 'farm:pancakeswap-v3:7682136', kind: 'farm', staked: true, amounts: [{ token: CAKE, formatted: '0.046', usd: 0.03 }], usd: 0.03 },
//           { id: 'fees:pancakeswap-v3:7682136', kind: 'fees', staked: true, amounts: [token0, token1], … }, …]
// c.totals / c.usd:按代币汇总 / 合计

const { status, claimed, failed, plan } = await claimAll(multi, privateKey, { minUsd: 1 })   // 价值低于 1 美元的项跳过
await claimAll(multi, privateKey, { mode: '7702' })                // 一笔交易领完(要求钱包能本地签名)
await claimAll(multi, privateKey, { items: c.items.filter((i) => i.kind === 'fees') })   // 只领挑出来的

| 来源 | 链 | 读取 | 领取 | | --- | --- | --- | --- | | V3 手续费(Uniswap V3、PancakeSwap V3、SunSwap V3) | 内置协议表 | 与 /lp 相同(仓位 NFT + 池子的手续费增长) | 仓位合约 multicall([collect…]),一笔合并多个仓位 | | V4 手续费(Uniswap V4) | 内置协议表 | v4: { tokenIds } 或 v4: { scan: true }(同 /lp,V4 不能按地址列出 NFT) | 一次 modifyLiquidities:每个仓位 DECREASE_LIQUIDITY(0),每个币 TAKE 一次 | | PancakeSwap V3 农场(CAKE + 质押仓位的手续费) | BSC、Ethereum、Base、Arbitrum(MasterChefV3) | balanceOf / tokenOfOwnerByIndex 列出质押的仓位,以所有者身份预执行 harvest 得到真正会发的 CAKE(pendingCake 比实际少,只作备用) | MasterChef multicall([harvest, collectTo…]) | | Aerodrome / Velodrome Slipstream gauge | Base、Optimism | 经 Voter 枚举 gauge(缓存 1 小时,也可以传 gauges),stakedValues + earned | 每个仓位一笔 getReward(tokenId)(合约只能领一个) |

  • protocols:只查某些协议(LP 协议 id 或挖矿协议 id;'pancakeswap-v3' 同时含它的农场);fees / rewards:只查手续费或只查奖励;farms:自己的 FarmProtocol;maxPositions:每个协议最多核对多少个仓位(默认 500)
  • recipient:领到的代币发给哪个地址(默认 owner);gauge 的奖励只能发给 owner,传了别的地址时这些项被跳过
  • simulate(默认 true,用 /simulate,按需加载):某一笔失败时逐项再试,失败的项放进 plan.skipped(带原因),其余照常;每笔交易带 simulation(资产变化就是实际会收到的数量)。原子模式下一个仓位失败会让整笔回滚,所以先剔除
  • minUsd:低于这个美元价值的项跳过(有任何一个币没有价格的项算价值未知,不跳过);batchSize:一笔最多合并多少个仓位,默认 20
  • claimAll 的 continueOnError 默认 true(每笔独立,不像 batchCall 默认遇错停);结果的 claimed / failed 是已确认 / 未确认的项

核对:BSC 主网分叉(anvil)上,以真实质押用户的身份核对手续费与 MasterChef.collectTo 的预执行结果逐位一致、CAKE 与 harvest 一致,claimAll 的实际到账等于预览;再用随机钱包持有从真实持有者转来的 PancakeSwap V3 / Uniswap V3 / Uniswap V4 仓位并质押一个到 MasterChef,分别用逐笔和 '7702'(一笔原子交易)领取,每个代币到账数量与预览逐位一致,转走一个仓位后它被模拟剔除、其余照常。Base 分叉上以真实用户的身份领取 Aerodrome gauge 奖励(到账与预览一致,只多出几秒的累计);Base 主网(公共节点)枚举 Voter 的全部 gauge 读到质押的仓位和 AERO(带美元价值),Tron 主网读 SunSwap V3 真实用户的手续费并用 triggerconstantcontract 预执行领取交易。

局限:手续费 / 奖励随时间增加,预览是查询那一刻的数量;V4 要给 tokenIds 或开启扫描;Uniswap V3 的 UniswapV3Staker 激励按 IncentiveKey 领取、不能按用户列出,没有支持;Slipstream 的未质押仓位用了不同的 NonfungiblePositionManager(tickSpacing 代替 fee),不在 /lp 表里,手续费不查(质押后手续费归投票人,质押人只有奖励);V3 手续费收到的是代币本身(包装币不自动换成主币)。

Flap 分红

子路径 @w3lib/evm-batch-call/dividends:查并领取 Flap(BNB Chain 发币平台)税收代币的分红。只支持 BSC(chainId 56)。

import { claimDividends, getDividends } from '@w3lib/evm-batch-call/dividends'

const { items, unsupported } = await getDividends(user, { chainId: 56, tokens: [flapToken], discover: true })
// items: [{ token, symbol, balance, claimable: '194002826213118', claimableFormatted, claimed, asset: 'native', assetSymbol: 'BNB', usd: 0.12, accruing, dividendContract, … }]
await claimDividends(multi, privateKey, items)                                   // 每个代币一笔
await claimDividends(multi, privateKey, items, { mode: '7702' })                  // 一笔原子交易,默认先模拟、剔除会失败的项
  • tokens:要查的 Flap 代币;discover:从持有人收到过的 Transfer 事件里发现(只收地址后缀 7777 / 8888 的代币,再逐个核对;卖光的代币上已累计的分红仍可领取,所以不能只看当前余额)。传对象可配置 lookbackBlocks(默认 5000)、fromBlock、progress(结果 scan.progress,下次接着扫)、blockRange、maxRequests、timeBudget。eth_getLogs 不带地址时,公共 BSC 节点只允许很小的区块范围(25 / 50 个区块)或直接拒绝(publicnode 要求带地址),所以 discover 在公共节点上只能扫最近几百个区块;要完整发现请用自己的节点,或把已知的代币传给 tokens
  • prices(默认 true):分红资产的美元价值;includeZero:也返回没有可领取分红的代币。一次最多检查 200 个代币
  • claimDividends:每个代币一笔 withdrawDividendsFor(owner) 发给它的 Dividend 合约,from 与 batchCall 相同,mode: '7702' | '4337' | '5792' 合成一笔原子交易;simulate(默认 true,用 /simulate,按需加载)先模拟每一笔,会 revert 的不发;发送前还会在链上核对每项的 dividendContract 是代币自己的 Dividend 合约,不一致的跳过(status: 'skipped',error 是原因);dryRun 只预估 gas。buildClaimDividends(items) 只构建、不签名
  • 任何人都可以替持有人领取,款项直接到持有人地址,所以 from 可以是别的钱包(如代付 gas),一批里也可以混多个持有人的项

合约和核实范围

资料来源:docs.flap.sh(Tax Token V2 / V3、Inspect A Tax Token、已部署地址)和链上调用。BSC 主网:Portal 0xe2cE6ab80874Fa9Fa2aAE65D277Dd6B8e65C9De0、TaxTokenHelper 0x53841c73217735F37BC1775538b03b23feFD8346、SwapRegistry 0x644A8f560138418bAD4EdEFC7c17878a3c2fBEB6。

  • 每个税收代币有自己的 Dividend 合约(EIP-1167 克隆,实现 0xd67447C29908Cb901374b658cfD3CB261a375905,Sourcify / BscScan 上没有验证源码;函数名靠官方文档、实现字节码里的函数选择器和链上调用核对)。代币的 dividendContract() 给出它;读 dividendToken()、weth()、minimumShareBalance()、withdrawableDividends(user)、withdrawnDividends(user);领取 withdrawDividendsFor(user)
  • 分红资产就是 Dividend 合约里的 dividendToken():可以是 WBNB(领取时解包成 BNB,结果里 asset: 'native')、代币自己、或任意 ERC20(BTCB、DOGE、FXIon 等,按 Flap 的 SwapRegistry 换成)。持有人要持有至少 minimumShareBalance(实测 10000 枚)才继续累计新分红(accruing);已累计的不受影响,卖光后仍可领取
  • Flap 的触发机器人也会调 distributeDividend 自动给大额持有人发放,所以 claimable 为 0 不代表没领过(看 claimed)
  • 只有一种合约形态,所以只有一个适配器(adapter: 'flap'):Tax Token V3(TOKEN_TAXED_V3,主网上目前绝大多数)已在主网和分叉上核实。Tax Token V2(dividendBps > 0 时才部署 Dividend 合约,文档说明函数相同)没有找到链上实例核实,能读到 dividendContract() 且函数齐全才会识别,否则放进 unsupported(带原因);V1 没有分红。不认识的合约不会猜函数
  • 核对:BSC 主网分叉(anvil,Prague)上用随机钱包代领真实持有人的分红:分红资产为 ERC20(FXIon)和 WBNB(解包成 BNB)两种,持有人到账数量等于预览的 claimable,与官方 TaxTokenHelper.getDividendInfo 的 (claimed, pending) 一致,领完后 claimable 为 0、claimed 增加;mode: '7702' 把两个持有人的领取合成一笔原子交易;simulate: true 剔除会 revert 的项。另有连主网的读取 / 发现测试(LIVE=1)

局限:只有 BSC(Flap 在 Base、X Layer 等链上也有部署,没有核实);claimable 是查询那一刻的数量,之后还会增加;usd 依赖价格服务,没有价格时为 null;代币地址不是 7777 / 8888 后缀的不会被 discover 发现(传 tokens 即可);官方文档中 MAGIC_DIVIDEND_COMPUTED 的 LP / 篮子代币(如 Stocks Vault)作为分红资产时,领取到的是篮子代币解包后的底层资产,assetSymbol / usd 反映的是篮子代币,不一定等于实际收到的资产。

兑换

子路径 @w3lib/evm-batch-call/swap:在内置协议表的 V2 / V3 池子里找最好的路线,报价并构建 / 发送兑换交易(Tron 上是 SunSwap V2)。

import { NATIVE_TOKEN } from '@w3lib/evm-batch-call'
import { getSwapQuote, swap } from '@w3lib/evm-batch-call/swap'

const plan = await getSwapQuote(user, { tokenIn: NATIVE_TOKEN, tokenOut: USDT, amountInFormatted: '0.1' }, { chainId: 56 })
plan.amountOutFormatted   // '72.6'
plan.route                // { protocol: 'pancakeswap-v3', version: 'v3', path: [WBNB, USDT], fees: [100], … }
plan.transactions         // 授权(卖出代币且额度不够时)+ 兑换

await swap(provider, key, { tokenIn: USDT, tokenOut: NATIVE_TOKEN, amountInFormatted: '100' }, { slippage: 0.01 })
  • 路线:直接兑换,加上经过包装主币 / 稳定币(LP_QUOTE_TOKENS,可用 intermediates 改)的两跳;V2 的每个协议、V3 的每个手续费档位都算一条候选。V3 先查哪些池子存在、当前流动性(没有流动性的不算,两跳时每一跳只用流动性最大的 2 个档位),然后批量报价(V2 用 Router 的 getAmountsOut,一次报完;QuoterV2 很费 gas,每 3 个一组并发,避免超过节点的 gas 上限),选输出最多的。routes 里是全部候选的报价
  • 最少收到:amountOutMin = 报价 × (1 − slippage)(默认 0.5%),交易里由 Router 检查;priceImpact 是这笔的价格比极小数量的价格差多少
  • 带转账税的代币:V2 的报价(getAmountsOut)不含转账税,实际收到的更少,默认滑点下会 INSUFFICIENT_OUTPUT_AMOUNT 回滚。用 tax(如 0.05)把最少收到再乘 (1 − tax),税率可用 /risk 查;plan.amountOut 仍是不含税的报价
  • 授权后重新报价:swap 在有授权时先发授权,确认后重新报价再发兑换,deadline 和最少收到从发兑换那一刻算
  • 交易:V2 走 Router 的 swapExact…SupportingFeeOnTransferTokens(主币进出用 ETH 版本);V3 走 SwapRouter02 / PancakeSwap SmartRouter 的 multicall(deadline, …),卖出主币时附带 value,买入主币时先收到 Router 再 unwrapWETH9。卖出代币时额度不够先授权给 Router(approve: 'max' 可设无限额度)
  • 协议:swapProtocols(chainId)——V2 是协议表里有 Router 的,V3 是 V3_SWAP_CONTRACTS 里有报价和兑换合约的(Uniswap V3:7 条链;PancakeSwap V3:Ethereum、BSC、Base)。V4 和聚合器暂不支持
  • 报价是池子当前的状态,交易上链前价格可能变化;有转账税的代币报价会偏高(实际收到的少),可以先用 代币风险检查 看税率

主网核对:8 条链(含 Tron)报价正常。在 Base、BSC 上固定一个区块报价(blockTag),在同一个区块上用 eth_call 执行构建出的兑换交易(卖出代币时用 state override 设置额度):最少收到 = 报价时成功、= 报价 + 1 时失败,说明收到的正好等于报价——覆盖主币 → 稳定币、稳定币 → 主币(授权 + 解包)、V3 两跳、V2 的三种函数;Tron 的 SunSwap V2 两个方向同样核对过。

其他选项:route 指定走 routes 里的某一条(如让用户选另一条路线);blockTag 按某个区块的状态报价。

打包:在已经用到 Provider 的基础上,buildSwap 约增加 16KB(minify,gzip 约 7KB,含 /liquidity 的授权代码和协议表),swap 另外按需加载 /write 的发送代码。

代币风险检查

子路径 @w3lib/evm-batch-call/risk:在 eth_call 里模拟“买入 → 转账 → 卖出”(deployless 的探测合约,不部署、不发交易),算出税率、判断能不能卖。

import { getTokenRisks } from '@w3lib/evm-batch-call/risk'

const [risk] = await getTokenRisks([token], { chainId: 56 })
// { stage: 'ok', honeypot: false, buyTax: 0.05, sellTax: 0.08, transferTax: 0.02, owner: '0x…', implementation: null,
//   flags: ['has-tax', 'owned'], protocol: 'pancakeswap-v2', pool: '0x…', buyGas: 125000, sellGas: 98000 }
  • 用代币 / 包装主币里包装主币一侧最深的 V2 池子,从包装主币合约发起 eth_call(它持有大量主币,eth_call 不需要签名),默认用储备的 1 / 1000(最多 1 个主币)买入
  • 税率:买入税 = 1 − 实际收到 / Router 报价;转账税:转一成给新地址,看对方收到多少;卖出税取两种算法中较大的:池子实际收到的代币(转账时扣的税,精确),以及换回的主币比报价少多少(有的代币卖出时顺便把收来的税也卖进同一个池子)
  • 节点、网络、限频的错误会抛出,不当成代币的风险;只有执行失败才标为买不进
  • 判断:stage 是模拟到哪一步(buy-failed 买不进:可能还没开放交易或限制了合约;sell-failed 卖不出)。honeypot:卖不出、转不了,或卖出税 ≥ 90%。flags 汇总:honeypot、cannot-buy、high-buy-tax / high-sell-tax / high-transfer-tax(> 10%)、has-tax(> 0.5%)、proxy(EIP-1967 可升级代理,规则以后可以改)、owned(owner() 不是零地址)、no-pool
  • 只能发现模拟时的行为:针对特定地址、时间、数量才生效的限制(黑名单、交易冷却、单笔上限)不一定能发现;Tron 不支持

核对:在 BSC 主网分叉上部署测试代币(买 5% / 卖 8% / 转账 2% 税、卖出时把攒下的税也卖进池子、禁止卖出、卖出税 95%)和 PancakeSwap V2 池子,税率一致、貔貅盘都能识别;主网上 CAKE、USDT、PEPE 等没有税。

打包:探测合约约 3KB 字节码;在已经用到 Provider 的基础上 getTokenRisks 约增加 19KB(minify,gzip 约 8KB)。

交易模拟

子路径 @w3lib/evm-batch-call/simulate:发送前先看一笔(或一串依赖的)交易会发生什么,可以直接传 buildSwap / buildRevokes / 流动性构建的交易。

import { getSimulation, getBatchSimulation } from '@w3lib/evm-batch-call/simulate'

const sim = await getSimulation({ to: router, data, value }, { chainId: 1, from: user, usd: true })
// { success: true, gasUsed: '152345', fee: '…',
//   balanceChanges: [{ address, asset: 'native', symbol: 'ETH', delta: '-1000…', formatted: '-1', usd: -2400 }, { asset: '0xA0b8…', symbol: 'USDC', formatted: '2395.1', usd: 2395 }],
//   approvalChanges: [], decoded: { type: 'swap', protocol: 'Uniswap SwapRouter02', method: 'multicall', summary: 'Swap 1 ETH for 2395.1 USDC on Uniswap SwapRouter02' },
//   warnings: [], transfers: […], mode: 'simulateV1' }

const plan = await getSwapQuote(user, { tokenIn: USDT, tokenOut: NATIVE_TOKEN, amountInFormatted: '1000' }, { chainId: 1 })
const flow = await getBatchSimulation(plan, { chainId: 1, from: user })   // approve + swap 在同一个区块里依次执行,状态延续
if (!flow.success) throw new Error(flow.results[flow.failedIndex!].error)
  • EVM:eth_simulateV1(实测 publicnode、drpc、mainnet.base.org、bsc-dataseed 都支持,anvil 也支持;traceTransfers 把主币转账变成日志)。资产变化和授权变化从日志里算:ERC20 / 721 / 1155 的 Transfer、WETH 的 Deposit / Withdrawal(WETH9 没有 Transfer 事件)、Approval、ApprovalForAll、Permit2 的 Approval / Permit / Lockdown。没有用 deployless 探测合约:公共节点上日志方案够用,也不需要字节码
  • 不支持 eth_simulateV1 的节点自动退回 eth_call + eth_estimateGas(mode: 'eth_call'):只有成败、gas、revert 原因,资产变化按 calldata 估(主币 value、ERC20 transfer),多笔交易之间状态不延续(结果里有 no-state 提示)。debug_traceCall 没有用(公共节点基本都关了)。gas 默认 min(1000 万, 2500 万 / 笔数):公共节点限制一次模拟的总 gas
  • 选项:from、watch(另外统计资产变化的地址)、blockTag、stateOverrides(如给地址加余额)、usd(给变化带美元价值,用 /price,按需加载)、risk(对收到的代币做 /risk 检查,按需加载)、fee
  • calldata 解码(不查网络):ERC20 / 721 / 1155、WETH、Uniswap V2 / V3 Router、V3 仓位合约、Universal Router(命令列表)、Permit2、Multicall3、Disperse、智能账户的 executeBatch;Router 的地址按内置协议表标出协议名;multicall 解出子调用;认不出的选择器是 type: 'unknown' 带原始选择器。decodeCalldata / decodeRevert 也可以单独用
  • revert 原因:Error(string)、Panic(带含义)、常见自定义错误(Universal Router、Permit2、OpenZeppelin;ExecutionFailed 里的错误一并解开),认不出的是 custom error 0x选择器
  • warnings({ code, severity: 'info' | 'warn' | 'danger', message, address? }):unlimited-approval、unlimited-nft-approval、unknown-spender(不在 knownSpenders() 里)、approve-to-eoa(被授权方没有合约代码)、no-code(带 calldata 调用没有代码的地址)、large-value(主币转出超过余额的 90%)、token-risk(risk: true)、unknown-method、reverted
  • assertSimulation(simulate(…)):失败或有 danger 提示时抛错,一行守卫“模拟通过才发送”
  • Tron:triggerconstantcontract(能量记在 gasUsed、日志里的 TRC20 Transfer / Approval),TRX 的变化只有 call_value(内部转账查不到),没有手续费估算(用 /fees 的 getCostEstimate),每笔按当前状态单独模拟

核对:以太坊主网用公共节点模拟 USDT 转账、revert、无限授权、WETH 存入、buildSwap 的 approve + swap(usd 价值与报价一致);在主网分叉(anvil)上先模拟、再真的发出去,USDT 和 ETH 的变化与模拟逐位一致。

历史记录

子路径 @w3lib/evm-batch-call/history:一个地址的转账 / 兑换 / 授权 / 流动性 / 铸造记录,统一成 Activity。

import { getHistory } from '@w3lib/evm-batch-call/history'

const page = await getHistory(user, { chainId: 1, limit: 20 })        // 默认 Blockscout,失败换 RPC 日志
// { items: [{ hash, time, block, status: 'success', type: 'swap', label: 'Swapped 1 ETH for 2395 USDC on Uniswap V2 Router',
//             changes: [{ asset: 'native', symbol: 'ETH', delta: '-1000…', formatted: '-1' }, …], fee: '…', protocol, method }, …],
//   nextCursor: 'p:20123456', source: 'blockscout' }
const more = await getHistory(user, { chainId: 1, cursor: page.nextCursor, types: ['swap'] })
await getHistory(user, { chainId: 56, source: ['etherscan', 'rpc'], apiKey })   // Etherscan v2:一个 Key 覆盖多条链
await getHistory(tronAddress, { chainId: 728126428 })                             // TronGrid
  • 来源(适配器):'blockscout'(免 Key,内置各链实例表 BLOCKSCOUT_APIS,可用 blockscoutSource({ [chainId]: url }) 补充)、'etherscan'(Etherscan v2,apiKey)、'rpc'(eth_getLogs)、'trongrid';传数组按顺序故障切换(某个来源出错 / 被限流就换下一个,全部失败抛 HistorySourceError,带每个来源的错误);也可以传自己实现的 { name, fetch(query) → RawPage }。默认:有 Blockscout 实例的链 [blockscout, rpc],其他链 [rpc],传了 apiKey 时 etherscan 排最前;Tron 是 trongrid
  • Blockscout 用的是它的 Etherscan 兼容接口(/api?module=account&action=txlist | txlistinternal | tokentx | tokennfttx | token1155tx),没用 v2 REST:数据相同,但 v2 一页 50 条带大量标签约 500KB;这个接口只有必要字段,并且与 Etherscan 系完全相同。公共实例按请求数限流(一页 3~5 个请求,被限流时自动退避重试,仍失败就换下一个来源);nft: false 少 2 个请求
  • 分类:approve(calldata 是 approve / permit / setApprovalForAll…)、liquidity(加 / 减流动性、领取手续费)、mint(收到来自零地址的 NFT / 代币)、swap(calldata 是兑换,或同一笔交易里不同资产一进一出)、transfer(其余有资产变化的、或普通转账)、contract、other;direction / counterparty / label 一并给出;fee 只在这个地址是发送者时有(最小单位的主币,Tron 是 sun);失败的交易 status: 'failed'、没有资产变化
  • 分页:nextCursor 是不透明字符串(按区块号 / Tron 的毫秒时间戳),各来源通用,可以中途换来源。limit 是“至少返回多少条”:按区块整块取,可能略多;types / before / after 过滤后不够 limit 会继续翻页(maxPages,默认 5)
  • rpc 来源的局限:只有 ERC20 / 721 / 1155 的 Transfer 日志(按 topic 里的地址查),没有主币转账和内部交易(顶层 value 只在带代币转账的交易里有);按区块窗口(rpcSource({ window, maxWindows }),默认 2000 块 × 10)往回扫,稀疏的地址一次调用可能一条都没有,拿 nextCursor 继续;节点需要支持无合约地址过滤的 eth_getLogs(publicnode 的以太坊节点不支持,drpc / 1rpc 可以)
  • Tron:TronGrid 的 /v1/accounts/{address}/transactions + /transactions/trc20(没有 Key 限频较严,传 apiKey);内部的 TRX 转账查不到

核对:以太坊主网上 Blockscout 取一个活跃地址的两页(时间递减、分页不重复),RPC 日志来源取代币转账、识别出 Universal Router 的兑换并分页,TronGrid 取 Binance 热钱包的 TRX / USDT 转账(Blockscout 公共实例限流很严,连续跑测试会被 429 一段时间);用假的 Etherscan 响应单测了分类、分页不丢不重和故障切换。

手续费估算

子路径 @w3lib/evm-batch-call/fees:当前的手续费建议,和一笔交易大概要付多少手续费。

import { getCostEstimate, getFeeEstimate } from '@w3lib/evm-batch-call/fees'

await getFeeEstimate({ chainId: 1 })
// { type: 'eip1559', baseFee: '1200000000', slow: { maxFeePerGas, maxPriorityFeePerGas }, normal: {…}, fast: {…} }

await getCostEstimate({ from: user, to: USDT, data }, { chainId: 8453 })
// { type: 'evm', gasLimit: '45000', gasPrice, fee, maxFee, l1Fee: '…', total, totalFormatted: '0.0000123', symbol: 'ETH' }

await getCostEstimate({ from, to: USDT, data }, { chainId: 728126428 })
// { type: 'tron', energy: 64285, bandwidth: 345, energyBurn: '6428500', bandwidthBurn: '345000', activates: false, totalFormatted: '6.7735', symbol: 'TRX' }
  • 费用建议:按最近 20 个区块的 eth_feeHistory,baseFee 取下一个区块的,三档优先费是各区块第 10 / 50 / 90 百分位的中位数(空区块不算),maxFeePerGas = baseFee × 2 + 优先费(/write 默认用节点的 getFeeData,优先费取 eth_maxPriorityFeePerGas,公式相同但优先费来源不同)。节点不支持 eth_feeHistory 或 baseFee 为 0 的链(如 BSC)按 eth_gasPrice(快档 1.2 倍);网络等其他错误照常抛出
  • 一笔交易:预估 gas(会执行一遍,revert 时抛出)× 这一档的价格;fee 按 baseFee + 优先费算(预计实际付的),maxFee 是最多付的。OP Stack / Scroll 链加上 L1 数据费(含 25% 余量)。多笔交易可以先查一次 estimateFees 再传 fees 共用
  • Tron:模拟合约调用得到能量,按发送者剩余的质押能量、免费 / 质押带宽抵扣,不够的部分按链参数的价格烧 TRX;转 TRX 给没激活的地址时算上激活费(activates: true)。能量按模拟值,不加余量

主网核对:Ethereum、Base(含 L1 数据费)、BSC 上的 ERC20 转账;Tron 上有质押资源和全新地址两种发送者的 USDT 转账,以及给没激活的地址转 TRX。

打包:在已经用到 Provider 的基础上,getFeeEstimate 约增加 6KB(minify,gzip 约 2KB);getCostEstimate 另外按需加载 /write 的节点代码,Tron 的部分只在 Tron 上加载。

域名解析

子路径 @w3lib/evm-batch-call/names:名字 → 地址、地址 → 名字,用 Provider 这条链的域名服务。

| 链 | 服务 | 名字 | | --- | --- | --- | | Ethereum | ENS | .eth 以及导入的 DNS 域名 | | BSC | SPACE ID | .bnb | | Base | Basenames | .base.eth |

import { getAddressNames, getNameAddresses } from '@w3lib/evm-batch-call/names'

await getNameAddresses(['vitalik.eth', 'nick.eth'], { chainId: 1 })
// [{ name: 'vitalik.eth', normalized: 'vitalik.eth', address: '0xd8dA…6045', service: 'ens' }, …]
await getAddressNames([user], { chainId: 56 })
// [{ address: user, name: 'alice.bnb', service: 'spaceid' }]
  • 名字按 ENSIP-15 规范化(大小写、全角等),结果的 normalized 是规范化后的名字;不合法的名字 normalized / address 为 null
  • 正查读注册表的 resolver、再读 addr,两次 multicall;反查读主名字后再正查核对(名字要解析回同一个地址,没核对的反查记录谁都能设置),共四次 multicall
  • 不支持通配解析(ENSIP-10)和链下解析(CCIP-Read),这类名字(如部分子域名)返回 null;Tron 上没有域名服务
  • services 可以换成自己的 ENS 式注册表({ id, name, chainId, registry, suffixes, reverseSuffix })

打包:在已经用到 Provider 的基础上约增加 2KB(minify,gzip 不到 1KB;名字规范化用 ethers 自带的 ensNormalize)。

授权管理

子路径 @w3lib/evm-batch-call/approvals:查一个地址给出的授权,批量撤销。

import { approvals, getApprovals, revokeApprovals, scanApprovals } from '@w3lib/evm-batch-call/approvals'

// 持有的代币 × 已知的被授权方(内置协议的 Router / 仓位合约、Uniswap / PancakeSwap 的 Router、1inch、0x、ParaSwap、Permit2),一次 multicall
const list = await getApprovals(user, { chainId: 56, tokens: [USDT, CAKE] })
// [{ type: 'erc20', token: USDT, symbol: 'USDT', spender: '0x1111…', spenderName: '1inch Router v6', amount: '…', unlimited: true },
//  { type: 'permit2', token: USDT, spender: '0x66a9…', spenderName: 'Uniswap Universal Router', amount: '…', expiration: 1767225600, expired: false, … }]

await revokeApprovals(provider, key, list.filter((a) => a.type !== 'nft' && a.unlimited))
  • 三种授权:ERC20 的 allowance;Permit2 的额度(amount / expiration / nonce,过期的不返回);NFT 的 isApprovedForAll(传 nfts,默认检查 OpenSea、Blur 的 operator)。includeZero: true 时也返回额度为 0 / 已过期 / 未授权的项
  • 检查范围:tokens × spenders(默认 knownSpenders(chainId)),加上直接给出的 pairs / nftPairs。被授权方不在已知列表里时 spenderName 为 null
  • 按事件发现:scanApprovals 扫地址发出的 Approval / ApprovalForAll 事件和 Permit2 的 Approval / Permit 事件,返回授权过的 pairs / nftPairs(只发现,当前是否还有效交给 approvals 查),可以保存 progress 下次接着扫。需要节点支持不带合约地址的 eth_getLogs(publicnode 等节点要求带地址,不行;drpc、1rpc、flashbots 可以),公共节点的区块范围限制很严,适合从某个时间点开始增量扫描,不适合扫全部历史。Tron 不支持
  • 撤销:buildRevokes 只构建交易(ERC20 每个一笔 approve(spender, 0),Permit2 的全部合成一笔 lockdown,NFT 每个一笔 setApprovalForAll(false));revokeApprovals 构建后发送,钱包和选项同 batchCall(mode: '7702' 时同一钱包的撤销合成一笔交易),默认一笔失败不影响后面的
  • Tron:SunSwap 的 Router / 仓位合约的额度和撤销(T 地址),没有 Permit2

主网核对:Ethereum 上从最近区块的真实 Approval / Permit / ApprovalForAll 事件取授权,额度和合约一致,scanApprovals 能发现,撤销的三种交易都能预估通过;Tron 上读到 SunSwap 的真实授权,撤销能模拟。

打包:在已经用到 Provider 的基础上,getApprovals 约增加 11KB(minify,gzip 约 5KB),scanApprovals 很小(约 4KB gzip,不含 Provider);发送的代码按需加载,只查授权时不会打包。

免 gas 授权(permit)

子路径 @w3lib/evm-batch-call/permit:用签名代替 approve 交易。EIP-2612 permit(含 DAI 风格)、Permit2 的 PermitSingle / PermitBatch(AllowanceTransfer)和 permitTransferFrom(SignatureTransfer)。构建 EIP-712 数据 → 用 ethers Signer 签名(私钥 Wallet、浏览器钱包的 JsonRpcSigner 都行)→ 本地验证 → 生成上链 calldata。

import { buildPermit, signPermit, permitCalldata, verifyPermitSignature, buildPermit2Single, permit2Calldata } from '@w3lib/evm-batch-call/permit'

const permit = await buildPermit(provider, { token: USDC, owner, spender: router, value: 100_000_000n }) // 精确额度、30 分钟有效
const signed = await signPermit(signer, permit)        // 签名前核对,签完本地验签
verifyPermitSignature(permit, signed.signature)        // true
permitCalldata(signed)                                 // token.permit(...) 的 calldata:谁都可以提交

const p2 = await buildPermit2Single(provider, { token: USDC, owner, spender: router, amount: 100_000_000n })
permit2Calldata(await signPermit(signer, p2))          // Permit2.permit(...)
  • 检测:detectPermit(provider, token) 读 nonces / DOMAIN_SEPARATOR / name / version / eip712Domain(EIP-5267),按链 ID 和地址重新算 domain,与代币的 DOMAIN_SEPARATOR 逐位一致才算支持(USDC v2 的 version 是 '2';没有 version() 时试 '1' / '2');PERMIT_TYPEHASH 识别 DAI 风格。不一致时返回原因,不签
  • 安全默认:精确额度(MaxUint256 / MaxUint160 要 allowUnlimited: true;DAI 风格的 permit 只能无限额度,也要显式允许);有效期默认 30 分钟,超过 24 小时要 allowLongDeadline: true;数量为 0 的空白 permit、零地址 spender 直接拒绝;signPermit 签名前核对签名者 = owner、chainId、verifyingContract(代币本身 / Permit2)、typed data 里的 spender / 数量 / 有效期与 permit 摘要一致(防被替换),钱包返回的签名不是 owner 的也报错
  • Permit2 的 permitTransferFrom:nonce 默认随机(无序位图,不会冲突);只有签名里的 spender 能调用;permitTransferFromCalldata 的 requestedAmount 不能超过签名的数量
  • 合约账户(ERC-1271)的签名无法本地验证(verifyPermitSignature 返回 false),也不能签 EIP-2612(链上要求 EOA 签名);撤销 Permit2 额度用 /approvals 的 buildRevokes(lockdown)

在 swap 里用(approve: 'permit' | 'permit2' | 'auto',实际采用的方式在 result.approval):

| 路线 | 能否用签名代替授权 | | --- | --- | | V3(Uniswap SwapRouter02 / PancakeSwap SmartRouter) | 能:selfPermitIfNecessary 放进同一笔 multicall,一次签名、一笔交易;代币要支持 EIP-2612 | | V2 Router | Router 没有 selfPermit:单独发 permit() 交易省不了什么,所以默认退回普通授权;用 mode: '7702' / '4337' / '5792' 把 permit 和兑换放进同一笔原子批量时才用 permit | | Permit2 | 内置的 V2 / V3 Router 都不读 Permit2(直接 transferFrom),'permit2' 会退回普通的精确授权,result.approval.reason 写明;Universal Router 暂不支持 | | DAI 风格 / 不支持 permit 的代币 / Tron / 4337 的合约账户 | 退回普通授权,reason 里有原因 |

const r = await swap(provider, signer, { tokenIn: USDC, tokenOut: NATIVE_TOKEN, amountInFormatted: '20' }, { approve: 'auto' })
r.approval   // { requested: 'auto', method: 'permit', via: 'selfPermit' } 或 { method: 'approve', reason: '…' }

permit 的有效期与兑换的 deadline 相同,额度是精确的卖出数量;用户拒绝签名时整个 swap 中止,不会悄悄改成发授权交易。

主网核对(Base 分叉,anvil Prague):USDC 的 permit 提交后额度正好是签的数量、重放失败;Permit2 的 PermitSingle 链上验签通过,permitTransferFrom 由指定 spender 转走、超额 / 重放 / 他人调用都失败;swap 的 'permit' 在 Uniswap V3、PancakeSwap V3 上一笔交易完成(额度用完归零),V2 路线 + 7702 代付的 permit + 兑换原子完成。

价格

子路径 @w3lib/evm-batch-call/price:代币的美元价格和地址的资产总值。

import { NATIVE_TOKEN } from '@w3lib/evm-batch-call'
import { getPortfolio, getTokenPrices } from '@w3lib/evm-batch-call/price'

await getTokenPrices([NATIVE_TOKEN, USDT, newToken], { chainId: 56 })
// [{ token: '0xEeee…', native: true, price: 612.3, source: 'defillama' }, …, { token: newToken, price: 0.0012, source: 'onchain' }]

const p = await getPortfolio(user, { chainId: 56, tokens: [NATIVE_TOKEN, USDT, CAKE], lp: true })
p.totalUsd // 代币 + LP 仓位的合计;p.tokens 按价值排序,p.lp 是 LP 仓位
  • 价格源依次使用,前面没有价格的代币交给后面的(sources,默认 [defillamaPrices(), onchainPrices()]),结果的 source 是给出价格的源
  • defillamaPrices({ at }):历史价格(unix 秒或 Date);minConfidence 过滤置信度低的价格(默认 0.9)
  • onchainPrices():在内置协议表的 V2 / V3 池子里找代币对包装主币、稳定币的池子,用计价币一侧最深的池子的价格;稳定币按 1 美元。V3 池子的深度取计价币余额和当前价格附近的流动性折算值中较小的(只看余额会被“远处放一笔、再推价格”的方式骗过),minLiquidityUsd(默认 10000)以下的池子不用。这只能挡住很小的池子:有人愿意自己出资建一个够深的池子时,价格仍然可以是他定的,所以新币的链上价格只适合展示。主网实测和 DefiLlama 相差在 0.5% 以内(Ethereum、BSC、Base、Tron)
  • combinePrices([…]) 把多个源组合成一个,可以传给 getLpPositions / getOwnerTokens 的 prices
  • 资产总值只计入传入的代币(EVM 不能只靠节点列出地址持有的全部代币),lp: true 时加上 LP 仓位(参数同 getLpPositions)

价格只用于展示和估值:链上价格是池子当前的价格,可能被单个区块内的交易影响,不要用它算交易的最小数量。

打包:在已经用到 Provider 的基础上,getTokenPrices 约增加 12KB(minify,gzip 约 5KB),getPortfolio(含 LP 仓位)约 31KB(gzip 约 12KB),均不含 ethers。

功能

  • 常用查询:余额、代币详情、授权额度、NFT(集合 / 持有 / 元数据地址 / ERC1155 余额,子路径 /nft),一个函数一次请求
  • 兑换(子路径 /swap):V2 / V3 池子的最佳路线(直接或两跳)、报价、构建和发送,主币自动包装 / 解包
  • 代币风险检查(子路径 /risk):模拟买入 / 转账 / 卖出,算出买卖税、识别貔貅盘,代理合约、owner
  • 手续费估算(子路径 /fees):EIP-1559 三档费用建议,一笔交易的手续费(含 L1 数据费),Tron 按能量 / 带宽算要烧的 TRX
  • 域名解析(子路径 /names):ENS、SPACE ID(.bnb)、Basenames(.base.eth),批量正查、反查(正查核对)
  • 授权管理(子路径 /approvals):ERC20 / Permit2 / NFT 授权的批量查询(默认检查常见 Router、聚合器、NFT 市场),按事件发现,批量撤销
  • 价格(子路径 /price):DefiLlama 当前 / 历史价格、链上 V2 / V3 池子价格,组合价格源,地址的资产总值(代币 + LP 仓位)
  • 资产列表(不推荐使用,尽力而为,子路径 /owner):getOwnerTokens,免费公开代币列表或带 Key 的索引服务发现代币,multicall 核对余额,可选 DefiLlama 价格
  • 批量读取:all / tryAll / tryEach,N 条读调用合成一次 eth_call;支持数组或对象输入
  • 自动合并:绑定合约直接 await、provider.call(x),以及 balances / tokens / allowances,同一收集窗口内的调用合并成一次请求并去重
  • 预执行:staticCall / staticCallAll / method.staticCall,带 from / value 模拟交易,返回解析好的 revert 原因
  • 主币余额合并:getEthBalance 与合约调用在同一次 eth_call 里——无论链上有没有 Multicall3
  • 任意链可用:内置 Multicall3 地址表;表里没有、地址无效、或查询早于部署区块时,自动改用 deployless(链上无需部署任何合约)。少数链不支持 deployless(合约创建式 eth_call 返回空数据,如 Oasis Emerald、Aurora):表里有 Multicall3 的链只能查合约部署之后的状态;deployless 失败时(空数据 / 无法解码)自动改成逐条 eth_call(主币余额用 eth_getBalance,合并在一个 HTTP batch 里,慢一些但任何链都能用),确认不支持后记住 10 分钟、不再先试一次 deployless;pnpm check:rpc 会检查内置节点表里每条链至少有一种批量方式可用
  • 备用节点:传多个节点自动故障切换;不传节点使用内置公共节点表(34 条 EVM 链 + Tron)
  • chainId 可选:传了节点就能自动识别 chainId,结果缓存,钱包切链自动失效
  • Tron:走 Tron HTTP API,T 开头地址可以直接用
  • 浏览器插件钱包:直接传 window.ethereum(EIP-1193)或 window.tronWeb
  • 大批量:合约模式按 chunkSize(默认 500)分片;deployless 按 48KB initcode 上限自动分片,结果超过 24KB 也能拿回

节点与备用节点

new Provider('https://bsc-dataseed.bnbchain.org')       // 只传节点:chainId 自动识别
new Provider(56)                                        // 内置公共节点
new Provider(56, 'https://bsc-dataseed.bnbchain.org')   // 单个 URL
new Provider(56, ['https://a.example', 'https://b.example']) // 主节点 + 备用节点
new Provider(56, [window.ethereum, 'https://bsc-dataseed.bnbchain.org']) // 钱包优先,失败用公共节点
new Provider(56, new JsonRpcProvider(url))               // 任意 ethers Provider
new Provider(56, DEFAULT_RPC_URLS[56], { fallback: { timeout: 8000, cooldown: 60_000 } })

多节点时按顺序使用:出错或超时(默认 10s)换下一个节点,出错的节点在 cooldown(默认 30s)内排到最后。与 ethers 的 FallbackProvider 相比,没有首次请求前对所有节点的同步,一个卡住的节点不会拖住第一次查询;合约 revert 等确定性结果不会拿去别的节点重试。

超时与出错分开计:出错(连不上、5xx、限频等)的节点进入冷却;超时只换下一个节点、不冷却,同一节点连续 3 次超时才冷却——冷启动的大批量查询偶尔慢,不会把健康的节点踢到后面。冷却状态按节点(URL / 对象)全局共享,Provider 重建后刚失败过的节点仍排在后面。

所有节点都失败时抛 AllNodesFailedError,err.errors 是每个节点的标识和失败原因([{ node, error }],按尝试顺序),message 里也逐个列出;节点标识只取 URL 的 host,不含 path / query 里的 API Key。单节点时抛原始错误。

可观测性:onRequest(listener) 订阅所有节点请求(全局,返回取消订阅的函数),每次对节点的请求一条事件:

import { onRequest } from '@w3lib/evm-batch-call'

const off = onRequest(({ node, method, ms, ok, error, attempt }) => {
  // node:节点 host(钱包为 'wallet');method:call / getBalance / getLogs …;attempt > 0 表示换过节点
  console.log(`${node} ${method} ${ms}ms ${ok ? 'ok' : 'failed'}${attempt ? ` (第 ${attempt + 1} 个节点)` : ''}`)
})

慢节点:fallback.stallTimeout(毫秒,默认 0 关闭)设置后,当前节点超过这个时间还没返回就同时请求下一个节点,原请求不取消,谁先成功用谁(与 FallbackProvider 的 stallTimeout 语义相同);timeout 才是真正放弃。节点正常但偶尔较慢时,用 { stallTimeout: 2000, timeout: 10_000 } 比把 timeout 设短更好:大批量查询本来就要几秒,timeout 太短会把正常的慢请求判成超时、反复重试。只影响 eth_call / eth_getBalance。

ethers 会把 eth_call 的任何 JSON-RPC 错误(限流、header not found 等)都包装成 CALL_EXCEPTION,库内用 isExecutionError(err) 区分“合约执行结果”和“节点问题”,只有后者会触发切换。

内置节点表 DEFAULT_RPC_URLS(按 chainId)/ DEFAULT_TRON_HOSTS 中的节点都能在浏览器里直连:放行跨域、支持 batch 请求、chainId 正确、不需要 API Key。公共节点随时可能下线或限流,生产环境建议传入自己的节点,内置表作为备用。

缓存持久化

decimals 永远不会变,但页面刷新后缓存就没了,冷启动的大批量查询要顺带查精度,耗时能差好几倍。可以把缓存持久化:

import { exportTokenMetaCache, importTokenMetaCache, persistTokenMetaCache } from '@w3lib/evm-batch-call'

persistTokenMetaCache(localStorage)          // 调用时读入,之后有更新就防抖(1 秒)写回,默认最多 5000 条;返回停止函数
// 或自己存:IndexedDB、服务端……
await save(JSON.stringify(exportTokenMetaCache()))
importTokenMetaCache(JSON.parse(await load())) // 格式不对的数据忽略;name / symbol 仍按写入时间 1 小时过期

批量读取

import { Contract, Provider } from '@w3lib/evm-batch-call'

const usdt = new Contract(USDT, ERC20_ABI) // 未绑定的 Contract:方法返回 Call,与 ethcall 一致
const multi = new Provider(56)

const [symbol, decimals] = await multi.all([usdt.symbol(), usdt.decimals()]) // 任意一条失败整体抛错
const res = await multi.tryAll([usdt.symbol(), other.symbol()])             // 失败的位置为 null
const res2 = await multi.tryEach([usdt.symbol(), usdt.balanceOf(user)], [true, false])
await multi.all(calls, { blockTag: 40_000_000 })                            // 指定区块
  • 单返回值的函数直接返回该值;多返回值返回 ethers Result(可按下标或名字取);整数统一是 bigint
  • 结果类型按输入推断(元组保留各项类型);ethcall 风格的 all<T>(calls) 返回 T[],all<any>(calls) 返回 any[]
  • tryAll 中出现非法地址(如后端给的脏数据)只让该条为 null,不会拖垮整批
  • Contract 暴露 ABI 里的所有函数(不只 view/pure),重载函数用完整签名访问:c['getAmount(uint256,address)'](...)

自动合并

绑定合约(multi.contract / multi.erc20)的方法返回值可以直接 await,等价于 multi.call(call):

const multi = new Provider(chainId, rpc, { batch: { wait: 10, maxSize: 500 } })

// 分散在不同组件里的调用,只要落在同一个收集窗口内就只发一次 eth_call
const a = multi.erc20(tokenA).balanceOf(user)
const b = multi.erc20(tokenB).balanceOf(user)
  • wait:收集窗口,默认 0(同一事件循环 tick);maxSize:达到即发出,默认 500
  • 单条失败只 reject 自己(CallFailedError,err.reason 是 revert 原因)
  • blockTag / from 不同的调用分开发
  • balances / tokens / allowances 的子调用也进同一个队列:不同组件同时查余额时合成一次 eth_call,相同的 balanceOf / decimals 只请求一次;一次调用的子调用不会被 maxSize 拆开
  • 合并发生在同一个 Provider 实例内,应按链缓存 Provider,不要每次 new
  • 绑定合约的 Call 只在第一次 await 时才发请求,传给 all / tryAll 不会额外发请求
  • 末位参数可以带 overrides:await pair.getReserves({ blockTag: 18_000_000 })。传给 all / tryAll 时以批次的 overrides 为准(一批只能在同一个区块、同一个 from 下执行)

预执行

const router = multi.contract(routerAddress, RouterABI)

await router.swap.staticCall(params, { value, from: user }) // 写法同 ethers
await multi.staticCall(router.swap(params, { value }), { from: user })

// 批量:每条单独返回成功/失败
const results = await multi.staticCallAll(
  [quoter.quoteExactInputSingle(paramsA), { call: router.swap(paramsB), overrides: { value: amountIn } }],
  { from: user },
)
for (const r of results) {
  if (r.success) console.log(r.data)
  else console.log(r.error) // 合约 revert 时是 CallFailedError(r.error.reason)
}

预执行不走 multicall:multicall 里子调用的 msg.sender 是 multicall 合约,也无法逐条带 value,与真实交易不一致。每条都是对目标合约的独立 eth_call,同时发出(JsonRpcProvider 会合进一个 JSON-RPC batch 请求)。

批量写操作

子路径 @w3lib/evm-batch-call/write,只做查询时打包不会带上。三个函数都复用 Provider(节点故障切换、一次 multicall 读余额 / decimals)。在已经用到 Provider 的基础上,只引入 batchCall 约增加 12KB(minify,gzip 约 4KB),三个函数都引入约 21KB(gzip 约 8KB),均不含 ethers;私钥用 ethers 的 BaseWallet 签名,不会带上 Wallet 的 keystore 加解密代码(约 74KB):

import { Contract, ERC20_ABI, NATIVE_TOKEN, Provider } from '@w3lib/evm-batch-call'
import { batchCall, batchCollect, batchTransfer } from '@w3lib/evm-batch-call/write'

const multi = new Provider(56, [rpc1, rpc2])

// 一对多转账:默认走 Disperse 合约,每种代币一笔交易(代币先 approve 本次的精确总额)
await batchTransfer(multi, privateKey, [
  { to: a, formatted: '1.5' },                // 默认代币(token 选项,默认主币)
  { to: b, amount: 10n ** 18n },              // amount 是最小单位,formatted 按精度换算,二选一
  { to: c, token: USDT, formatted: '20' },    // 主币、代币可以混在一起
])

// 归集:多个钱包 → 一个地址;主币不够付手续费的子钱包先由 gasWallet 补 gas
const { results, topUps } = await batchCollect(multi, [key1, key2, key3], hotWallet, {
  tokens: [USDT, NATIVE_TOKEN],   // 包含主币:代币归集确认后,扣掉手续费扫空剩余主币
  gasWallet: gasKey,
})

// 通用批量调用合约
const usdt = new Contract(USDT, ERC20_ABI)
await batchCall(multi, [
  { from: key1, call: usdt.approve(router, amount) },
  { from: key1, to: router, data: swapData },  // 依赖上一笔:预估失败时先等 approve 确认再预估
  { from: key2, to: someone, value: 10n ** 16n },
])

钱包:私钥字符串,或 ethers Signer。能本地签名的(Wallet 等)自己管理 nonce,可以连续发出、等待时重发;浏览器钱包的 JsonRpcSigner 交给钱包签名并发送(每笔需要用户确认,不支持 '7702')。

发送方式:

| mode | 说明 | 用于 | | --- | --- | --- | | 'tx' | 每个调用一笔交易。同一钱包按 nonce 顺序连续发出(不等上一笔确认),不同钱包并发(concurrency,默认 8) | batchCall 默认;所有链 | | 'disperse' | Disperse 合约(DISPERSE_ADDRESS),每种代币每 batchSize(默认 200)个收款人一笔交易 | batchTransfer 默认 | | '7702' | 钱包通过 EIP-7702 委托给 Simple7702Account(SIMPLE_7702_ACCOUNT,eth-infinitism),同一钱包的调用合成一笔发给自己的 executeBatch 交易(每 batchSize,默认 50 个一笔),一笔里的调用要么全成功要么全失败;msg.sender 仍是钱包本身 | batchCall / batchTransfer 可选 |

  • Disperse 自动退回逐笔发送:链上没有 Disperse 合约、某种代币只有一个收款人、某一批预估就 revert(收款地址拒收主币、转账收税的代币、被拉黑的地址等)时,这部分改为逐笔发送,出问题的收款项单独标记失败。结果里的 via 是实际的发送方式。Disperse 只会从调用者自己的账户 transferFrom,approve 给它的额度别人无法动用
  • 7702 会改变账户:委托一直保留,直到撤销(revokeDelegation(multi, key))。钱包已委托给其他合约时默认报错,传 redelegate: true 才会改。需要链支持 EIP-7702、委托合约在这条链上已部署(没部署时直接报错:委托给没有代码的地址时调用会“成功”但什么都不执行),钱包需要能本地签名 7702 授权(私钥 / ethers Wallet)。发授权交易时钱包不能有未确认的交易(否则 gas 预估不准、上链后执行失败)。第一笔带授权的交易确认后才发同一钱包的下一笔,没确认(超时)时后面的批次跳过
  • Disperse 不能用于通用调用:它只能分发主币 / 代币。任意合约调用合成一笔交易只能用 '7702'(Multicall3 的 msg.sender 是 Multicall3 合约本身,approve 或转给它的代币任何人都能拿走)

结果:与输入一一对应,字段都是字符串 / 数字 / null,可以直接 JSON.stringify:

{ index: 0, from, to, status: 'confirmed', hash: '0x…', nonce: 7, gasLimit: '55320', gasUsed: '46100', fee: '46100000000000', blockNumber: 123, error: null }
// batchTransfer 另有 token / native / amount / formatted / via / approvals(为走 Disperse 发出的 approve 交易);batchCollect 另有 token / native / amount / formatted

| status | 含义 | | --- | --- | | confirmed | 已上链且执行成功 | | reverted | 已上链但执行失败(手续费已扣) | | failed | 没有发出去(预估 revert、余额不足、节点拒绝、用户在钱包里拒绝),不花手续费;error 是原因(能解析时带 revert 原因) | | sent | 已广播,没等确认(wait: false,或等待中途取消)。广播结果不确定的交易也算 sent(超时 / 连不上节点,或前一个节点超时后另一个节点拒绝;浏览器钱包已广播、之后查询出错),可能已进交易池,不能当作失败重试,error 里有说明,等待期间会定期重发。节点明确拒绝的(给出了错误信息)是 failed | | pending | 等到 timeout 还没确认,之后仍可能上链 | | skipped | 没有执行:同一钱包前面的调用发送前失败(batchCall 默认 continueOnError: false;已发出、之后上链 revert 的拦不住后面已发出的交易)、已取消、同一笔 7702 交易里其他调用失败、余额为 0 等 | | simulated | dryRun: true 时预估通过(不发交易) |

依赖前一笔的调用:预估 gas 失败且同一钱包还有未确认的交易时,先等它们确认再重新预估一次,所以“先 approve 再调用”可以直接写在一批里。dryRun 不会真的执行前一笔,这类调用预估会失败。

归集流程(batchCollect):一次 multicall 读出所有钱包的余额 → 预估每笔归集的手续费(gasLimit × maxFeePerGas,OP Stack / Scroll 加上 L1 数据费)→ 不够的钱包由 gasWallet 补上差额(gasMode,默认 'disperse' 一笔补齐,等确认)→ 重新预估、发代币归集(手续费价格沿用补 gas 时的那一个,补 gas 等确认期间节点报价上涨也不会超出补的数量)→ 包含主币时等代币归集确认,按最新余额(minBlock 不早于最后一笔交易的区块)扣掉手续费扫空。代币没有全部归集成功的钱包不扫主币(留着付重新归集的手续费);补的 gas 用剩的零头不够付扫尾手续费时,主币一项也为 skipped。余额为 0、低于 minAmount、钱包就是归集地址的项为 skipped。dryRun: true 返回计划(补多少 gas、每笔 gasLimit),不发交易。

Disperse / 7702 里同一笔交易的多个调用,hash、fee、gasUsed 都是整笔交易的值(汇总手续费时按 hash 去重)。

nonce 被占用:广播时节点说 nonce 已被用掉,先多查几次回执(节点可能落后):是这笔已上链就按已发出处理;确定没上链(钱包在别处发了交易)才换 nonce 重发;查询出错时不确定,按已发出跟踪,绝不重发。

浏览器钱包(ethers 的 JsonRpcSigner):先确认钱包当前所在的链与 Provider 一致,不一致时报错、不发送(否则会在另一条链上真的转账);交易里也带上 chainId,钱包会再校验一次。

共同选项(SendOptions):wait(默认 true,等上链确认)、timeout(默认 180 秒)、pollInterval(默认 2 秒,等待期间每 5 次重发一次原交易)、concurrency、gasMultiplier(gas 预估的放大倍数,默认 1.2)、gasPrice / maxFeePerGas / maxPriorityFeePerGas(不传按节点的 getFeeData)、dryRun、signal(取消:不再发新交易)、onUpdate(每个结果状态变化时回调,用于展示进度;回调抛错会被忽略,不影响发送)。

Tron:同样的 batchCall / batchTransfer / batchCollect,传 Tron 的 Provider 即可(地址可以用 T 开头的格式)。Tron 部分按需加载:打包工具做代码分割时(Vite / Rollup / webpack 默认如此),只用 EVM 不会带上它(约 17KB)。

const tron = new Provider(TRON_CHAIN_ID.mainnet, 'https://api.trongrid.io', { tron: { apiKey } })
await batchTransfer(tron, privateKey, [{ to: 'T…', formatted: '1.5' }, { to: 'T…', token: USDT, formatted: '20' }])
await batchCollect(tron, [key1, key2], hotWallet, { tokens: [USDT, NATIVE_TOKEN], gasWallet: gasKey })
  • 交易在本地按 protobuf 编码(与 TronWeb 逐字节一致)、本地签名,通过 /wallet/broadcasthex 广播:不让节点代为构造交易,节点无法替换交易内容
  • 每个调用一笔交易(Tron 上没有通用的 Disperse 合约,mode 不支持 '7702',gasMode 和调用里的 gasLimit 不起作用);Tron 没有 nonce,交易互不阻塞
  • 合约调用先用 triggerconstantcontract 模拟,revert 的不发送;fee_limit 默认按模拟的能量 × 能量单价 × 1.5(feeLimit 可以指定)
  • 发送前检查 TRX 够不够:Tron 上能量不够的合约调用照样上链、执行失败(OUT_OF_ENERGY)并烧掉 TRX,所以按每笔的金额 + 能量(× 1.2,扣掉质押的能量)+ 带宽 + 激活费累计,不够的那笔及之后在发送前就是 failed
  • 给合约地址转 TRX 时用合约调用(空 calldata 带 call_value),主网禁止用普通转账给合约转 TRX
  • 交易 60 秒过期(浏览器钱包 3 分钟)。过期判断按固化数据:同一个节点上固化区块的时间超过过期时间 + 60 秒、并且固化的交易信息里没有这笔,连续两次确认后才换新的参考区块重发,最多 retries 次(默认 2);查询出错时一律当作“不确定”,不会重发,避免重复执行
  • 广播时只有交易本身的问题(签名、合约校验、带宽不够、交易太大)判为 failed;其他错误码(如 TAPOS_ERROR、节点忙)可能只是某个节点的问题,按 sent 继续跟踪
  • 默认 pollInterval 3 秒,等待期间每 3 次重发一次原交易
  • 带宽按 java-tron 的规则算:质押带宽或免费带宽有一项够这笔交易用时不收费,两者不能凑在一起用,都不够时整笔按字节烧 TRX
  • 归集的手续费按上面的资源模型算;没激活的子钱包由补 gas 的转账激活(激活费约 1.1 TRX 由 gasWallet 付),补 gas 前先确认 gasWallet 付得起全部(含激活费),不够时什么都不发、直接报错;TRX 余额读不到的钱包不补 gas。扫空 TRX 时扣掉带宽费;归集地址是合约时还要预留能量,会剩一点零头
  • 结果的 hash 是交易 ID(不带 0x),gasLimit 是模拟的能量,gasUsed 是实际消耗的能量,fee 是实际烧掉的 sun;nonce 为 null
  • 钱包:私钥、ethers Wallet、自定义 { address, signDigest(digest) }(KMS / 硬件钱包),或浏览器钱包:注入的 window.tronWeb(TronLink / OKX 等)、@tronweb3/tronwallet-adapter 的 adapter
  • 浏览器钱包:每笔交易弹一次确认(Tron 钱包不支持一次签多笔),过期时间放宽到 3 分钟。钱包拿到的是 TronWeb 格式的交易 JSON(能通过 TronWeb 的 txCheck,和本地编码的 raw_data_hex 一致);签完后从签名恢复出地址核对,钱包切换了账户或改了交易时这笔失败。用户拒绝时 error 为 'rejected by user'
await window.tronLink.request({ method: 'tron_requestAccounts' }) // 先连接(TronLink)
await batchTransfer(new Provider(window.tronWeb), window.tronWeb, recipients)

智能账户批量(原子执行、代付 gas)

batchCall / batchTransfer(以及 batchCollect、swap)的 mode 增加三种,同一钱包的调用合成一笔原子执行的操作,gas 可以由别人付。底层积木在子路径 @w3lib/evm-batch-call/smart-account;这些模式的代码按需加载,不用时 /write 的体积不变。

| mode | 谁签什么 | 谁付 gas | 适用钱包 | | --- | --- | --- | --- | | '7702' + sponsor | 用户签 EIP-7702 授权(未委托时)+ UserOperation(EIP-712) | sponsor 钱包(用户不需要主币) | 能本地签名的钱包(私钥 / Wallet) | | '4337' | owner 签 UserOperation(v0.7 用 personal_sign,v0.8 用 EIP-712,浏览器钱包也行) | 账户自己 / paymaster / 自己打包的钱包 | 任何能签名的钱包 | | '5792' | 钱包弹一次确认 | 钱包决定 | 支持 EIP-5792 的浏览器钱包 |

// 1) 代付 gas 的 7702:用户没有主币,sponsor 付 gas;一个 UserOperation 里的转账原子执行
await batchTransfer(multi, userKey, recipients, { token: USDT, mode: '7702', sponsor: sponsorKey })
await batchCollect(multi, [kid1, kid2], hot, { tokens: [USDT, NATIVE_TOKEN], sponsor: sponsorKey }) // 子钱包不用补 gas,主币全额转走

// 2) ERC-4337:owner 对应的 SimpleAccount(首次使用时部署,资产放在账户地址里)
import { getSimpleAccount } from '@w3lib/evm-batch-call/smart-account'
const { address } = await getSimpleAccount(multi, owner)                 // 部署前就能算出
await batchTransfer(multi, ownerKey, recipients, {
  mode: '4337',
  smartAccount: {
    bundler: 'https://public.pimlico.io/v2/8453/rpc',                       // 或 BundlerClient
    paymaster: new PaymasterClient(pmUrl, { sponsorshipPolicyId }),         // ERC-7677;或 (op, ctx) => ({ paymaster, paymasterData, … })
    // account: 'simple7702' 让 UserOperation 的 sender 就是钱包自己(EntryPoint v0.8);entryPoint: '0.7' | '0.8'
    // selfBundle: key | true   不用 bundler:自己发 EntryPoint.handleOps 交易
  },
})

// 3) 浏览器钱包 EIP-5792:支持原子批量时一次确认,不支持时退回逐笔发送(result.atomic 为 false)
const signer = await new BrowserProvider(window.ethereum).getSigner()
await batchCall(multi, calls.map((c) => ({ from: signer, ...c })), { mode: '5792' })      // atomicRequired: true 时不退回、直接失败
  • EntryPoint v0.7 / v0.8:UserOperation 的打包、哈希(v0.8 是 EIP-712,