feat: replay preceding block txs for accurate gas estimation#96
Conversation
…#95) When analyzing a transaction that isn't the first in its block, the gas estimation previously used state from block N-1, ignoring state changes from preceding transactions. This adds replay of preceding transactions via revm's transact_commit before running gas estimation, ensuring the simulated state matches the actual on-chain state at execution time.
There was a problem hiding this comment.
Pull request overview
Adds mid-block state awareness to gas estimation by replaying transactions that precede the target transaction within the same block, improving estimation accuracy for state-dependent executions.
Changes:
- Introduces a
PrecedingTxrepresentation andreplay_preceding_transactions()ingas-estimatorto advance a sharedCacheDBviarevmreplay. - Adds
get_preceding_transactions()inrpcto fetch a block with full transactions and convert0..tx_indexinto replayable transactions. - Integrates preceding-tx replay into the EvmSketch estimator path and CLI (with graceful fallback on RPC failure).
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/rpc/src/lib.rs | Fetches full block txs via eth_getBlockByNumber(..., true) and converts preceding txs into PrecedingTx. |
| crates/rpc/Cargo.toml | Adds dependencies needed for block tx fetching/conversion (gas-analyzer-estimator, alloy-rpc-types, revm). |
| crates/gas-estimator/src/lib.rs | Adds PrecedingTx and a revm-based replay routine that commits preceding tx state into CacheDB. |
| crates/evmsketch/src/lib.rs | Adds estimate_state_changes_gas_with_preceding() to replay into the same CacheDB before estimating. |
| crates/cli/src/main.rs | Wires receipt transaction_index -> preceding tx fetch -> replayed estimation, with fallback to prior behavior. |
| Cargo.lock | Updates lockfile for added dependencies/versions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Foundry v1.7.0 (released 2026-04-28) bumped revm-inspectors to v0.39.0, which since v0.38.1 emits StructLog.memory entries as 0x-prefixed hex. The previous parser fed entries straight into u8::from_str_radix and panicked on the 'x' with InvalidDigit. Strip an optional 0x per entry so geth/erigon, old Anvil, and new Anvil all work.
CI failure diagnosis & fixThe SymptomWhy it's not the PR's fault
Branch CI history confirms this:
The CI workflow installs Foundry via Root causeFoundry v1.7.0 bumped - memory.push(hex::encode(chunk));
+ memory.push(hex::encode_prefixed(chunk));So Anvil's
The motivation for the upstream change is alignment with the opcode tracer spec (execution-apis #762). Our This affects only the Anvil backend; the EvmSketch path doesn't go through Anvil's structlogger, which is why The fix (
|
The Rust SimEnvOpts struct gained `basefee` to fix replay-time BASEFEE opcode values, but the Solidity fixture used to verify env propagation (SimEnvOptsStructs.SimEnvOpts) didn't have a corresponding field, so a regression in basefee wiring would not be caught by the env tests. Add `blockBaseFee` to the Solidity struct, both constructors, and the env-mismatch check, regenerate the abis/, and add a wrong-basefee test mirroring the existing wrong-timestamp one. Existing tests now run with a non-zero basefee so the new field is actually exercised.
…spec # Conflicts: # crates/gas-estimator/src/lib.rs
`estimate_state_changes_gas_with_preceding` was reading storage from `sketch.rpc_db`, which is anchored at the tx's own block number. Per JSON-RPC semantics, that returns state at the *end* of block N — after every tx in the block, including the one being analyzed, has executed. For txs whose state updates re-invoke functions that consume in-block nonces (EIP-2612 USDC permit, etc.), this caused the simulated call to revert because the recovered signer no longer matched `owner`: the signature was valid for nonce N but the simulator saw nonce N+1. The CLI then silently fell back to the heuristic estimator. Switch to a `SimpleRpcDb` anchored at `block_number - 1` so storage reads observe pre-tx state. `replay_preceding_transactions` then brings the DB to the correct mid-block state before estimation. This matches the no-preceding path on line 253 which already uses `anchor_block_number().saturating_sub(1)`. `sim_env` continues to use the real block's header for basefee/timestamp/coinbase/etc.
Reframe the comment so it explains the general property — RPC reads at `block_number` see post-block state, and replaying preceding txs on top of that double-applies their effects — and treat EIP-2612 permit as one example of how the breakage surfaces, rather than the cause.
revm's `Context::mainnet()` defaults to `SpecId::PRAGUE`. Mainnet has since activated Osaka (Fusaka), and contracts deployed at or after that fork can use opcodes that PRAGUE doesn't recognize. When revm hits one under the wrong spec it halts the frame with `InstructionResult::NotActivated`, which propagates to the caller as "all forwarded gas consumed, 0 bytes returndata". 0x's `AllowanceHolder.exec` interprets that signature as an out-of-gas griefing attempt and re-emits it via the `INVALID` opcode, producing an opaque empty revert in the outer estimator. Concretely, repro tx 0x71c48ec2…b1ba16e03d hits this path: the 0x settler calls Ekubo Core, whose log2 implementation uses inline-asm `clz(...)` (EIP-7939, Osaka-only opcode 0x1E). Switching `cfg.spec` to `SpecId::OSAKA` activates the CLZ handler, the call returns normally, and the analyzer reports a measured estimate (2.79% savings) instead of falling back to the heuristic. Osaka also activates EIP-7825, which caps individual transactions at 2^24 gas regardless of the block gas limit. We were forwarding the full block gas_limit (60M on this chain) into the simulated tx, which revm now rejects with `TxGasLimitGreaterThanCap`. Clamp the tx-level `gas_limit(...)` at every site (`estimate_gas_raw` and `replay_preceding_transactions`) to `min(sim_env.gas_limit, EIP7825_TX_GAS_CAP)`. Block-level `gas_limit` is left untouched. Trade-off: hard-coding `OSAKA` is incorrect for historical txs from blocks before Osaka activated. Acceptable for now because this tool is run on recent txs; a follow-up should derive `SpecId` from the block number.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 12 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
fix: anchor preceding-tx replay state to block N-1
The repo was renamed and transferred from BreadchainCoop/gas-killer-avs-sol to gas-killer/solidity-sdk. Update the submodule URL and bump the pinned SHA to upstream main. The diff between SHAs is natspec-only on StateChangeHandlerLib.sol; ABI and deployed bytecode of the wrapper contract are byte-identical (only the IPFS metadata hash changes).
bagelface
left a comment
There was a problem hiding this comment.
Correctness review. Six issues below, ranging from latent bugs to cases that actively corrupt the mid-block CacheDB state used for gas estimation.
| cfg.disable_balance_check = true; | ||
| cfg.disable_base_fee = true; | ||
| cfg.disable_fee_charge = true; | ||
| cfg.spec = revm::primitives::hardfork::SpecId::OSAKA; |
There was a problem hiding this comment.
Same SpecId::OSAKA problem as line 186, but with a more direct correctness consequence here. In the replay path, a preceding transaction from a pre-Osaka block may behave differently under Osaka semantics: it might revert when it originally succeeded, or succeed when it originally reverted, resulting in different storage writes being committed to the CacheDB. Any subsequent read from those slots — including by the analyzed transaction's simulation — will see wrong state.
There was a problem hiding this comment.
Same fix as the comment on line 186 — replay_preceding_transactions now uses sim_env.spec (derived from the anchored header against EthSpec::mainnet()) instead of a hardcoded OSAKA. See 3e0f025 and test_sim_env_spec_derivation_against_mainnet in crates/evmsketch/src/lib.rs.
| /// | ||
| /// If `preceding_txs` is empty (first-in-block), this behaves identically | ||
| /// to `estimate_state_changes_gas`. | ||
| pub fn estimate_state_changes_gas_with_preceding( |
There was a problem hiding this comment.
The public estimate_state_changes_gas_raw at line 142 was not updated by this PR. It still creates CacheDB::new(&self.sketch.rpc_db) — BasicRpcDb anchored to block N — so eth_getStorageAt calls return post-block state. This is the exact bug PR #122 fixed for estimate_state_changes_gas (line 246) and this function. Any external caller of estimate_state_changes_gas_raw gets subtly wrong results. It should be updated to use SimpleRpcDb at block_number - 1, consistent with its sibling methods.
There was a problem hiding this comment.
Addressed in 3e0f025. estimate_state_changes_gas_raw now constructs SimpleRpcDb { provider, block_number: anchor - 1 } and wraps it in a CacheDB, mirroring the fix already applied to its sibling estimate_state_changes_gas.
Tested by test_simple_rpc_db_queries_at_configured_block in crates/evmsketch/src/lib.rs. It uses a custom recording tower::Service that captures every JSON-RPC request, then constructs a SimpleRpcDb with block_number = 99 and asserts the resulting eth_getStorageAt call carries block tag "0x63" — so the underlying primitive cannot silently ignore its configured block.
…as anchor Addresses bagelface's review on PR #96. - SimEnvOpts gains `spec: SpecId` and `difficulty: U256`. Both replace hardcoded values in `estimate_gas_raw` and `replay_preceding_transactions`, so historical (pre-Osaka, pre-Merge) blocks no longer get the wrong gas schedule, opcode set, or DIFFICULTY value. - evmsketch's `sim_env()` derives `spec` via `alloy_evm::spec` against `EthSpec::mainnet()` and reads `difficulty` from the header. - `PrecedingTx` gains `access_list` (EIP-2930) and `authorization_list` (EIP-7702); `get_preceding_transactions` populates both, and the replay TxEnv builder threads them through. Without these, replay can OOG mid-tx and silently drop storage writes from the CacheDB. - The EIP-7825 per-tx cap is dropped from the replay path entirely (with `disable_fee_charge` it serves no correctness purpose) and gated on `spec >= OSAKA` in the analyzed-tx path. - `estimate_state_changes_gas_raw` switched from `BasicRpcDb` (anchored at block N) to `SimpleRpcDb` at `block_number - 1`, mirroring the fix PR #122 already applied to its sibling. The wasm path keeps `OSAKA` + zero difficulty since it runs against EmptyDB with no real chain state.
…as anchor Addresses bagelface's review on PR #96. - SimEnvOpts gains `spec: SpecId` and `difficulty: U256`. Both replace hardcoded values in `estimate_gas_raw` and `replay_preceding_transactions`, so historical (pre-Osaka, pre-Merge) blocks no longer get the wrong gas schedule, opcode set, or DIFFICULTY value. - evmsketch's `sim_env()` derives `spec` via `alloy_evm::spec` against `EthSpec::mainnet()` and reads `difficulty` from the header. - `PrecedingTx` gains `access_list` (EIP-2930) and `authorization_list` (EIP-7702); `get_preceding_transactions` populates both, and the replay TxEnv builder threads them through. Without these, replay can OOG mid-tx and silently drop storage writes from the CacheDB. - The EIP-7825 per-tx cap is dropped from the replay path entirely (with `disable_fee_charge` it serves no correctness purpose) and gated on `spec >= OSAKA` in the analyzed-tx path. - `estimate_state_changes_gas_raw` switched from `BasicRpcDb` (anchored at block N) to `SimpleRpcDb` at `block_number - 1`, mirroring the fix PR #122 already applied to its sibling. The wasm path keeps `OSAKA` + zero difficulty since it runs against EmptyDB with no real chain state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds regression tests for each issue flagged in bagelface's review. Each test is written to fail under the pre-fix code so future regressions surface immediately. gas-estimator: - effective_tx_gas_limit unit test (Issue 2): cap fires under Osaka, pre-Osaka specs left untouched. - replay-no-cap test (Issue 3): a >16.7M-gas burner under Shanghai records gas_used > 2^24, proving the EIP-7825 cap is not applied to the replay path. - difficulty propagation test (Issue 4): handwritten bytecode `DIFFICULTY PUSH1 0 SSTORE` under GRAY_GLACIER spec; storage slot 0 must equal sim_env.difficulty. - access_list test (Issue 5): same SLOAD tx replayed with vs without AccessList entry; gas_used delta of exactly 2300 confirms the entry is being threaded into the TxEnv (intrinsic 4300 minus warm savings 2000). - authorization_list test (Issue 6): signed EIP-7702 authorization applied during replay; CacheDB account for the authority gains the 23-byte 0xef0100 || target delegation indicator. - replay_preceding_transactions now returns Vec<ExecutionResult> so tests can inspect per-tx gas_used / halt reasons. - effective_tx_gas_limit extracted as a small helper so the cap-gating logic has a direct test surface. - PrecedingTx derives Clone/Debug for test ergonomics. evmsketch: - spec derivation test (Issue 1): synthesized headers at known mainnet Berlin/London/Paris/Shanghai/Cancun heights map to the right SpecId via alloy_evm::spec(&EthSpec::mainnet(), header). Catches accidental chainspec swaps (e.g. to Sepolia). - SimpleRpcDb mock-provider test (Issue 7): a custom recording tower::Service captures the JSON-RPC request issued by storage_ref and asserts the block tag matches the configured block_number, so the N-1 anchoring in estimate_state_changes_gas_raw cannot be defeated by the underlying primitive.
Adds regression tests for each issue flagged in bagelface's review. Each test is written to fail under the pre-fix code so future regressions surface immediately. gas-estimator: - effective_tx_gas_limit unit test (Issue 2): cap fires under Osaka, pre-Osaka specs left untouched. - replay-no-cap test (Issue 3): a >16.7M-gas burner under Shanghai records gas_used > 2^24, proving the EIP-7825 cap is not applied to the replay path. - difficulty propagation test (Issue 4): handwritten bytecode `DIFFICULTY PUSH1 0 SSTORE` under GRAY_GLACIER spec; storage slot 0 must equal sim_env.difficulty. - access_list test (Issue 5): same SLOAD tx replayed with vs without AccessList entry; gas_used delta of exactly 2300 confirms the entry is being threaded into the TxEnv (intrinsic 4300 minus warm savings 2000). - authorization_list test (Issue 6): signed EIP-7702 authorization applied during replay; CacheDB account for the authority gains the 23-byte 0xef0100 || target delegation indicator. - replay_preceding_transactions now returns Vec<ExecutionResult> so tests can inspect per-tx gas_used / halt reasons. - effective_tx_gas_limit extracted as a small helper so the cap-gating logic has a direct test surface. - PrecedingTx derives Clone/Debug for test ergonomics. evmsketch: - spec derivation test (Issue 1): synthesized headers at known mainnet Berlin/London/Paris/Shanghai/Cancun heights map to the right SpecId via alloy_evm::spec(&EthSpec::mainnet(), header). Catches accidental chainspec swaps (e.g. to Sepolia). - SimpleRpcDb mock-provider test (Issue 7): a custom recording tower::Service captures the JSON-RPC request issued by storage_ref and asserts the block tag matches the configured block_number, so the N-1 anchoring in estimate_state_changes_gas_raw cannot be defeated by the underlying primitive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous docstrings led with 'Issue N — ...' references to a code review that future readers won't have context on. Replaced each with a self-contained statement of the invariant or property the test enforces.
chore(submodule): retarget gas-killer-avs-sol to renamed solidity-sdk
| /// value (zero post-Merge). | ||
| pub fn sim_env(&self) -> SimEnvOpts { | ||
| let header = self.sketch.anchor.header(); | ||
| let spec = alloy_evm::spec(&alloy_evm::eth::spec::EthSpec::mainnet(), header); |
There was a problem hiding this comment.
we can't assume mainnet spec. we will be running Sepolia transactions as well, so should detect from chain ID
There was a problem hiding this comment.
Addressed in 6fdfdfc.
Chain detection now happens in EvmSketchExecutorBuilder::build: it queries eth_chainId, maps it through chain_id_to_genesis_and_spec, threads Genesis::Mainnet | Genesis::Sepolia into EvmSketch::builder().with_genesis(...), and stores the chain ID on the executor. sim_env() then picks EthSpec::mainnet() or EthSpec::sepolia() based on the stored chain ID instead of hardcoding mainnet.
Unsupported chain IDs (Holesky, Anvil, etc.) error explicitly rather than silently defaulting to mainnet — defaulting would corrupt hardfork derivation whenever the target chain disagrees with mainnet at the same height/timestamp.
Two tests in crates/evmsketch/src/lib.rs:
test_chain_id_to_genesis_and_spec_supported_and_rejected_chains— pins mainnet→Genesis::Mainnet+ Cancun ts1_710_338_135, sepolia→Genesis::Sepolia+ Cancun ts1_706_655_072, and asserts that 17000/31337/0 all error.test_sepolia_spec_diverges_from_mainnet— synthesizes a header at timestamp1_708_000_000(between the two Cancun activations) and asserts mainnet maps toSHANGHAIwhile Sepolia maps toCANCUN. Hardcoding mainnet would silently misclassify the Sepolia case as Shanghai.
bagelface
left a comment
There was a problem hiding this comment.
We don't want to assume mainnet specs, so need to detect Sepolia or Mainnet.
Hardcoding `EthSpec::mainnet()` produces wrong hardfork activation when analyzing Sepolia transactions (e.g. Cancun activates ~3 days earlier on Sepolia by timestamp). Query `eth_chainId` in `EvmSketchExecutorBuilder::build`, map to `Genesis` and `EthSpec`, and pick the matching spec in `sim_env`. Unsupported chains error explicitly rather than silently degrading.
8711082 to
251b2ce
Compare
Summary
Closes #95
PrecedingTxstruct andreplay_preceding_transactions()to thegas-estimatorcrate — replays transactions via revm'stransact_committo advanceCacheDBstateget_preceding_transactions()to therpccrate — fetches block with full tx objects via a singleeth_getBlockByNumber(N, true)call, converts toPrecedingTxestimate_state_changes_gas_with_preceding()toGasKillerEvmSketchDefault— creates oneCacheDB, replays preceding txs, then runs gas estimation on the same DBtransaction_indexfrom receipt, fetches preceding txs when index > 0, gracefully falls back to current behavior on RPC failureDecisions (from spec):
eth_getBlockByNumberwith full tx objects) for fetching preceding txsdebug_traceCallper preceding tx)Test plan
--anvilflag (Anvil path is unchanged, no regression)eth_getBlockByNumber