fix(l1): fix BLOCKHASH cross-batch resolution during block import#6386
fix(l1): fix BLOCKHASH cross-batch resolution during block import#6386
Conversation
When importing blocks in batches, the BLOCKHASH opcode could fail for blocks from the previous batch because those blocks weren't marked as canonical yet. The block_hash_cache only contained hashes for the current batch. Fix: before each batch, load the last 256 block hashes from the store into the cache so cross-batch BLOCKHASH lookups succeed. Closes #6385
When importing blocks in batches, the BLOCKHASH opcode could fail for blocks from the previous batch because those blocks weren't marked as canonical yet. The block_hash_cache only contained hashes for the current batch. Fix: walk the parent chain from the first block's parent to cache the last 256 block hashes. Uses get_block_header_by_hash (works without canonical index) instead of get_block_header (requires canonical). Add regression test: batch_cross_batch_blockhash_regression builds 3 blocks where block 3 calls BLOCKHASH for block 2, then executes them in two separate batches. Without the fix, batch 2 fails with "Block hash not found for block number 2". Closes #6385
Lines of code reportTotal lines added: Detailed view |
🤖 Codex Code Review
No other blocking correctness, gas-accounting, or consensus issues stood out in the diff. The cache preload itself follows the exact parent-hash chain, so it looks branch-correct for non-canonical batch imports. Non-blocking: the regression test is directionally good, but it only asserts that batch 2 succeeds. It would be stronger to also assert the contract stored I could not run the test locally in this sandbox because Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewHere is my review of PR 6386: PR 6386 — Fix BLOCKHASH cross-batch resolution during block importOverall: The fix is conceptually sound and correctly addresses the reported bug. The approach of pre-populating the
|
| Severity | Item |
|---|---|
| Minor correctness | Redundant get_block_header_by_hash on first loop iteration (wasted I/O) |
| Observability | _ => break silently drops store errors; add a tracing::warn! |
| Robustness | Use header.number instead of arithmetic saturating_sub(1) for the counter |
| Test quality | Dead code before init.clear() should be removed |
| Test quality | Assert the actual stored BLOCKHASH value, not just import success |
The core logic (range, direction, or_insert semantics) is correct. Clean up the items above and this is ready to merge.
Automated review by Claude (Anthropic) · sonnet · custom prompt
🤖 Codex Code Review
No production-side correctness or security issues stood out in blockchain.rs:2144. The ancestor preload matches the EVM’s 256-block Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Claude Code ReviewHere is the review: Review: fix(l1): BLOCKHASH cross-batch resolution during block importOverall: The fix is logically correct and addresses a real consensus bug. Most issues below are minor, but one deserves attention before merge.
|
| Severity | Location | Finding |
|---|---|---|
| Minor bug | blockchain.rs:2170 |
_ => break swallows storage errors silently |
| Cleanup | batch_tests.rs:281-292 |
5 dead pushes + stale comments before init.clear() |
| Style | blockchain.rs:2158 |
Unnecessary { } scope around the walk block |
| Nit | batch_tests.rs:395-411 |
Add a comment explaining why is_ok() is sufficient |
The core fix is correct. The dead code in the test is the most visible thing to clean up before merge.
Automated review by Claude (Anthropic) · sonnet · custom prompt
Greptile SummaryThis PR fixes a cross-batch Key changes:
Minor observations:
Confidence Score: 5/5
|
| Filename | Overview |
|---|---|
| crates/blockchain/blockchain.rs | Adds a pre-execution parent-chain walk caching up to 256 block hashes before batch execution begins, correctly fixing cross-batch BLOCKHASH resolution. Logic is sound; one minor redundant storage lookup in the first loop iteration. |
| test/tests/blockchain/batch_tests.rs | Valid regression test that deploys a BLOCKHASH-dependent contract in batch 1 and invokes it in batch 2; relies on state-root mismatch to detect incorrect results. Contains dead code from an abandoned first attempt at building init bytecode (5 bytes pushed before init.clear()). |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[add_blocks_in_batch called] --> B[Build batch cache\nblocks.iter → block_hash_cache]
B --> C[Fetch parent_header\nfrom storage by hash]
C --> D[Seed walk:\ncache parent_header.number → parent_header.hash]
D --> E{number > lookback?\nlookback = first_block_number − 256}
E -- Yes --> F[get_block_header_by_hash hash]
F -- Ok Some header --> G[hash = header.parent_hash\nnumber -= 1\ncache entry with or_insert]
G --> E
F -- Err / None --> H[break — genesis boundary reached]
E -- No --> I[256 entries cached]
H --> I
I --> J[StoreVmDatabase::new_with_block_hash_cache\nparent_header + block_hash_cache]
J --> K[Execute each block in batch\nBLOCKHASH resolved from cache]
K --> L[Validate state root\nstore_block_updates]
Prompt To Fix All With AI
This is a comment left during a code review.
Path: test/tests/blockchain/batch_tests.rs
Line: 278-289
Comment:
**Dead code from abandoned first attempt**
The initial `init` construction (lines 278–289) is never used — `init.clear()` is called immediately after, discarding those 5 bytes. This is leftover scaffolding from an earlier draft of the test and adds noise. Consider removing it:
```suggestion
let deploy_code = {
let runtime = vec![0x43, 0x60, 0x01, 0x90, 0x03, 0x40, 0x60, 0x00, 0x55, 0x00];
let rt_len = runtime.len();
let mut init = Vec::new();
// PUSH1 rt_len PUSH1 (init_len) PUSH1 0 CODECOPY PUSH1 rt_len PUSH1 0 RETURN
let init_len = 12; // we'll verify this
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: crates/blockchain/blockchain.rs
Line: 2158-2173
Comment:
**Redundant storage round-trip on first iteration**
The loop opens by calling `get_block_header_by_hash(hash)` where `hash = parent_header.hash()`. This fetches `parent_header` from storage again, even though it is already available in the local variable `parent_header`. The redundant lookup can be avoided by seeding the walk directly from `parent_header.parent_hash`:
```suggestion
{
// Seed the walk with the grandparent hash directly from the
// already-fetched parent_header, avoiding a redundant storage lookup.
let mut hash = parent_header.parent_hash;
let mut number = parent_header.number.saturating_sub(1);
block_hash_cache.entry(number).or_insert(hash);
let lookback = first_block_header.number.saturating_sub(256);
while number > lookback {
match self.storage.get_block_header_by_hash(hash) {
Ok(Some(header)) => {
hash = header.parent_hash;
number = number.saturating_sub(1);
block_hash_cache.entry(number).or_insert(hash);
}
_ => break,
}
}
}
```
Note: this suggestion also drops the separate `block_hash_cache.entry(parent_header.number).or_insert(parent_header.hash())` line before the loop since that entry was already inserted via the initial `blocks.iter()` map if the parent happens to be in the batch, but `parent_header` is always one block _before_ the batch, so you may want to keep that first `or_insert` call as well and just remove the first loop iteration.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: "fix(l1): populate bl..."
| let runtime = vec![0x43, 0x60, 0x01, 0x90, 0x03, 0x40, 0x60, 0x00, 0x55, 0x00]; | ||
| let mut init = Vec::new(); | ||
| // PUSH<len> <runtime> PUSH1 0 RETURN | ||
| init.push(0x60 + 0); // PUSH1 | ||
| init.push(runtime.len() as u8); | ||
| init.push(0x60); // PUSH1 | ||
| init.push(0x00); // offset 0 | ||
| init.push(0x39); // CODECOPY — but we need the runtime in memory first | ||
|
|
||
| // Simpler: use PUSH bytes directly | ||
| // PUSH10 <runtime_bytes> PUSH1 0 MSTORE PUSH1 10 PUSH1 22 RETURN | ||
| // Actually let's just use a minimal init code: |
There was a problem hiding this comment.
Dead code from abandoned first attempt
The initial init construction (lines 278–289) is never used — init.clear() is called immediately after, discarding those 5 bytes. This is leftover scaffolding from an earlier draft of the test and adds noise. Consider removing it:
| let runtime = vec![0x43, 0x60, 0x01, 0x90, 0x03, 0x40, 0x60, 0x00, 0x55, 0x00]; | |
| let mut init = Vec::new(); | |
| // PUSH<len> <runtime> PUSH1 0 RETURN | |
| init.push(0x60 + 0); // PUSH1 | |
| init.push(runtime.len() as u8); | |
| init.push(0x60); // PUSH1 | |
| init.push(0x00); // offset 0 | |
| init.push(0x39); // CODECOPY — but we need the runtime in memory first | |
| // Simpler: use PUSH bytes directly | |
| // PUSH10 <runtime_bytes> PUSH1 0 MSTORE PUSH1 10 PUSH1 22 RETURN | |
| // Actually let's just use a minimal init code: | |
| let deploy_code = { | |
| let runtime = vec![0x43, 0x60, 0x01, 0x90, 0x03, 0x40, 0x60, 0x00, 0x55, 0x00]; | |
| let rt_len = runtime.len(); | |
| let mut init = Vec::new(); | |
| // PUSH1 rt_len PUSH1 (init_len) PUSH1 0 CODECOPY PUSH1 rt_len PUSH1 0 RETURN | |
| let init_len = 12; // we'll verify this |
Prompt To Fix With AI
This is a comment left during a code review.
Path: test/tests/blockchain/batch_tests.rs
Line: 278-289
Comment:
**Dead code from abandoned first attempt**
The initial `init` construction (lines 278–289) is never used — `init.clear()` is called immediately after, discarding those 5 bytes. This is leftover scaffolding from an earlier draft of the test and adds noise. Consider removing it:
```suggestion
let deploy_code = {
let runtime = vec![0x43, 0x60, 0x01, 0x90, 0x03, 0x40, 0x60, 0x00, 0x55, 0x00];
let rt_len = runtime.len();
let mut init = Vec::new();
// PUSH1 rt_len PUSH1 (init_len) PUSH1 0 CODECOPY PUSH1 rt_len PUSH1 0 RETURN
let init_len = 12; // we'll verify this
```
How can I resolve this? If you propose a fix, please make it concise.| { | ||
| let mut hash = parent_header.hash(); | ||
| let mut number = parent_header.number; | ||
| block_hash_cache.entry(number).or_insert(hash); | ||
| let lookback = first_block_header.number.saturating_sub(256); | ||
| while number > lookback { | ||
| match self.storage.get_block_header_by_hash(hash) { | ||
| Ok(Some(header)) => { | ||
| hash = header.parent_hash; | ||
| number = number.saturating_sub(1); | ||
| block_hash_cache.entry(number).or_insert(hash); | ||
| } | ||
| _ => break, | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Redundant storage round-trip on first iteration
The loop opens by calling get_block_header_by_hash(hash) where hash = parent_header.hash(). This fetches parent_header from storage again, even though it is already available in the local variable parent_header. The redundant lookup can be avoided by seeding the walk directly from parent_header.parent_hash:
| { | |
| let mut hash = parent_header.hash(); | |
| let mut number = parent_header.number; | |
| block_hash_cache.entry(number).or_insert(hash); | |
| let lookback = first_block_header.number.saturating_sub(256); | |
| while number > lookback { | |
| match self.storage.get_block_header_by_hash(hash) { | |
| Ok(Some(header)) => { | |
| hash = header.parent_hash; | |
| number = number.saturating_sub(1); | |
| block_hash_cache.entry(number).or_insert(hash); | |
| } | |
| _ => break, | |
| } | |
| } | |
| } | |
| { | |
| // Seed the walk with the grandparent hash directly from the | |
| // already-fetched parent_header, avoiding a redundant storage lookup. | |
| let mut hash = parent_header.parent_hash; | |
| let mut number = parent_header.number.saturating_sub(1); | |
| block_hash_cache.entry(number).or_insert(hash); | |
| let lookback = first_block_header.number.saturating_sub(256); | |
| while number > lookback { | |
| match self.storage.get_block_header_by_hash(hash) { | |
| Ok(Some(header)) => { | |
| hash = header.parent_hash; | |
| number = number.saturating_sub(1); | |
| block_hash_cache.entry(number).or_insert(hash); | |
| } | |
| _ => break, | |
| } | |
| } | |
| } |
Note: this suggestion also drops the separate block_hash_cache.entry(parent_header.number).or_insert(parent_header.hash()) line before the loop since that entry was already inserted via the initial blocks.iter() map if the parent happens to be in the batch, but parent_header is always one block before the batch, so you may want to keep that first or_insert call as well and just remove the first loop iteration.
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/blockchain.rs
Line: 2158-2173
Comment:
**Redundant storage round-trip on first iteration**
The loop opens by calling `get_block_header_by_hash(hash)` where `hash = parent_header.hash()`. This fetches `parent_header` from storage again, even though it is already available in the local variable `parent_header`. The redundant lookup can be avoided by seeding the walk directly from `parent_header.parent_hash`:
```suggestion
{
// Seed the walk with the grandparent hash directly from the
// already-fetched parent_header, avoiding a redundant storage lookup.
let mut hash = parent_header.parent_hash;
let mut number = parent_header.number.saturating_sub(1);
block_hash_cache.entry(number).or_insert(hash);
let lookback = first_block_header.number.saturating_sub(256);
while number > lookback {
match self.storage.get_block_header_by_hash(hash) {
Ok(Some(header)) => {
hash = header.parent_hash;
number = number.saturating_sub(1);
block_hash_cache.entry(number).or_insert(hash);
}
_ => break,
}
}
}
```
Note: this suggestion also drops the separate `block_hash_cache.entry(parent_header.number).or_insert(parent_header.hash())` line before the loop since that entry was already inserted via the initial `blocks.iter()` map if the parent happens to be in the batch, but `parent_header` is always one block _before_ the batch, so you may want to keep that first `or_insert` call as well and just remove the first loop iteration.
How can I resolve this? If you propose a fix, please make it concise.
ElFantasma
left a comment
There was a problem hiding this comment.
LGTM, just pointed out the same nit that greptile found.
- Skip redundant DB lookup on first loop iteration - Split error handling to log storage failures - Remove unnecessary scope braces - Clean up dead code in test
Fixes #6385.
When importing blocks in batches,
BLOCKHASHcould fail to resolve hashes for blocks that landed in a prior batch -- those blocks aren't canonical yet during import, so they were absent from the cache and any lookup returned zero.The fix walks the parent chain from the first block of the current batch, caching up to 256 prior block hashes into
block_hash_cachebefore execution begins. The walk breaks early if storage doesn't have a header (e.g. genesis boundary), so there's no impact on single-batch or shallow imports.Regression test in
test/tests/blockchain/batch_tests.rs: deploys a contract that callsBLOCKHASH(number - 1)and invokes it from a second batch, confirming the hash is resolved correctly.