feat: top-of-block pre-simulation to filter reverting tx spam#465
feat: top-of-block pre-simulation to filter reverting tx spam#465
Conversation
Pre-simulate pending pool transactions once per block before the flashblock loop begins. Transactions that revert are marked excluded via FlashblockTxCache so they're skipped during all subsequent flashblock iterations — moving the simulation cost of adversarial reverting txs off the critical path of flashblock production. Uses a random coinbase during simulation (configurable) to prevent adversaries from detecting the simulation environment by checking the COINBASE opcode. Same anti-gaming technique as rbuilder on L1. New CLI flags (both default false/true respectively): --builder.enable-presim --builder.presim-random-coinbase Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove `with_bundle_update()` — presim is read-only, no bundle tracking needed - Remove `derive(Clone)` on BlockEvmFactory — pass evm_config and evm_env by ref/clone instead of cloning the whole factory - Add MAX_PRESIM_TXS (4096) cap to bound worst-case pool flooding - Rename presim_duration → presim_pass_duration to avoid confusion with existing tx_simulation_duration metric - Document known limitations: parent-state-vs-post-deposit divergence and coinbase balance detection bypass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fail-open: presim errors are logged and skipped instead of aborting the entire block build. Presim is an optimization, not required for correctness. - Add PRESIM_DEADLINE (500ms) time budget to prevent adversarial txs from turning presim into a self-inflicted DoS on the first flashblock. - Document gas revenue tradeoff: excluded reverting txs don't pay the builder, which is an explicit opt-in tradeoff. - Document defense-in-depth: mid-block pool arrivals after presim are caught by exclude_reverts_between_flashblocks during building. - Document independent simulation limitation for multi-tx flows. - Make presim_txs_excluded a Histogram (consistent with siblings). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Build an initial empty block before presim tests so parent state has fully committed genesis state (funded accounts). Presim runs against parent state, so block 1 wouldn't see genesis funding without this. - Add info-level logs for presim completion and exclusion count so CI output shows whether presim actually ran. - Promote presim summary log from debug to info for observability. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| // warmed fallback cache. Txs arriving mid-block after presim are | ||
| // caught by the existing exclude_reverts_between_flashblocks | ||
| // mechanism during building (defense in depth). | ||
| if self.config.presim_enabled { |
There was a problem hiding this comment.
this comment is not correct - looking in context.rs where exclude_reverts_between_flashblocks is actually used, it's only used for bundle txs, normal non-bundle txs don't touch that path at all
| //! | ||
| //! - **Reverting txs forfeit gas revenue.** Excluded txs are never | ||
| //! included in the block, so the builder does not collect their gas | ||
| //! fees. This is an explicit tradeoff: faster flashblock production |
There was a problem hiding this comment.
how does this allow for faster flashblock production? we're still executing the tx either way. do you mean faster flashblock syncing?
| //! - **Independent simulation.** Each tx is simulated against the same | ||
| //! top-of-block state without committing prior results. Multi-tx | ||
| //! flows from one sender (approve → transferFrom) may be falsely | ||
| //! excluded if the second tx depends on the first's state changes. |
There was a problem hiding this comment.
just flagging this again - seems like an issue
| /// Maximum wall-clock time for the presim pass. Prevents adversarial | ||
| /// txs that maximize EVM compute from turning presim into a | ||
| /// self-inflicted DoS on the first flashblock. | ||
| const PRESIM_DEADLINE: Duration = Duration::from_millis(500); |
There was a problem hiding this comment.
seems long considering flashblock time is 200ms
| while let Some(tx) = best_txs.next(()) { | ||
| if simulated >= MAX_PRESIM_TXS { | ||
| debug!(target: "payload_builder", limit = MAX_PRESIM_TXS, "presim: tx limit reached"); | ||
| break; | ||
| } | ||
| if Instant::now() >= deadline { | ||
| debug!(target: "payload_builder", budget_ms = PRESIM_DEADLINE.as_millis(), "presim: time budget exceeded"); | ||
| break; | ||
| } | ||
|
|
||
| let tx_hash = *tx.hash(); | ||
| let recovered = tx.into_consensus(); | ||
| simulated += 1; | ||
|
|
||
| let sim = evm_config | ||
| .evm_with_env(&mut state, env.clone()) | ||
| .transact(&recovered); | ||
|
|
||
| match sim { | ||
| Ok(exec) if !exec.result.is_success() => { | ||
| let gas = exec.result.gas_used(); | ||
| debug!( | ||
| target: "payload_builder", | ||
| %tx_hash, | ||
| gas_used = gas, | ||
| "presim: excluding reverting transaction" | ||
| ); | ||
| result.excluded.push(tx_hash); | ||
| gas_saved = gas_saved.saturating_add(gas); | ||
| } | ||
| Err(_) | Ok(_) => {} | ||
| } | ||
| } |
There was a problem hiding this comment.
this can be run in parallel since they all run on top of the same parent-state independently - spawn a tokio task for each, use a JoinSet or something to await for them all to finish (or drop once deadline/limit is reached)
Presim was creating State without with_bundle_update(), which may cause transact() to return incorrect results for CREATE txs with reverting init code. CI showed simulated=2 excluded=0 — both txs appeared to succeed in presim despite one being a reverting tx. Restoring with_bundle_update() to match the State construction used by the flashblock builder (context.rs). Also adds per-tx debug logs for EVM results to aid future debugging. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
FlashblockTxCacheas excluded so every flashblock iteration skips them — zero wasted EVM time on the critical pathCOINBASE(same technique as rbuilder on L1)Motivation and Context
The builder is spending too much time executing transactions that revert during flashblock production. The existing per-address gas limiter helps, but adversarial reverting txs still burn simulation time on every flashblock iteration. This PR moves that cost to a single pre-pass before the loop starts.
Design choices:
RemoveRevertedTransactions/exclude_reverts_between_flashblockslogic.run_blocking_taskto avoid blocking the tokio runtime.New flags
--builder.enable-presimfalseBUILDER_ENABLE_PRESIM--builder.presim-random-coinbasetrueBUILDER_PRESIM_RANDOM_COINBASENew metrics (
op_rbuilderscope)presim_duration— histogrampresim_txs_simulated— histogrampresim_txs_excluded— counterpresim_gas_saved— counterChecklist
cargo check -p op-rbuilder— cleancargo check -p op-rbuilder --tests— cleancargo fmt— cleanmake test— integration tests added, need to run full suiteTest plan
Tests in
src/tests/presim.rs:presim_filters_reverting_tx_without_revert_protection— key test: presim ON, revert protection OFF, reverting tx excludedpresim_keeps_valid_transactions— no false positivespresim_disabled_by_default_includes_reverts— default behavior unchangedpresim_without_random_coinbase— filter still works with deterministic coinbase🤖 Generated with Claude Code