Developing Multicall Contracts (Batch Operations)
Reading state from ten contracts means ten RPC requests, ten round-trips to a node. On public endpoints, this is 2-5 seconds of loading time for a dApp. Multicall solves this from both sides: for reads — aggregating requests into one RPC call, for writes — multiple on-chain operations in one transaction.
Multicall for Reading: eth_call Aggregation
Multicall3 is deployed at address 0xcA11bde05977b3631167028862bE2a173976CA11 on 50+ networks. Takes an array of (address target, bytes callData), executes all call-s and returns results. Important: it's read-only from the user's perspective, but Multicall3 uses aggregate3 — can be called as state-changing for writing or as a pure read via eth_call.
Typical use via wagmi/viem:
import { useReadContracts } from 'wagmi';
const { data } = useReadContracts({
contracts: [
{ address: token1, abi: erc20Abi, functionName: 'balanceOf', args: [user] },
{ address: token2, abi: erc20Abi, functionName: 'balanceOf', args: [user] },
{ address: pool, abi: poolAbi, functionName: 'getReserves' },
{ address: oracle, abi: oracleAbi, functionName: 'latestAnswer' },
]
});
// one RPC request instead of four
wagmi uses Multicall3 under the hood via multicall batching — all calls in one useReadContracts collapse into one eth_call to Multicall3. If the network doesn't support Multicall3, wagmi falls back to parallel eth_call-s.
Custom Multicall: When Multicall3 Doesn't Fit
Multicall3 doesn't store state, doesn't check authorization, doesn't support native ETH for separate calls. For write operations with custom logic, we write our own contract.
The self-multicall pattern — a contract calls itself through multiple functions in one transaction. OpenZeppelin implements this via Multicall.sol:
abstract contract Multicall {
function multicall(bytes[] calldata data)
external
virtual
returns (bytes[] memory results)
{
results = new bytes[](data.length);
for (uint256 i = 0; i < data.length; i++) {
(bool success, bytes memory result) = address(this).delegatecall(data[i]);
require(success, _getRevertMsg(result));
results[i] = result;
}
}
}
delegatecall to address(this) — the contract calls its own functions on behalf of the original msg.sender. This allows doing approve + deposit in one call, where both steps see the same msg.sender.
Critical warning: delegatecall to address(this) with user-provided data — potential vulnerability. If a contract is not isolated from privileged functions, an attacker can craft data calling transferOwnership. OpenZeppelin's Multicall explicitly limits use through documentation: don't use with contracts where authorization depends on msg.sender in privileged functions, unless they are protected separately.
Stateful Multicall: Atomic Multi-Step Operations
For complex DeFi operations (flash loan → swap → repay), we need a contract that holds intermediate state between calls and rolls back everything if any step fails.
contract AtomicBatcher {
struct Step {
address target;
bytes callData;
uint256 value;
uint256 minReturnValue; // result check
}
function executeBatch(Step[] calldata steps)
external
payable
returns (bytes[] memory results)
{
results = new bytes[](steps.length);
for (uint256 i = 0; i < steps.length; i++) {
(bool success, bytes memory result) = steps[i].target.call{
value: steps[i].value
}(steps[i].callData);
require(success, string(abi.encodePacked("Step ", i, " failed")));
if (steps[i].minReturnValue > 0) {
uint256 returnValue = abi.decode(result, (uint256));
require(returnValue >= steps[i].minReturnValue, "Slippage exceeded");
}
results[i] = result;
}
// return unspent ETH
if (address(this).balance > 0) {
(bool sent,) = msg.sender.call{value: address(this).balance}("");
require(sent);
}
}
}
minReturnValue — built-in slippage protection on each step. Swap returned less than minimum — entire transaction reverts.
When Custom Contract Is Needed Instead of Multicall3
| Scenario | Multicall3 | Custom |
|---|---|---|
| Aggregating read requests | Sufficient | Excessive |
| Multiple ERC-20 transfers | Sufficient | Excessive |
| Approve + protocol action (one token) | Sufficient | Sufficient |
| Flash loan + arbitrage + repay | Not suitable | Needed |
| Conditional actions (if step X > Y) | Not suitable | Needed |
| Distributing ETH to addresses with different amounts | Not suitable | Needed |
Developing a custom Multicall contract with test coverage: 1-3 days depending on result verification logic complexity.







