feat(flashblocks): State root computation via PayloadProcessor sparse trie task#2247
feat(flashblocks): State root computation via PayloadProcessor sparse trie task#2247
Conversation
mw2000
commented
Apr 16, 2026
- Replace inline synchronous state root computation with reth's PayloadProcessor sparse trie task, computing state roots in parallel during block building
- PayloadProcessor, ChangesetCache, and TreeConfig are stored on OpPayloadBuilder and reused across blocks so the sparse trie stays warm
- Falls back to synchronous computation on task failure
- Rewrite state_root benchmark to measure residual wait time (finish → root) with busy-wait simulation of 200ms inter-flashblock delays
🟡 Heimdall Review Status
|
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
|
||
| // PayloadProcessor is reused across blocks for warm sparse trie. | ||
| let evm_config = BaseEvmConfig::optimism(ctx.chain_spec()); | ||
| let tree_config = TreeConfig::default(); |
There was a problem hiding this comment.
TreeConfig::default() — the benchmark uses TreeConfig::default().with_disable_sparse_trie_cache_pruning(true), but production here uses the plain default (which has pruning enabled). If the benchmark is meant to reflect production, one of these should change. If cache pruning is intentionally enabled in production but disabled in benchmarks to keep the trie warm, that's fine but worth documenting the discrepancy.
bca1ea0 to
8979c8d
Compare
|
|
||
| // Merge pending transitions into bundle_state before reading it. | ||
| // revm's commit() pushes to transition_state, not bundle_state. | ||
| state.merge_transitions(BundleRetention::Reverts); |
There was a problem hiding this comment.
Double merge_transitions when called from finalize_payload
finalize_state_root calls state.merge_transitions(BundleRetention::Reverts) here, then the caller finalize_payload passes state to build_block which calls merge_transitions again (payload.rs:1035). The second merge is a no-op on the now-empty transition state, but build_block clones state.transition_state beforehand (payload.rs:1033) to restore it at the end — since the finalization path already merged, this clone captures an empty transition state.
This is safe only because finalize_payload is called at the end of block building and no further flashblocks are built afterward. However, the implicit coupling is fragile — if someone later calls build_block after finalize_state_root in a non-terminal context, state would be silently corrupted. Consider either:
- Having
finalize_state_rootnot callmerge_transitionsitself (let the caller manage it), or - Adding a comment documenting this coupling.
| StateProviderFactory | ||
| + ChainSpecProvider<ChainSpec = BaseChainSpec> | ||
| + BlockReaderIdExt<Header = Header> | ||
| + DatabaseProviderFactory< | ||
| Provider: BlockReader | ||
| + StageCheckpointReader | ||
| + PruneCheckpointReader | ||
| + ChangeSetReader | ||
| + StorageChangeSetReader | ||
| + BlockNumReader | ||
| + StorageSettingsCache, | ||
| > + StateReader | ||
| + Clone | ||
| + 'static | ||
| { |
There was a problem hiding this comment.
Breaking change to ClientBounds public trait
Adding DatabaseProviderFactory<Provider: ...> + StateReader + 'static is a breaking change — any downstream type implementing ClientBounds must now also satisfy these bounds. Worth noting in the PR description / changelog. If these bounds are only needed by spawn_state_root_task (which is pub(crate)), consider introducing a separate StateRootClientBounds trait to keep ClientBounds stable for external consumers.
8979c8d to
454d4e8
Compare
| state_root = %outcome.state_root, | ||
| "Background state root task completed" | ||
| ); | ||
| return Ok((outcome.state_root, outcome.trie_updates, hashed_state)); |
There was a problem hiding this comment.
No consistency check between background trie_updates and locally-computed hashed_state
When the background task succeeds, outcome.trie_updates (computed by the sparse trie from per-tx diffs fed through the hook) is returned alongside hashed_state (computed locally from bundle_state on line 178). These two independently-derived views of the state are stored together in BuiltPayloadExecutedBlock and must be consistent for the engine to persist state correctly.
If any state update is ever lost in transit through the hook (e.g., a future refactoring accidentally skips a send_state_update call, or a new code path commits state without notifying the hook), the trie updates and hashed state would silently diverge — no error, no warning, just an inconsistent state persisted by the engine.
Consider adding a debug assertion or logging comparison (e.g., comparing the state root derived from hashed_state against outcome.state_root in debug/test builds) to catch divergence early. Something like:
| return Ok((outcome.state_root, outcome.trie_updates, hashed_state)); | |
| return Ok((outcome.state_root, outcome.trie_updates, hashed_state)); | |
| // TODO: consider adding a debug assertion comparing the | |
| // locally-derived state root against outcome.state_root to | |
| // catch hook/bundle_state divergence. |
c9c8b75 to
65dd6dd
Compare
| let (state_hook, handle) = spawn_state_root_task( | ||
| &self.state_root_deps, | ||
| &self.client, | ||
| ctx.parent().hash(), | ||
| ctx.parent().state_root, | ||
| ctx.block_number(), | ||
| )?; | ||
| let mut state_root_handle = Some(handle); | ||
|
|
||
| // 1. execute the pre steps and seal an early block with that | ||
| let sequencer_tx_start_time = Instant::now(); | ||
| let mut state = | ||
| State::builder().with_database(cached_reads.as_db_mut(db)).with_bundle_update().build(); | ||
|
|
||
| let mut info = execute_pre_steps(&mut state, &ctx)?; | ||
| let mut info = execute_pre_steps(&mut state, &ctx, &state_hook)?; |
There was a problem hiding this comment.
Sparse trie task leak on early error returns
If execute_pre_steps fails here (or build_block / payload_tx.send fail a few lines below before the flashblock loop is entered), the function returns an error via ? without calling state_hook.finish(). The sparse trie task spawned at line 265 will block forever waiting for the FinishedStateUpdates signal that finish() sends.
The build_next_flashblock error path at line 530 correctly calls state_hook.finish() before returning, but these earlier paths don't.
Consider wrapping state_hook in a guard that calls finish() on Drop, or adding explicit state_hook.finish() calls to these error paths. Something like:
let mut info = match execute_pre_steps(&mut state, &ctx, &state_hook) {
Ok(info) => info,
Err(e) => {
state_hook.finish();
return Err(e);
}
};Alternatively, a RAII wrapper would make this foolproof:
struct FinishOnDrop<'a>(&'a BuilderStateHook);
impl Drop for FinishOnDrop<'_> {
fn drop(&mut self) { self.0.finish(); }
}Introduce an async state root task that runs alongside flashblock execution using reth's PayloadProcessor, so the final state root computation overlaps with block building instead of blocking it.
65dd6dd to
7d5c6e3
Compare
Review Summary — State root via PayloadProcessor sparse trie taskArchitectureThe design is sound: Key positives:
Existing inline comments — statusSeveral inline comments from the prior review run appear to reference concerns that the current code already addresses:
Remaining observations
|