A Go library for batch calling Ethereum contracts using Multicall3 and abigen v2.
- Type-safe contract calls with any abigen v2 binding
- Generic
batch.Add(...)returningResult[T](Go 1.27) - Automatic chunking and concurrent execution
- Retry of failed calls without re-sending successes
- Per-result block info (
Result.Block) for consistency checks - Revert reason decoding
go get github.com/0x0001/multicallpackage main
import (
"context"
"fmt"
"log"
"github.com/0x0001/multicall"
"github.com/0x0001/multicall/bindings"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/ethclient"
)
func main() {
client, err := ethclient.Dial("https://ethereum-rpc.publicnode.com")
if err != nil {
log.Fatal(err)
}
mc := multicall.New(client)
batch := mc.NewBatch()
// Define token addresses
usdtAddr := common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
usdcAddr := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")
// Create ERC20 codec (generated with abigen --v2)
erc20 := bindings.NewErc20()
// Add calls to batch
usdtName := batch.Add(usdtAddr, erc20.TryPackName, erc20.UnpackName)
usdcName := batch.Add(usdcAddr, erc20.TryPackName, erc20.UnpackName)
// Execute the batch
if err := batch.Execute(context.Background()); err != nil {
log.Fatal(err)
}
fmt.Printf("USDT: %s\n", usdtName.Value)
fmt.Printf("USDC: %s\n", usdcName.Value)
}Add, AddWithCallback and AddCall are now methods on *Batch instead of package-level functions (requires a Go 1.27+ toolchain). The old functions still work but are deprecated; rewrite call sites with the bundled fixer:
go run github.com/0x0001/multicall/cmd/multicallfix@latest -fix ./...Behavior changes: Execute consumes the batch — a second Execute without new calls returns ErrBatchExecuted (ErrBatchInProgress while the first is still running; see Retries), failed chunks are joined with errors.Join, and revert-reason formatting changed. The default underlying method is now tryBlockAndAggregate (same semantics as aggregate3, plus block reporting — see Underlying Method); Result gained a Block field and WithAggregate() is deprecated in favor of WithMethod(MethodAggregate).
AddWithCallback pushes each unpacked result to a callback at Execute time instead of returning a Result:
batch.AddWithCallback(contractAddr,
erc20.TryPackName,
erc20.UnpackName,
func(name string, err error) {
if err != nil {
log.Printf("Error: %v", err)
return
}
fmt.Println("Name:", name)
},
)
batch.Execute(context.Background()) // callbacks invokedAddWithResult is the callback variant receiving the full Result, Block included.
Callbacks run on their chunk's goroutine, so callbacks of different calls may fire concurrently — guard any state they share. They must not panic: on a single-chunk batch the panic propagates out of Execute (or Retry); on a concurrent chunk it crashes the process.
Use abigen to generate Go bindings for any contract:
# Install abigen
go install github.com/ethereum/go-ethereum/cmd/abigen@latest
# Generate bindings for your contract
abigen \
--abi ./MyContract.json \
--pkg mycontract \
--type MyContract \
--out mycontract.go \
--v2Then instantiate it with mycontract.NewMyContract() and pass its TryPack*/Unpack* methods to batch.Add like any other binding.
Customize the Multicall client:
mc := multicall.New(client,
multicall.WithAddress(customAddress), // Custom Multicall3 address
multicall.WithBatchSize(50), // Max calls per batch (default: 30)
multicall.WithConcurrency(5), // Concurrent batches (default: 3)
multicall.WithAllowFailure(false), // Fail on individual errors (default: true)
multicall.WithLogger(myLogger), // Diagnostics; nil logs nothing (default)
)WithLogger wires a diagnostic logger; the default is fully silent. Successful chunks log at debug level (method, call count, block, duration); chunk failures, cancellations and short responses at warn. Adapting log/slog is two one-line methods:
type slogLogger struct{ l *slog.Logger }
func (s slogLogger) Debugf(format string, args ...any) { s.l.Debug(fmt.Sprintf(format, args...)) }
func (s slogLogger) Warnf(format string, args ...any) { s.l.Warn(fmt.Sprintf(format, args...)) }
mc := multicall.New(client, multicall.WithLogger(slogLogger{slog.Default()}))The library sends your calls through one of Multicall's aggregate methods, selected with WithMethod:
mc := multicall.New(client,
multicall.WithMethod(multicall.MethodBlockAndAggregate),
)| Method | Failure semantics | Block info | Works on |
|---|---|---|---|
MethodTryBlockAndAggregate (default) |
failure tolerance via AllowFailure |
✅ | Multicall2+ |
MethodBlockAndAggregate |
any failure reverts the whole chunk | ✅ | Multicall2+ |
MethodAggregate3 |
per-call tolerance via AllowFailure |
❌ | Multicall3 |
MethodAggregate |
any failure reverts the whole chunk | number only | Multicall1+ |
The default reports the block each chunk landed on, so every Result carries Block (see below). If the address you configured with WithAddress predates Multicall2, fall back to MethodAggregate — the greatest common denominator every Multicall variant supports.
AllowFailure maps to requireSuccess with MethodTryBlockAndAggregate and to the per-call flag with MethodAggregate3 — but under the library's single global setting, both methods behave identically: true isolates a failed call in its own Result, false reverts the whole chunk. The two methods would only differ for mixed per-call tolerance within one request, which the library has never exposed.
WithAggregate() is deprecated; WithMethod(MethodAggregate) is the exact equivalent, and go fix rewrites old call sites.
Chunks run concurrently and may land on different blocks. Every Result (and every AddWithResult callback) carries Result.Block — the block its chunk landed on — so consistency is checkable after Execute:
fmt.Println(result.Block.Number, result.Block.Hash)Calls in the same chunk share one block. To force the whole batch to read a single block, pin it with mc.NewBatchWithOpts(big.NewInt(18000000)).
Block.Number is nil when the call never landed on a block (failed chunk, pack error) or the method reports none (MethodAggregate3).
With the default AllowFailure=true, a failed call surfaces as CallFailedError in its own Result — including the block it failed on — and the rest still succeeds. With AllowFailure=false (or a strict method), one failing call reverts its whole chunk: every Result in it carries that error, and Execute returns it joined.
Query blockchain state using Multicall3's built-in methods:
import "github.com/0x0001/multicall"
multicallAddr := common.HexToAddress(multicall.DefaultMulticall3Address)
blockNumber := batch.Add(multicallAddr,
multicall.TryPackGetBlockNumber,
multicall.UnpackGetBlockNumber,
)
balance := batch.Add(multicallAddr,
func() ([]byte, error) { return multicall.TryPackGetEthBalance(addr) },
multicall.UnpackGetEthBalance,
)
batch.Execute(context.Background())
fmt.Println("Block:", blockNumber.Value)
fmt.Println("Balance:", balance.Value)All Multicall3 view methods are exposed as TryPack*/Unpack* package functions (block number/hash, chain id, basefee, timestamp, ETH balance, …).
When you only need success/failure status:
result := batch.AddCall(contractAddr,
myContract.TryPackMyMethod,
)
batch.Execute(context.Background())
if result.Ok {
fmt.Println("Success!")
fmt.Printf("Raw data: %x\n", result.Value)
}// Check if an error is a call failure
if multicall.IsCallFailed(err) {
reason := multicall.RevertReason(err)
fmt.Printf("Call failed: %s\n", reason)
}
// Check individual results
if !result.Ok {
if multicall.IsCallFailed(result.Err) {
fmt.Printf("Revert reason: %s\n", multicall.RevertReason(result.Err))
}
}Transient failures — rate limits, timeouts, network blips — are worth re-sending; deterministic reverts usually are not. Retry re-sends exactly the calls that failed the last execution and writes the outcome back into the same Results; the retry policy stays with you. The method docs carry the full semantics — callback re-entrancy, batch lifecycle, block reporting.
MIT