@pulsadev/multicall
v0.2.0
Published
High-performance, provider-agnostic multicall for EVM chains
Readme
@pulsadev/multicall
High-performance, provider-agnostic multicall for EVM chains.
Batch hundreds of contract reads into a single RPC call. Zero runtime dependencies. Works with viem, ethers v5, ethers v6, or a plain RPC URL.
Features
- Provider agnostic — viem, ethers v5, ethers v6, EIP-1193, or raw RPC URL
- Multicall3 — uses
aggregate3with per-callallowFailure - Auto-chunking — splits large batches by call count and data size
- Retry + fallback — exponential backoff with optional fallback to individual calls
- Revert decoding — human-readable
Error(string)andPanic(uint256)messages - Result caching — TTL-based cache to avoid redundant calls
- 70+ chains — Ethereum, Arbitrum, Optimism, Base, Polygon, BSC, zkSync, and more
- AbortSignal — cancel in-flight requests
- Zero dependencies — pure keccak256 implementation, no external packages
- Tiny — ~31 KB bundled (ESM + CJS + full TypeScript declarations)
Install
npm install @pulsadev/multicall
# or
pnpm add @pulsadev/multicall
# or
yarn add @pulsadev/multicallQuick Start
With viem
import { Multicall } from '@pulsadev/multicall'
import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
const client = createPublicClient({ chain: mainnet, transport: http() })
const mc = new Multicall(client)
const results = await mc.call([
{
target: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC
allowFailure: true,
callData: '0x18160ddd', // totalSupply()
},
{
target: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
allowFailure: true,
callData: '0x06fdde03', // name()
},
])
for (const r of results) {
if (r.status === 'success') {
console.log('Result:', r.result)
} else {
console.log('Reverted:', r.error?.reason)
}
}With ethers v6
import { Multicall } from '@pulsadev/multicall'
import { JsonRpcProvider } from 'ethers'
const provider = new JsonRpcProvider('https://eth.llamarpc.com')
const mc = new Multicall(provider)
const balance = await mc.getEthBalance('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045')
console.log('Balance:', balance)With a plain RPC URL
import { Multicall } from '@pulsadev/multicall'
const mc = new Multicall('https://eth.llamarpc.com')
const blockNumber = await mc.getBlockNumber()
console.log('Block:', blockNumber)API
new Multicall(provider, options?)
Create a new multicall instance.
Provider — any of the following:
- viem
PublicClient - ethers v6
JsonRpcProvider - ethers v5
Provider - EIP-1193 provider (
window.ethereum) - RPC URL string (
https://...orwss://...)
Options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| chainId | number | auto-detected | Override chain ID |
| multicallAddress | Address | canonical | Custom Multicall3 address |
| blockTag | string \| bigint | 'latest' | Block to query |
| allowFailure | boolean | true | Allow individual calls to fail |
| chunk.maxCallsPerChunk | number | 256 | Max calls per batch |
| chunk.maxDataSizePerChunk | number | 65536 | Max calldata bytes per batch |
| retry.maxRetries | number | 3 | Number of retries |
| retry.backoffMs | number | 200 | Initial backoff delay |
| retry.backoffMultiplier | number | 2 | Backoff multiplier |
| retry.fallbackToIndividual | boolean | false | Fallback to individual calls on batch failure |
| cache.enabled | boolean | false | Enable result caching |
| cache.ttlMs | number | 5000 | Cache TTL in milliseconds |
| signal | AbortSignal | — | Abort signal for cancellation |
mc.call(calls, options?)
Execute a batch of calls using aggregate3.
const results = await mc.call([
{ target: '0x...', allowFailure: true, callData: '0x...' },
{ target: '0x...', allowFailure: true, callData: '0x...' },
])
// returns: MulticallResult[]
// { status: 'success', result: '0x...' }
// { status: 'failure', error: { reason: 'Insufficient balance', data: '0x...' } }mc.aggregate3(calls, options?)
Low-level aggregate3 call. Returns raw { success, returnData } tuples.
const raw = await mc.aggregate3([
{ target: '0x...', allowFailure: true, callData: '0x...' },
])
// returns: { success: boolean, returnData: Hex }[]mc.tryAggregate(requireSuccess, calls, options?)
Execute using tryAggregate. Set requireSuccess to false for partial failure tolerance.
mc.getBlockNumber(options?)
Get the current block number via Multicall3.
mc.getEthBalance(address, options?)
Get the ETH balance of an address via Multicall3.
mc.getBasefee(options?)
Get the current block base fee via Multicall3.
mc.getChainId()
Get the chain ID (auto-detected or from options).
mc.clearCache()
Clear the internal result cache.
Encoding Helpers
Build calldata without importing ethers or viem:
import { encodeFunctionData, functionSelector, decodeFunctionResult } from '@pulsadev/multicall'
// Get function selector
const sel = functionSelector('balanceOf(address)')
// '0x70a08231'
// Encode full calldata
const data = encodeFunctionData('balanceOf(address)', [
'0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
])
// Decode result
const [balance] = decodeFunctionResult(resultHex, ['uint256'])Chain Support
All chains with a canonical Multicall3 deployment are supported out of the box. For chains with non-standard deployments (e.g., zkSync Era), addresses are mapped automatically.
import { getSupportedChainIds, getMulticall3Address } from '@pulsadev/multicall'
const chains = getSupportedChainIds()
// [1, 10, 137, 42161, 8453, ...]
const addr = getMulticall3Address(324) // zkSync Era
// '0xF9cda624FBC7e059355ce98a31693d299FACd963'| Network | Chain ID | Status | |---------|----------|--------| | Ethereum | 1 | ✅ | | Optimism | 10 | ✅ | | BSC | 56 | ✅ | | Polygon | 137 | ✅ | | Fantom | 250 | ✅ | | zkSync Era | 324 | ✅ | | Arbitrum One | 42161 | ✅ | | Avalanche | 43114 | ✅ | | Base | 8453 | ✅ | | Linea | 59144 | ✅ | | Scroll | 534352 | ✅ | | Blast | 81457 | ✅ | | Mantle | 5000 | ✅ | | ... and 60+ more | | ✅ |
For unlisted chains, the canonical Multicall3 address (0xcA11bde05977b3631167028862bE2a173976CA11) is used by default.
Advanced Usage
Auto-chunking
Large batches are automatically split to stay within RPC limits:
const mc = new Multicall(provider, {
chunk: {
maxCallsPerChunk: 100,
maxDataSizePerChunk: 32_768,
},
})Retry with fallback
Retry failed batches with exponential backoff. Optionally fall back to individual calls:
const mc = new Multicall(provider, {
retry: {
maxRetries: 3,
backoffMs: 200,
backoffMultiplier: 2,
fallbackToIndividual: true,
},
})Result caching
Cache results to avoid redundant RPC calls:
const mc = new Multicall(provider, {
cache: { enabled: true, ttlMs: 10_000 },
})
// First call hits RPC
await mc.aggregate3(calls)
// Second call within 10s returns cached result
await mc.aggregate3(calls)
// Clear manually
mc.clearCache()Cancellation
Cancel in-flight requests with AbortSignal:
const controller = new AbortController()
setTimeout(() => controller.abort(), 5000)
const results = await mc.call(calls, {
signal: controller.signal,
})Benchmarks
Measured on a dedicated VPS (Dallas, 4 vCPU, 8 GB RAM) against Ethereum mainnet via publicnode RPC. Median of 10 runs after 3 warmup runs.
| Calls | @pulsadev/multicall | viem built-in | Difference | |------:|:-------------------:|:------------:|:----------:| | 10 | 55ms | 56ms | — | | 50 | 56ms | 60ms | 8% faster | | 100 | 59ms | 97ms | 39% faster | | 200 | 68ms | 84ms | 20% faster | | 500 | 79ms | 111ms | 29% faster |
Run benchmarks yourself:
node bench/bench_final.mjs
Comparison
| Feature | @pulsadev/multicall | viem built-in | ethereum-multicall | ethcall | |---------|:------------------:|:------------:|:-----------------:|:------:| | Provider agnostic | ✅ | ❌ viem only | ❌ ethers v5 | ❌ ethers v6 | | Multicall3 aggregate3 | ✅ | ✅ | ❌ MC2 only | ✅ | | Auto-chunking | ✅ | ✅ bytes only | ❌ | ❌ | | Retry + fallback | ✅ | ❌ | ❌ | ❌ | | Revert reason decoding | ✅ | ❌ | ❌ | ❌ | | Result caching | ✅ | ❌ | ❌ | ❌ | | AbortSignal | ✅ | ❌ | ❌ | ❌ | | Zero dependencies | ✅ | ❌ | ❌ | ❌ | | Bundle size | ~31 KB | part of viem | 131 KB | 231 KB | | Maintained | ✅ | ✅ | ❌ | ❌ |
License
MIT © Yuto Nakamura
