Most batched transactions on Ethereum today are dumb. They are static. You sign a list of calls at time T, and the EVM executes that exact list at time T+1, T+2, or T+30, regardless of what has changed in between. If you signed supply(0.05 WETH) but a swap earlier in the batch produced 0.0499 WETH, the batch reverts. ERC-8211 fixes this by introducing three primitives that make batches behave like small programs: runtime parameter injection, inline assertions, and a shared storage context. This article walks through each primitive in execution order, with the concrete opcodes and structs developers will actually touch.
Why static batching breaks composable DeFi
The pattern that motivated ERC-8211 is mundane. A user wants to swap USDC for WETH on Uniswap, then deposit the resulting WETH into Aave. Under EIP-5792 or a vanilla ERC-4337 UserOp, the wallet has to either guess the output amount (and pad with slippage that the next call must accept) or front-run the batch with a simulation and hardcode the result. Both options leak value. Slippage padding leaves dust. Simulation drifts if the pool moves between simulation and inclusion.
ERC-8211 lets the executor read on-chain state during batch execution and pipe that value directly into the next call's calldata. The Biconomy reference implementation at github.com/bcnmy/composable-batch-erc ships this as a single contract that wallets can register as an executor module, and the EIP draft (see the EIP PR thread) standardizes the entry struct so any account abstraction stack can adopt it.
For the bigger picture on why this matters, see our overview of what ERC-8211 is and the head-to-head with ERC-4337 and EIP-5792.
Primitive 1: Runtime parameter injection
Each batch entry is a struct with a target, value, calldata, and a list of input fetchers. A fetcher is a small instruction that runs immediately before the call and writes its result into a specific byte offset of the calldata. There are three fetcher types in the canonical implementation.
The three fetcher types
Fetcher | Use case | Example |
| Static values known at signing time. Address constants, calldata selectors, fixed amounts. |
|
| Reading arbitrary contract state. Oracle prices, pool reserves, vault share rates, NFT ownership. |
|
| Querying ERC-20 or native balances of any account. The most common fetcher in DeFi batches. |
|
What the pseudocode looks like
Instead of writing aave.supply(WETH, 0.05 ether, account), an ERC-8211 entry expresses the same intent as:
aave.supply(WETH, BALANCE(WETH), account)
At execution time, the fetcher fires, queries WETH.balanceOf(account), and splices the returned uint256 into the calldata at the offset where the amount argument lives. Whatever the prior swap produced, down to the last wei, is what gets supplied. No slippage padding, no dust left in the account.
STATIC_CALL is the escape hatch for anything not covered by BALANCE. Want to supply the exact amount of a Yearn vault token that corresponds to your underlying balance? STATIC_CALL(yvWETH, convertToShares(BALANCE(WETH))). Fetchers compose.
Primitive 2: Pre and post assertions
Fetchers solve the "what value" problem. Assertions solve the "did this actually do what I expected" problem. Each entry can carry a list of predicates that run before or after the call. If any predicate evaluates false, the entire batch reverts.
The four predicate operators
EQ. Exact equality. Useful for post-call checks: did this NFT transfer leave the account holding exactly tokenId 42?
GTE. Greater than or equal. The slippage check of ERC-8211.
GTE(BALANCE(WETH), minWeth)after a swap.LTE. Less than or equal. Cap a price, cap a fee, cap a slippage delta.
IN. Membership in a list. Approved recipients, allowed pool addresses, whitelisted callers.
Pure predicate entries
The most underrated feature in the spec is the pure predicate entry. If you set target = address(0), the executor skips the call entirely and only runs the predicates. This turns a batch entry into a standalone assertion. Want to guarantee that USDC price stays above $0.998 across a multi-step strategy? Drop a pure predicate at the start: GTE(STATIC_CALL(chainlinkUSDC, latestAnswer()), 99800000). No call, no gas for execution, just a check that reverts the batch if the world has moved.
This pattern is what makes ERC-8211 batches feel like programs instead of scripts. Conditions are first-class. The cross-chain use case in cross-chain DeFi with ERC-8211 leans heavily on pure predicates to gate destination-side execution.
Primitive 3: Shared storage context
Fetchers can read live on-chain state. But sometimes the value you want lives only in the return data of a previous call in the same batch. There is no public balance for it. The classic case is a swap router that returns the amount out in its return value, but does not update any account-visible balance until the next block.
ERC-8211 introduces a singleton Storage contract. Each entry can declare output captures: instructions that pull a slice of the return data after the call executes and write it to a named slot in Storage. Subsequent entries reference those slots through a fourth fetcher pattern, typically expressed as STORAGE(slotId), which reads the captured value back out.
The key property: Storage persists not just across entries within a single batch, but across the lifetime of subsequent UserOps from the same account. A batch executed at block N can write a value that a batch at block N+100 reads. Data flow between steps without writing custom contracts, without trusted relayers, without off-chain coordination.
The execution pipeline, entry by entry
Every batch entry walks through the same five-stage pipeline. Understanding the order is the difference between a batch that works and a batch that silently does the wrong thing.
Resolve inputs. All fetchers attached to the entry fire in order.
RAW_BYTEScopies literal bytes.STATIC_CALLdispatches a view call.BALANCEqueries the relevant token.STORAGEreads from the shared context. Results are written into the entry's calldata at the offsets the fetcher declared.Validate pre-constraints. Any predicate marked as "pre" runs against the now-resolved calldata and current state. EQ, GTE, LTE, IN each return a boolean. Any false reverts the entire batch with a structured error pointing at the failing entry index and predicate index.
Route and assemble. The executor finalizes the calldata, picks the call type (CALL, DELEGATECALL, STATICCALL as declared in the entry flags), and prepares the dispatch.
Execute the call. Standard EVM call. If
target == address(0), the executor skips this stage and the entry is treated as a pure predicate check. Return data is captured in memory for the next stage.Capture outputs. Any output-capture instructions attached to the entry slice the return data and write into the shared Storage contract. Any "post" predicates run now against the post-call state. A failed post-predicate reverts the whole batch, undoing every prior entry.
This pipeline is what gives ERC-8211 its atomicity guarantee. The batch either commits in full, with every assertion satisfied, or none of it commits. Developers do not write conditional logic to undo prior steps. The executor handles unwind through standard EVM revert semantics.
A concrete worked example
Here is a swap-and-supply that uses all three primitives. The user wants to take 1000 USDC, swap it for WETH on Uniswap, and supply the WETH to Aave, with a guarantee that they receive at least 0.28 WETH from the swap and that the Aave aToken balance increases by the supplied amount.
Entry 1. Approve Uniswap router to spend 1000 USDC. Target: USDC. Fetcher: RAW_BYTES(approve.selector, router, 1000e6). No predicates.
Entry 2. Swap USDC for WETH. Target: Uniswap router. Fetcher: RAW_BYTES(swap calldata). Output capture: write the returned amountOut to STORAGE[wethOut]. Post-predicate: GTE(STORAGE[wethOut], 0.28e18).
Entry 3. Approve Aave pool to spend WETH. Target: WETH. Fetcher: RAW_BYTES(approve.selector, aavePool) for the selector and pool, then BALANCE(WETH, account) for the amount. No predicates.
Entry 4. Supply WETH to Aave. Target: Aave pool. Fetcher: RAW_BYTES(supply.selector, WETH) for the static parts, BALANCE(WETH, account) for the amount argument. Post-predicate: GTE(STATIC_CALL(aWETH, balanceOf(account)), STORAGE[wethOut]).
Entry 5 (optional). Pure predicate. Target: address(0). Predicate: EQ(BALANCE(WETH, account), 0). Asserts the account is fully out of WETH at the end. No dust.
Five entries, zero hardcoded amounts that depend on prior outcomes, and atomic reversion if any leg misses its target. The same batch is portable across any wallet that supports the ERC-8211 executor module.
Limits and footguns
Three things to watch. First, fetchers cost gas, and STATIC_CALL fetchers cost as much as the underlying view call. A batch with ten STATIC_CALL fetchers across six entries can be meaningfully more expensive than a hand-tuned monolithic call. Second, the Storage contract is shared across an account's lifetime, so slot collisions across unrelated batches are a real risk. The Biconomy reference uses namespaced slot IDs derived from the entry hash, and developers should not bypass that helper. Third, predicates run against state as it exists at execution time, not signing time. A pre-predicate on BALANCE at entry 4 can pass even if a prior entry produced an unexpected amount, because the balance was already mutated. Pair fetchers with post-predicates on the entry that produced the value, not the entry that consumes it.
For an end-to-end walkthrough including the Solidity structs and module registration, see the ERC-8211 developer guide.
Methodology and sources
This article references the Biconomy reference implementation at github.com/bcnmy/composable-batch-erc and the EIP draft pull request in the ethereum/EIPs repository. Primitive names and struct field semantics follow the reference implementation as of May 2026; final EIP wording may diverge before merge.

