diff --git a/crates/blockchain/blockchain.rs b/crates/blockchain/blockchain.rs index 6b153dca0ac..7ad455b3ddb 100644 --- a/crates/blockchain/blockchain.rs +++ b/crates/blockchain/blockchain.rs @@ -2303,14 +2303,40 @@ impl Blockchain { let chain_config: ChainConfig = self.storage.get_chain_config(); - // Cache block hashes for the full batch so we can access them during execution without having to store the blocks beforehand - let block_hash_cache = blocks.iter().map(|b| (b.header.number, b.hash())).collect(); + // Cache block hashes for the full batch so we can access them during + // execution without having to store the blocks beforehand. + let mut block_hash_cache: BTreeMap = + blocks.iter().map(|b| (b.header.number, b.hash())).collect(); let parent_header = self .storage .get_block_header_by_hash(first_block_header.parent_hash) .map_err(|e| (ChainError::StoreError(e), None))? .ok_or((ChainError::ParentNotFound, None))?; + + // Walk the parent chain to cache the last 256 block hashes so that + // BLOCKHASH can resolve references to blocks from previous batches + // (they may not be canonical yet during import). + block_hash_cache + .entry(parent_header.number) + .or_insert_with(|| parent_header.hash()); + let mut hash = parent_header.parent_hash; + let mut number = parent_header.number.saturating_sub(1); + let lookback = first_block_header.number.saturating_sub(256); + while number > lookback { + block_hash_cache.entry(number).or_insert(hash); + match self.storage.get_block_header_by_hash(hash) { + Ok(Some(header)) => { + hash = header.parent_hash; + number = number.saturating_sub(1); + } + Ok(None) => break, + Err(e) => { + warn!("Failed to fetch block header by hash during BLOCKHASH cache walk: {e}"); + break; + } + } + } let vm_db = StoreVmDatabase::new_with_block_hash_cache( self.storage.clone(), parent_header, diff --git a/test/tests/blockchain/batch_tests.rs b/test/tests/blockchain/batch_tests.rs index 3b3ed724b9e..60067b3e683 100644 --- a/test/tests/blockchain/batch_tests.rs +++ b/test/tests/blockchain/batch_tests.rs @@ -247,6 +247,162 @@ async fn batch_selfdestruct_created_account_no_spurious_state() { ); } +/// Regression test for cross-batch BLOCKHASH resolution during import. +/// +/// When importing blocks in batches, a block in batch N+1 may execute +/// BLOCKHASH for a block that was in batch N. Without the fix, the hash +/// wouldn't be found because batch N's blocks aren't canonical yet and +/// the block_hash_cache only covers the current batch. +/// +/// This test builds 3 blocks: +/// - Block 1: deploy a contract that stores `blockhash(number - 1)` in storage +/// - Block 2: empty (just advances the chain) +/// - Block 3: call the contract (reads blockhash of block 2) +/// +/// Then executes blocks 1-2 in one batch, and block 3 in a second batch. +/// Block 3 needs `blockhash(2)` which is only in the first batch. +#[tokio::test] +async fn batch_cross_batch_blockhash_regression() { + let sk = test_secret_key(); + let sender = sender_from_key(&sk); + let signer: Signer = LocalSigner::new(sk).into(); + + let (store_a, chain_id) = setup_store(sender).await; + let blockchain_a = Blockchain::default_with_store(store_a.clone()); + let genesis_header = store_a.get_block_header(0).unwrap().unwrap(); + + // Deploy contract: BLOCKHASH(NUMBER - 1) -> SSTORE(0) + // Bytecode: NUMBER PUSH1 1 SWAP1 SUB BLOCKHASH PUSH1 0 SSTORE STOP + // 43 60 01 90 03 40 60 00 55 00 + let deploy_code = { + let runtime = vec![0x43, 0x60, 0x01, 0x90, 0x03, 0x40, 0x60, 0x00, 0x55, 0x00]; + let rt_len = runtime.len(); + // Init code: CODECOPY runtime into memory, then RETURN it. + // PUSH1 rt_len PUSH1 init_len PUSH1 0 CODECOPY PUSH1 rt_len PUSH1 0 RETURN + let init_len = 12; + // PUSH1 rt_len | PUSH1 init_code_len | PUSH1 0 | CODECOPY + // PUSH1 rt_len | PUSH1 0 | RETURN + let mut init = vec![ + 0x60, + rt_len as u8, + 0x60, + init_len as u8, + 0x60, + 0x00, + 0x39, + 0x60, + rt_len as u8, + 0x60, + 0x00, + 0xF3, + ]; + assert_eq!(init.len(), init_len as usize); + init.extend_from_slice(&runtime); + Bytes::from(init) + }; + + let contract_address = calculate_create_address(sender, 0); + + // tx1: deploy the contract + let mut tx1 = Transaction::EIP1559Transaction(EIP1559Transaction { + chain_id, + nonce: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: TEST_MAX_FEE_PER_GAS, + gas_limit: TEST_GAS_LIMIT, + to: TxKind::Create, + value: U256::zero(), + data: deploy_code, + ..Default::default() + }); + tx1.sign_inplace(&signer).await.unwrap(); + + blockchain_a + .add_transaction_to_pool(tx1) + .await + .expect("tx1 should enter pool"); + + // Build block 1 (deploys the contract) + let block1 = build_block(&store_a, &blockchain_a, &genesis_header).await; + assert!( + !block1.body.transactions.is_empty(), + "block1 must include tx" + ); + blockchain_a + .add_block(block1.clone()) + .expect("block1 valid"); + store_a + .forkchoice_update(vec![], 1, block1.hash(), None, None) + .await + .unwrap(); + blockchain_a + .remove_block_transactions_from_pool(&block1) + .unwrap(); + + // Build block 2 (empty, just advances the chain) + let block2 = build_block(&store_a, &blockchain_a, &block1.header).await; + blockchain_a + .add_block(block2.clone()) + .expect("block2 valid"); + store_a + .forkchoice_update(vec![], 2, block2.hash(), None, None) + .await + .unwrap(); + + // tx3: call the contract (triggers BLOCKHASH for block 2) + let mut tx3 = Transaction::EIP1559Transaction(EIP1559Transaction { + chain_id, + nonce: 1, + max_priority_fee_per_gas: 0, + max_fee_per_gas: TEST_MAX_FEE_PER_GAS, + gas_limit: TEST_GAS_LIMIT, + to: TxKind::Call(contract_address), + value: U256::zero(), + data: Bytes::new(), + ..Default::default() + }); + tx3.sign_inplace(&signer).await.unwrap(); + + blockchain_a + .add_transaction_to_pool(tx3) + .await + .expect("tx3 should enter pool"); + + // Build block 3 (calls contract, needs blockhash of block 2) + let block3 = build_block(&store_a, &blockchain_a, &block2.header).await; + assert!( + !block3.body.transactions.is_empty(), + "block3 must include tx" + ); + blockchain_a + .add_block(block3.clone()) + .expect("block3 valid"); + + // Now re-execute on a fresh store in TWO batches: + // Batch 1: blocks 1-2, Batch 2: block 3 + // Block 3 needs blockhash(2) which is only in batch 1. + let (store_b, _) = setup_store(sender).await; + let blockchain_b = Blockchain::default_with_store(store_b); + + let result1 = blockchain_b + .add_blocks_in_batch(vec![block1, block2], CancellationToken::new()) + .await; + assert!( + result1.is_ok(), + "batch 1 should succeed — got error: {:?}", + result1.err() + ); + + let result2 = blockchain_b + .add_blocks_in_batch(vec![block3], CancellationToken::new()) + .await; + assert!( + result2.is_ok(), + "batch 2 should succeed (needs blockhash from batch 1) — got error: {:?}", + result2.err() + ); +} + /// Simpler variant: a single block with a self-destructing contract, executed /// in batch. Ensures the basic batch path doesn't regress for single-block /// batches containing selfdestruct.