@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
Maintainers
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为该地址)的 ERC20Transfer事件;不发标准Transfer事件的代币发现不了;Tron 不支持
价格
prices: true 用 DefiLlama 查美元价格(免费、不需要 Key),结果带 price / value,并按价值从高到低排序;minUsd 只保留价值不低于它的代币(没有价格的代币也会被去掉,主币始终保留)。只传 minUsd 时会自动开启 DefiLlama 价格;与 prices: false 同时使用会报错。也可以传自己的价格源:prices: { name, prices: async ({ chainId, tokens, native }) => Map }。
局限性
使用前请了解:
- 默认来源只能发现公开列表里的代币。刚发行、还没被列表收录的代币(如新上线的 meme 币)、小众代币查不到;需要完整持仓时,传
alchemy({ apiKey })或nodereal({ apiKey }) - 默认来源依赖第三方免费服务(MetaMask、CoinGecko 的列表,DefiLlama 的价格):它们可能限流、变更格式或停止服务;MetaMask 列表接口不是对外承诺的公开 API。生产环境建议组合多个来源,或使用自己维护的列表(
tokenList(url)) - 列表里也有垃圾币和零头。默认只用至少 3 家来源收录的代币来减少垃圾币,但仍会有不少价值很低的代币;需要干净的列表时开启
prices并设置minUsd - 价格不一定可信。流动性很差的代币价格可能严重虚高(实测有空投代币被估值数十万美元),默认已过滤 DefiLlama 置信度低于 0.9 的价格,但展示总资产时仍建议使用自己的价格源或再做校验
- 需要查几千到两万个代币的余额,耗时几秒:实测 Ethereum(约 9500 个代币)首次约 11 秒、之后约 6 秒,BSC(约 4800 个)首次约 8 秒、之后约 4 秒(首次要在链上核对持有代币的精度,之后有缓存)。大量使用时建议缓存结果;列表很大时也会占用较多节点请求
- 只包含 ERC20 代币,不含 NFT(NFT 请用
getNftBalances等);Tron 链没有默认来源,需要自己传source - 使用 Alchemy / NodeReal 时受它们的免费额度和限频约束,翻页次数默认最多 20 页(2000 个代币),可用
maxPages调整 - 开启
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/amount1Mindeadline:交易有效期(秒),默认 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时带美元价值,按价值排序),来源出错只记进errorsbuildClaimAll(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:一笔最多合并多少个仓位,默认 20claimAll的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在公共节点上只能扫最近几百个区块;要完整发现请用自己的节点,或把已知的代币传给tokensprices(默认 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、ERC20transfer),多笔交易之间状态不延续(结果里有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继续跟踪 - 默认
pollInterval3 秒,等待期间每 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,
