Caution
This code has not been audited. Use at your own risk. No warranty is provided, express or implied. Do not deploy to production without an independent security review.
Never approve ERC-20 tokens to Multicall4 directly. See Dangers and mitigations.
A Turing-complete call batcher for atomic execution of dynamic user workflows onchain.
Existing multicall primitives (e.g. multicall3, protocol-specific batchers) share the same shape: a hardcoded list of (target, calldata) pairs, executed sequentially with results returned as an array. That covers the easy case - independent reads or independent writes fanned out to save round trips, but it breaks down the moment the actions become dependent on each other.
Concrete examples where the flat-list model fails:
- Approve exactly the amount a router quoted, then swap using that same amount, in one transaction.
- Read a pool's reserves, compute the optimal input, and execute the swap without a second RPC round trip or a stale-quote race.
- Deploy a contract via
CREATE2and immediately initialize it at its own predicted address in the same call. - Try one execution path; if it reverts, fall back to another without leaking the failure to the outer caller.
The current workarounds either split calls into multiple transactions, which loses atomicity, exposes an MEV surface, and introduces stale-data races, or require a bespoke Solidity contract per workflow. The underlying gap is expressiveness. On-chain execution primitives don't offer the atomicity guarantees dependent workflows need. The natural evolution of multicall, the one that closes the gap end-to-end, is to make the flow Turing-complete: give the program branching, arithmetic, and inline data flow between calls, all resolved on-chain at execution time, atomically.
Multicall4 is a Turing-complete call batcher for atomic execution of dynamic user workflows onchain. Under the hood it is a small bytecode interpreter: a single external entrypoint, multicall(bytes calldata program), reads program as a sequence of opcodes over a byte stack and executes it inside one transaction.
program is a linear stream of opcode bytes and their inline operands. Each opcode reads its arguments from the top of the stack (and, for PUSH, from the program itself), performs a bounded amount of work, and pushes any results back onto the stack. Multicall4 gives the caller a scriptable environment where each call can:
- Branch on the outcome of the previous call,
- Feed the return data of one call into the calldata of the next,
- Compute values inline instead of pre-baking them into calldata (arithmetic, bitwise, hashing),
- Deploy contracts and immediately use their address (
CREATE,CREATE2), - Decide at runtime whether to
RETURNa chosen slice of the stack orREVERTwith a chosen reason.
The stack is untyped: everything is bytes. Operands are big-endian and their widths are fixed per-opcode. Program indexes and stack offsets are uint32, so a program cannot exceed ~4 GiB. Transaction calldata itself hits this limit long before.
Byte counts in Inline / Pops / Pushes cells: the first line is the total; subsequent lines number each field with its width in bytes.
| Byte | Opcode | Inline (bytes) | Pops (bytes) | Pushes (bytes) | Description |
|---|---|---|---|---|---|
0x00 |
PUSH |
4 + length1. length (4)2. data (length) |
0 | length1. data (length) |
Copies length bytes of inline program data onto the top of the stack. |
0x01 |
JUMPI |
0 | 5 1. condition (1)2. jumpIndex (4) |
0 | If condition != 0, sets the program counter to jumpIndex. Otherwise falls through. |
0x02 |
RETURN |
0 | 8 1. offset (4)2. size (4) |
0 | Halts and returns size bytes of the current stack starting at offset. |
0x03 |
REVERT |
0 | 8 1. offset (4)2. size (4) |
0 | Reverts with size bytes of the current stack starting at offset as the revert reason. |
0x04 |
PC |
0 | 0 | 4 1. current program counter (4) |
Pushes the current program counter. |
0x05 |
CALL |
0 | 56 + length1. calldata (length)2. to (20)3. value (32)4. length (4) |
5 + return-data length 1. return data (return-data length) 2. return-data length (4) 3. success flag (1) |
External call to to with value wei and calldata. Downstream msg.sender is Multicall4 itself. |
0x06 |
CREATE |
0 | 36 + length1. initcode (length)2. value (32)3. length (4) |
21 1. deployed address (20, zero on failure) 2. success flag (1) |
Deploys a new contract via CREATE with value wei and initcode. |
0x07 |
CREATE2 |
0 | 68 + length1. initcode (length)2. value (32)3. salt (32)4. length (4) |
21 1. deployed address (20, zero on failure) 2. success flag (1) |
Deploys a new contract via CREATE2 with value wei, salt, and initcode. |
0x08 |
DUP |
0 | 8 1. offset (4)2. size (4) |
size1. bytes of the resulting stack starting at offset (size) |
Duplicates size bytes at offset onto the top of the stack. |
0x09 |
SWAP |
0 | 8 1. offset (4)2. size (4) |
0 | Swaps the size bytes at offset with the top size bytes of the stack. Reverts if the two regions overlap. |
0x0a |
POP |
0 | 8 1. offset (4)2. size (4) |
0 | Removes size bytes at offset from the stack. |
0x0b |
BLOCKHASH |
0 | 32 1. blockNumber (32) |
32 1. blockhash(blockNumber) (32) |
Pushes blockhash(blockNumber) (zero for blocks outside the last 256). |
0x0c |
COINBASE |
0 | 0 | 20 1. block.coinbase (20) |
Pushes block.coinbase. |
0x0d |
BASEFEE |
0 | 0 | 32 1. block.basefee (32) |
Pushes block.basefee. |
0x0e |
PREVRANDAO |
0 | 0 | 32 1. block.prevrandao (32) |
Pushes block.prevrandao. |
0x0f |
CHAINID |
0 | 0 | 32 1. block.chainid (32) |
Pushes block.chainid. |
0x10 |
GASLIMIT |
0 | 0 | 32 1. block.gaslimit (32) |
Pushes block.gaslimit. |
0x11 |
NUMBER |
0 | 0 | 32 1. block.number (32) |
Pushes block.number. |
0x12 |
TIMESTAMP |
0 | 0 | 32 1. block.timestamp (32) |
Pushes block.timestamp. |
0x13 |
GAS |
0 | 0 | 32 1. gasleft() (32) |
Pushes gasleft(). |
0x14 |
ADDRESS |
0 | 0 | 20 1. address(this) (20) |
Pushes address(this) (i.e. Multicall4). |
0x15 |
CALLER |
0 | 0 | 20 1. msg.sender (20) |
Pushes msg.sender of the enclosing multicall(...) call. |
0x16 |
CALLVALUE |
0 | 0 | 32 1. msg.value (32) |
Pushes msg.value of the enclosing multicall(...) call. |
0x17 |
GASPRICE |
0 | 0 | 32 1. tx.gasprice (32) |
Pushes tx.gasprice. |
0x18 |
ORIGIN |
0 | 0 | 20 1. tx.origin (20) |
Pushes tx.origin. |
0x19 |
BALANCE |
0 | 20 1. address (20) |
32 1. address.balance (32) |
Pushes the native balance of address. |
0x1a |
EXTCODESIZE |
0 | 20 1. address (20) |
32 1. address.code.length (32) |
Pushes the deployed code size of address. |
0x1b |
EXTCODECOPY |
0 | 28 1. address (20)2. offset (4)3. length (4) |
length1. address.code[offset : offset + length] (length) |
Pushes length bytes of address's deployed code starting at offset. Out-of-range bytes read as zero (EVM semantics). |
0x1c |
EXTCODEHASH |
0 | 20 1. address (20) |
32 1. keccak256(address.code) (32) |
Pushes keccak256(address.code) (zero if account has no code, per EVM semantics). |
0x1d |
SHA3 |
0 | 8 1. offset (4)2. size (4) |
32 1. keccak256(stack[offset : offset + size]) (32) |
Computes keccak256 over size bytes of the current stack starting at offset. |
0x1e |
ADD |
0 | 64 1. a (32)2. b (32) |
32 1. a + b (32) |
Wrapping unsigned addition. |
0x1f |
SUB |
0 | 64 1. a (32)2. b (32) |
32 1. a - b (32) |
Wrapping unsigned subtraction. |
0x20 |
MUL |
0 | 64 1. a (32)2. b (32) |
32 1. a * b (32) |
Wrapping unsigned multiplication. |
0x21 |
DIV |
0 | 64 1. a (32)2. b (32) |
32 1. a / b (32) |
Unsigned division. |
0x22 |
SDIV |
0 | 64 1. a (32)2. b (32) |
32 1. a / b (32) |
Signed division. |
0x23 |
MOD |
0 | 64 1. a (32)2. b (32) |
32 1. a % b (32) |
Unsigned modulo. |
0x24 |
SMOD |
0 | 64 1. a (32)2. b (32) |
32 1. a % b (32) |
Signed modulo. |
0x25 |
EXP |
0 | 64 1. a (32)2. b (32) |
32 1. a ** b (32) |
Wrapping unsigned exponentiation. |
0x26 |
ADDMOD |
0 | 96 1. a (32)2. b (32)3. n (32) |
32 1. (a + b) % n (32) |
(a + b) % n with arbitrary-precision intermediate (unsigned). |
0x27 |
MULMOD |
0 | 96 1. a (32)2. b (32)3. n (32) |
32 1. (a * b) % n (32) |
(a * b) % n with arbitrary-precision intermediate (unsigned). |
0x28 |
EQ |
0 | 64 1. a (32)2. b (32) |
1 1. a == b flag (1) |
Equality. |
0x29 |
GT |
0 | 64 1. a (32)2. b (32) |
1 1. a > b flag (1) |
Unsigned greater-than. |
0x2a |
SGT |
0 | 64 1. a (32)2. b (32) |
1 1. a > b flag (1) |
Signed greater-than. |
0x2b |
LT |
0 | 64 1. a (32)2. b (32) |
1 1. a < b flag (1) |
Unsigned less-than. |
0x2c |
SLT |
0 | 64 1. a (32)2. b (32) |
1 1. a < b flag (1) |
Signed less-than. |
0x2d |
NOT |
0 | 32 1. value (32) |
32 1. ~value (32) |
Bitwise negation. |
0x2e |
AND |
0 | 64 1. a (32)2. b (32) |
32 1. a & b (32) |
Bitwise AND. |
0x2f |
OR |
0 | 64 1. a (32)2. b (32) |
32 1. a | b (32) |
Bitwise OR. |
0x30 |
XOR |
0 | 64 1. a (32)2. b (32) |
32 1. a ^ b (32) |
Bitwise XOR. |
0x31 |
SHL |
0 | 64 1. a (32)2. b (32) |
32 1. a << b (32) |
Left shift. |
0x32 |
SHR |
0 | 64 1. a (32)2. b (32) |
32 1. a >> b (32) |
Logical right shift. |
0x33 |
SAR |
0 | 64 1. a (32)2. b (32) |
32 1. a >> b (32) |
Arithmetic right shift. |
Multicall4 is a monolith: every opcode is implemented inline in one contract. A plugin architecture was considered, where each opcode lives in its own contract and Multicall4 acts as a dispatcher that delegatecalls into the user-supplied opcode contract per instruction. That shape would make new opcodes hot-pluggable: any Solidity version could ship a new EVM instruction (say, a future MCOPY2, TSTORE2, or a curve precompile wrapper) and users could adopt it by pointing at a freshly-deployed opcode contract without waiting for a Multicall4 redeploy.
Ultimately the monolith was chosen for gas. The plugin variant costs additional ~700 warm / ~2600 cold gas per opcode for the delegatecall alone. Programs run tens to hundreds of opcodes per invocation, so the overhead compounds into tens of thousands of gas per multicall. That is a permanent tax paid on every call, in exchange for flexibility.
Multicall4 is the family name for the underlying stack-machine architecture. Individual opcode-set revisions are versioned as Multicall4V1, Multicall4V2, and so on. When new revisions land, the current revision is not upgraded in place: a new contract file (Multicall4V2.sol) is deployed at a new deterministic address. Consumers pin whichever revision has the opcodes they need.
multicall(bytes)ABI stays byte-compatible across revisions. Opcode set and byte assignments are not guaranteed stable across revisions: opcodes may be added, removed, renumbered, or have their semantics changed. Consumers pin the revision they target and only migrate deliberately.- Each revision's opcode set and per-chain address are listed in this repo's README under the revision's git tag.
- The npm package tracks the same numbering: the major version of
@juglipaff/multicall4matches the revision suffix (1.x.y→Multicall4V1,2.x.y→Multicall4V2, …).
This trades a small amount of ecosystem bookkeeping (docs + registry updates on new releases) for permanent gas savings on every call.
The contract holds no persistent storage. Every invocation starts from a clean slate and the stack lives only for the duration of the call, so the blast radius of any error is bounded by the single transaction that triggers it. This does not make misuse safe though.
Multicall4 is permissionless - any address can send it any program. An approve(multicall4, N) from Alice is not scoped to Alice. Any transaction from any address can craft a program that calls token.transferFrom(Alice, attacker, N) and drain her balance. This is the same pitfall as any router-style contract that shares an allowance across users, magnified because Multicall4 executes user-supplied bytecode. Do not approve tokens directly to Multicall4 on any chain.
Intended safe usage is a per-user execution proxy (WIP). The proxy holds the ERC-20 allowances instead of Multicall4, accepts programs only from its owner, validates them, and only then forwards to Multicall4.multicall(...). Under this pattern Multicall4 remains the shared execution engine but never holds an allowance from any user.
Runtime (Solidity):
@openzeppelin/contracts- usesutils/Bytes.sol(for byte array slices).
Development / testing:
forge-std- Foundry standard library (Test,console, cheatcodes).foundry- build, test, coverage, formatter. Install viafoundryup.
Foundry pulls Solidity dependencies as git submodules under lib/.
npm install @juglipaff/multicall4Pre-generated ABIs ship in the abi/ directory of the npm package for off-chain clients (ethers.js, viem, wagmi, web3.js, etc.):
import Multicall4V1 from "@juglipaff/multicall4/abi/Multicall4V1.json";
import IMulticall4V1 from "@juglipaff/multicall4/abi/IMulticall4V1.json";TypeScript projects can import the JSON directly with resolveJsonModule enabled in tsconfig.json. ABI files are regenerated on every release, so no post-install compilation is required.
Programs are built off-chain. A program is a raw bytes blob (a concatenation of opcode bytes and their inline operands) passed to multicall(bytes). The example below uses viem to batch two ERC-20 transferFrom calls into one transaction.
Warning
This example approves Multicall4V1 to spend an ERC-20 balance for illustration only. Do not do this on any live chain. Approving tokens to Multicall4V1 directly is unsafe. See Dangers and mitigations.
import {
createWalletClient,
encodeFunctionData,
encodePacked,
erc20Abi,
http,
parseAbi,
type Address,
type Hex,
} from "viem";
import { mainnet } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import Multicall4V1Abi from "@juglipaff/multicall4/abi/Multicall4V1.json";
// Opcode byte values. Order matches the Opcode enum in IMulticall4V1.sol.
const PUSH = 0x00;
const CALL = 0x05;
// Encodes: PUSH <calldata>, PUSH <to>, PUSH <value>, PUSH <length>, CALL.
// CALL pop order (bottom-to-top of stack): calldata, to, value, length.
// PUSH header layout: opcode (1 byte) + length (uint32, 4 bytes) + data (`length` bytes).
function encodeCall(to: Address, value: bigint, data: Hex): Hex {
const dataLen = (data.length - 2) / 2; // strip 0x, 2 hex chars per byte
return encodePacked(
["uint8", "uint32", "bytes", // PUSH calldata
"uint8", "uint32", "address", // PUSH to (20 bytes)
"uint8", "uint32", "uint256", // PUSH value (32 bytes)
"uint8", "uint32", "uint32", // PUSH length (4 bytes)
"uint8"], // CALL
[PUSH, dataLen, data,
PUSH, 20, to,
PUSH, 32, value,
PUSH, 4, dataLen,
CALL],
);
}
const MULTICALL4_V1: Address = "0x0000000000000000000000000000000000000000"; // fill in per-chain
const TOKEN: Address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // e.g. USDC
const ALICE: Address = "0x1111111111111111111111111111111111111111";
const BOB: Address = "0x2222222222222222222222222222222222222222";
const account = privateKeyToAccount(process.env.PRIVATE_KEY as Hex);
const client = createWalletClient({ account, chain: mainnet, transport: http() });
const transferA = encodeFunctionData({
abi: erc20Abi,
functionName: "transferFrom",
args: [account.address, ALICE, 1_000_000n],
});
const transferB = encodeFunctionData({
abi: erc20Abi,
functionName: "transferFrom",
args: [account.address, BOB, 2_000_000n],
});
const program = encodePacked(
["bytes", "bytes"],
[encodeCall(TOKEN, 0n, transferA), encodeCall(TOKEN, 0n, transferB)],
);
await client.writeContract({
address: MULTICALL4_V1,
abi: Multicall4V1Abi,
functionName: "multicall",
args: [program],
});The same shape works from ethers.js (ethers.solidityPacked) or web3.js (web3.eth.abi.encodePacked). Only the packing helper changes.
This repo uses Foundry for development and testing and git submodules for dependency management.
git clone https://github.com/Juglipaff/multicall4.git
cd multicall4
forge install
### Make changes
forge test # Test and regenerate gas snapshots
forge coverage # Collect coverage - CI fails if < 100% coverage
scripts/extract-abi.sh src abi # Regenerate abisMulticall4 is designed for deterministic cross-chain deployment via the Arachnid CREATE2 factory at 0x4e59b44847b379578588920cA78FbF26c0B4956C.
forge script scripts/Deploy.s.sol --rpc-url $RPC --broadcastThe salt lives in scripts/Deploy.s.sol. Placeholder is bytes32(0). Replace with a vanity salt before mainnet deployment if wanted.