· 9 min read

Cross-chain and predicate based simulations with ERC-8211

Cross-chain and predicate based simulations with ERC-8211

Signing the not-yet-valid

ERC-8211 introduces a primitive that older account abstraction standards couldn't express: the predicate. A predicate is a gate on a function call — a checkpoint that doesn't execute, doesn't transfer, doesn't change state. It only validates a condition. The batch advances past it when the condition holds, and reverts otherwise.

This is what allows a transaction to be signed before it is valid.

The canonical use case is a post-bridge action. A user signs a single batch that says: "on the destination chain, once my USDC balance exceeds 1,000, swap it for ETH and supply to a lending market." At signing time, the destination-chain USDC balance is zero. The swap would revert. The supply would revert. Every step after the predicate would revert. The batch is, by every conventional measure, invalid.

A relayer holds the signature and submits the batch once a bridge delivers the funds. The predicate flips from false to true. Everything downstream executes against state that didn't exist when the user signed. One signature, multiple chains, an arbitrary delay between intent and execution.

Predicates are clean. They collapse what used to be a chain of signatures, trusted relayers, and optimistic guesses into a single signed object with on-chain-enforced gates. But they introduce a problem that doesn't exist in conventional batches: you can't simulate them with the tools we have.

Why eth_call falls down

When a user is shown a quote and decides whether to sign it, what they actually need to know is concrete: will this succeed, what will it cost, and what will I receive?

For an ordinary userOp, the answer comes from eth_call against a simulation entrypoint on the bundler. The node forks current state, replays the operation, and reports the outcome. Gas, revert status, revert reason — all available before signing.

For a predicate-gated, cross-chain userOp, this protocol breaks at the first checkpoint. The predicate evaluates state that doesn't exist yet. The destination-chain balance is zero because nothing has bridged. The predicate fails. The simulator reports "predicate failed," which the user already knew. They learn nothing about whether their swap would slip, whether the lending market is paused, whether the gas estimate is sane, or how much of the destination token they'd actually end up with.

There is no answer in current state because the question is about a future state. The simulator needs to be lied to — convincingly, precisely — about the world it's running against.

State overrides as a future world

eth_call accepts a state-override map. You hand it a set of (address, slot, value) triples and it pretends those slots hold those values for the duration of the call. Every read inside the trace returns the overridden value; everything downstream of those reads computes against the synthetic world.

The conceptual move is to use overrides to construct the post-bridge world in memory: pretend the user already holds the bridged tokens on the destination chain, then ask the simulator what would happen. If the answer is "everything succeeds, you receive 0.42 ETH, gas is X," the user can sign with confidence. If the answer is "swap reverts due to slippage," the user sees the genuine failure mode at quote time, before any bridge fee is paid and any 30-minute wait begins.

The catch is in the unit of override. eth_call overrides storage slots, not view functions. You cannot override balanceOf(holder) directly; the EVM has no concept of overriding a function. You override the slot that backs it, and balanceOf — which is just a read of that slot, formatted — returns whatever you wrote.

So the question becomes: which slot?

The storage slot problem

ERC-20 standardizes the interface, not the layout. Two tokens that present an identical public surface can store balances in entirely different places.

There is no on-chain registry that maps token to slot. The contract itself doesn't know — storage layout is a compile-time decision baked into bytecode, not introspectable runtime metadata. You cannot ask the token where its balances live.

The naive approach — try slot 0, then 1, then 2, fall back to a hardcoded list of known offsets — fails silently. Wrong slot means the override writes to a location nothing reads. The simulator runs against the original (zero) balance. The predicate fails for the same reason as before. The simulator reports the same useless answer it would have given without overrides at all. Worse, the failure looks identical to a legitimate predicate failure, so the user can't distinguish "the simulator can't find the slot" from "your batch genuinely won't work."

To override balances reliably, the slot has to be discovered, not guessed.

How slot detection works

The reliable approach turns the question inside out. Rather than guessing where the balance is stored and checking, you observe the live contract reading it.

The procedure:

  1. Pick a holder address with a known, non-zero balance.
  2. Call balanceOf(holder) against the live contract using a tracing variant of eth_call (the family of debug_traceCall methods on supported nodes), with SLOAD opcodes recorded.
  3. Walk the trace. Every storage read the function performed is captured: which contract, which slot, which value.
  4. Find the read whose returned value matches the balance the function returned.
  5. That slot — or, more precisely, that contract-and-slot pair — is, by definition, where the balance lives.

This works because the EVM is fully deterministic and fully observable through tracing. Whatever path the bytecode took to produce its answer, every storage read along that path is in the trace. The right slot is the one whose value flows into the return.

The mechanism is simple to describe and treacherous to implement, because real tokens don't read just one slot.

Filtering the noise

balanceOf call against an upgradeable token typically performs several reads before it gets to the balance:

All of these are SLOADs in the trace. None of them are the balance slot. They have to be filtered out — either by recognizing well-known slots (EIP-1967 implementation slot is fixed, blacklist flags are typically booleans) or by matching the returned value against the read value (the balance slot is the one whose SLOAD returned exactly the balance).

Classifying the layout

Once a candidate slot is identified, it's almost never the slot you actually override. Token balances live in a mapping, and mappings in Solidity are stored at keccak256(abi.encode(holder, baseSlot)) — the slot you discovered is the base, and the per-holder slot is derived from it. Vyper inverts the hash order. Some tokens use a nested mapping (mapping(address => mapping(uint => uint))) and you need both layers of derivation.

Detection therefore produces not just a slot number but a layout descriptor: the base slot, the hash convention (Solidity vs. Vyper), the address of the contract whose storage holds it (which, for delegated-storage tokens, is not the token contract). Override-time, the descriptor and the holder address combine into a concrete (contract, slot) pair.

The zero-balance trap

If the test holder has a zero balance, the heuristic fails. Many slots in any contract return zero on read; you can't tell which zero is "the" balance.

The way around this is to never run detection against a zero holder. Either pick a holder you know has a positive balance (a known whale, a contract with a large treasury), or perform a bootstrap: write a sentinel value to a guessed slot via override, run the trace, see if the returned balance changed to match the sentinel. If yes, the guess was right. If no, try the next candidate. This converts detection into a fixed-point search but bounds it tightly because the candidate set comes from the trace, not from blind guessing.

Why detection caches well

The result of detection is stable. A token's storage layout is fixed by its bytecode; it doesn't drift. Once (chainId, tokenAddress) resolves to a layout descriptor, that result is good until the contract is upgraded — and most ERC-20 contracts never are. Detection is expensive (a full trace per token), but it's a one-time cost per token per chain. Subsequent simulations reuse the cached descriptor and pay nothing.

This is what makes the technique practical. If detection had to run on every simulation, the latency would dominate the quote flow. As a one-shot per-token primitive that feeds a long-lived cache, it disappears into the background.

What overrides unlock

With slot detection in place, the override map for a predicate-gated, cross-chain batch is constructible. Every token balance the predicates depend on becomes a (contract, slot, value) triple in the override map. The simulator runs eth_call against the destination-chain entrypoint with the map applied, and the post-bridge world materializes for the duration of the call.

Three things become possible that weren't before.

Gas estimation for cross-chain actions

Before predicate gating, gas for a cross-chain action was unestimable in any honest sense. The destination-chain step couldn't be simulated because the prerequisite state didn't exist. Integrators either over-estimated wildly (charging the user for worst-case execution) or under-estimated and let the destination-chain operation fail at submission time.

With overrides, the destination-chain step runs against a synthetic post-bridge world and produces a gas number that reflects the actual execution path: which branches the swap takes, how many storage writes the lending deposit performs, how much calldata the predicate evaluator consumes. The estimate is as accurate as a same-chain estimate, because mechanically it is a same-chain estimate against a state that's been edited to look like the future.

Predicting outputs

The simulation doesn't just succeed or fail — it traces. Every state change, every event, every return value is observable. For a swap-then-supply batch, the simulator can report exactly how much of the destination token the user will receive, exactly how many lending-market shares the deposit will mint, exactly which fees were taken at each hop.

This was impossible before. With no way to simulate the destination step, integrators could show the user the inputs of a cross-chain action but not its outputs. Quotes were upper and lower bounds with a wide gap between them, or they were point estimates with no guarantee. With predicate-gated simulation, the quote becomes a precise forecast: you will receive 0.4187 ETH, 0.4185 minimum after slippage, with this gas envelope on each chain. The user signs against a number, not a range.

Surfacing failures at quote time

The third benefit is the negative case. A predicate-gated batch can fail for reasons that have nothing to do with the predicate: the destination market might be paused, the swap might slip past tolerance, an oracle might be stale, a downstream contract might revert for an unrelated reason. Without override-based simulation, all of these surface only after submission — after the bridge has run, the relayer has paid gas, and the user has waited.

With overrides, every reachable failure surfaces at quote time. The user sees the exact revert reason, traced to the offending call, before signing. The bridge never runs. The relayer never spends gas on a doomed batch. The user never waits for an outcome that was knowable in advance.

What this enables, in aggregate

Predicates are a clean primitive for expressing future-conditional execution. Storage slot detection is the unglamorous primitive that makes predicates simulatable. Together, they turn a category of transaction that used to be signed on hope into one that can be signed on a forecast — gas estimated, outputs predicted, failures surfaced ahead of time.

The detection itself is a tracing problem dressed up as a layout problem: observe a real read, filter out the noise, classify the layout, derive the per-holder slot, cache the result. Each step is mechanical. The leverage comes from composing them. Once you can override a balance reliably, you can override any state any predicate cares about. Once you can override the state, the simulator stops being a present-tense tool and becomes a future-tense one. And once simulation is future-tense, cross-chain and predicate-gated transactions stop being a UX gamble.

That's the shift ERC-8211 needed to be useful in practice, not just expressive on paper.