Skip to main content

reth_consensus_common/
validation.rs

1//! Collection of methods for block validation.
2
3use alloy_consensus::{BlockHeader as _, EMPTY_OMMER_ROOT_HASH};
4use alloy_eips::{eip4844::DATA_GAS_PER_BLOB, eip7840::BlobParams};
5use alloy_primitives::B256;
6use reth_chainspec::{EthChainSpec, EthereumHardfork, EthereumHardforks};
7use reth_consensus::ConsensusError;
8use reth_primitives_traits::{
9    constants::{GAS_LIMIT_BOUND_DIVISOR, MAXIMUM_GAS_LIMIT_BLOCK, MINIMUM_GAS_LIMIT},
10    Block, BlockBody, BlockHeader, GotExpected, SealedBlock, SealedHeader,
11};
12
13/// The maximum RLP length of a block, defined in [EIP-7934](https://eips.ethereum.org/EIPS/eip-7934).
14///
15/// Calculated as `MAX_BLOCK_SIZE` - `SAFETY_MARGIN` where
16/// `MAX_BLOCK_SIZE` = `10_485_760`
17/// `SAFETY_MARGIN` = `2_097_152`
18pub const MAX_RLP_BLOCK_SIZE: usize = 8_388_608;
19
20/// Gas used needs to be less than gas limit. Gas used is going to be checked after execution.
21#[inline]
22pub fn validate_header_gas<H: BlockHeader>(header: &H) -> Result<(), ConsensusError> {
23    if header.gas_used() > header.gas_limit() {
24        return Err(ConsensusError::HeaderGasUsedExceedsGasLimit {
25            gas_used: header.gas_used(),
26            gas_limit: header.gas_limit(),
27        })
28    }
29    // Check that the gas limit is below the maximum allowed gas limit
30    if header.gas_limit() > MAXIMUM_GAS_LIMIT_BLOCK {
31        return Err(ConsensusError::HeaderGasLimitExceedsMax { gas_limit: header.gas_limit() })
32    }
33    Ok(())
34}
35
36/// Ensure the EIP-1559 base fee is set if the London hardfork is active.
37#[inline]
38pub fn validate_header_base_fee<H: BlockHeader, ChainSpec: EthereumHardforks>(
39    header: &H,
40    chain_spec: &ChainSpec,
41) -> Result<(), ConsensusError> {
42    if chain_spec.is_london_active_at_block(header.number()) && header.base_fee_per_gas().is_none()
43    {
44        return Err(ConsensusError::BaseFeeMissing)
45    }
46    Ok(())
47}
48
49/// Validate that withdrawals are present in Shanghai
50///
51/// See [EIP-4895]: Beacon chain push withdrawals as operations
52///
53/// [EIP-4895]: https://eips.ethereum.org/EIPS/eip-4895
54#[inline]
55pub fn validate_shanghai_withdrawals<B: Block>(
56    block: &SealedBlock<B>,
57) -> Result<(), ConsensusError> {
58    let withdrawals = block.body().withdrawals().ok_or(ConsensusError::BodyWithdrawalsMissing)?;
59    let withdrawals_root = alloy_consensus::proofs::calculate_withdrawals_root(withdrawals);
60    let header_withdrawals_root =
61        block.withdrawals_root().ok_or(ConsensusError::WithdrawalsRootMissing)?;
62    if withdrawals_root != *header_withdrawals_root {
63        return Err(ConsensusError::BodyWithdrawalsRootDiff(
64            GotExpected { got: withdrawals_root, expected: header_withdrawals_root }.into(),
65        ));
66    }
67    Ok(())
68}
69
70/// Validate that blob gas is present in the block if Cancun is active.
71///
72/// See [EIP-4844]: Shard Blob Transactions
73///
74/// [EIP-4844]: https://eips.ethereum.org/EIPS/eip-4844
75#[inline]
76pub fn validate_cancun_gas<B: Block>(block: &SealedBlock<B>) -> Result<(), ConsensusError> {
77    // Check that the blob gas used in the header matches the sum of the blob gas used by each
78    // blob tx
79    let header_blob_gas_used = block.blob_gas_used().ok_or(ConsensusError::BlobGasUsedMissing)?;
80    let total_blob_gas = block.body().blob_gas_used();
81    if total_blob_gas != header_blob_gas_used {
82        return Err(ConsensusError::BlobGasUsedDiff(GotExpected {
83            got: header_blob_gas_used,
84            expected: total_blob_gas,
85        }));
86    }
87    Ok(())
88}
89
90/// Ensures the block response data matches the header.
91///
92/// This ensures the body response items match the header's hashes:
93///   - ommer hash
94///   - transaction root
95///   - withdrawals root
96pub fn validate_body_against_header<B, H>(body: &B, header: &H) -> Result<(), ConsensusError>
97where
98    B: BlockBody,
99    H: BlockHeader,
100{
101    let ommers_hash = body.calculate_ommers_root();
102    if Some(header.ommers_hash()) != ommers_hash {
103        return Err(ConsensusError::BodyOmmersHashDiff(
104            GotExpected {
105                got: ommers_hash.unwrap_or(EMPTY_OMMER_ROOT_HASH),
106                expected: header.ommers_hash(),
107            }
108            .into(),
109        ))
110    }
111
112    let tx_root = body.calculate_tx_root();
113    if header.transactions_root() != tx_root {
114        return Err(ConsensusError::BodyTransactionRootDiff(
115            GotExpected { got: tx_root, expected: header.transactions_root() }.into(),
116        ))
117    }
118
119    match (header.withdrawals_root(), body.calculate_withdrawals_root()) {
120        (Some(header_withdrawals_root), Some(withdrawals_root)) => {
121            if withdrawals_root != header_withdrawals_root {
122                return Err(ConsensusError::BodyWithdrawalsRootDiff(
123                    GotExpected { got: withdrawals_root, expected: header_withdrawals_root }.into(),
124                ))
125            }
126        }
127        (None, None) => {
128            // this is ok because we assume the fork is not active in this case
129        }
130        _ => return Err(ConsensusError::WithdrawalsRootUnexpected),
131    }
132
133    Ok(())
134}
135
136/// Validate a block without regard for state:
137///
138/// - Compares the ommer hash in the block header to the block body
139/// - Compares the transactions root in the block header to the block body
140/// - Pre-execution transaction validation
141pub fn validate_block_pre_execution<B, ChainSpec>(
142    block: &SealedBlock<B>,
143    chain_spec: &ChainSpec,
144) -> Result<(), ConsensusError>
145where
146    B: Block,
147    ChainSpec: EthChainSpec + EthereumHardforks,
148{
149    validate_block_pre_execution_with_tx_root(block, chain_spec, None)
150}
151
152/// Validate a block without regard for state using an optional pre-computed transaction root.
153///
154/// - Compares the ommer hash in the block header to the block body
155/// - Compares the transactions root in the block header to the block body
156/// - Pre-execution transaction validation
157///
158/// If `transaction_root` is provided, it is used instead of recomputing the transaction trie
159/// root from the block body. The caller must ensure this value was derived from
160/// `block.body().calculate_tx_root()`.
161pub fn validate_block_pre_execution_with_tx_root<B, ChainSpec>(
162    block: &SealedBlock<B>,
163    chain_spec: &ChainSpec,
164    transaction_root: Option<B256>,
165) -> Result<(), ConsensusError>
166where
167    B: Block,
168    ChainSpec: EthChainSpec + EthereumHardforks,
169{
170    post_merge_hardfork_fields(block, chain_spec)?;
171
172    // Check transaction root
173    let expected_transaction_root = block.header().transactions_root();
174    let calculated_transaction_root =
175        transaction_root.unwrap_or_else(|| block.body().calculate_tx_root());
176    if calculated_transaction_root != expected_transaction_root {
177        return Err(ConsensusError::BodyTransactionRootDiff(
178            GotExpected { got: calculated_transaction_root, expected: expected_transaction_root }
179                .into(),
180        ))
181    }
182
183    Ok(())
184}
185
186/// Validates the ommers hash and other fork-specific fields.
187///
188/// These fork-specific validations are:
189/// * EIP-4895 withdrawals validation, if shanghai is active based on the given chainspec. See more
190///   information about the specific checks in [`validate_shanghai_withdrawals`]. Before shanghai,
191///   the block body must not contain withdrawals.
192/// * EIP-4844 blob gas validation, if cancun is active based on the given chainspec. See more
193///   information about the specific checks in [`validate_cancun_gas`].
194/// * EIP-7934 block size limit validation, if osaka is active based on the given chainspec.
195pub fn post_merge_hardfork_fields<B, ChainSpec>(
196    block: &SealedBlock<B>,
197    chain_spec: &ChainSpec,
198) -> Result<(), ConsensusError>
199where
200    B: Block,
201    ChainSpec: EthereumHardforks,
202{
203    // Check ommers hash
204    let ommers_hash = block.body().calculate_ommers_root();
205    if Some(block.ommers_hash()) != ommers_hash {
206        return Err(ConsensusError::BodyOmmersHashDiff(
207            GotExpected {
208                got: ommers_hash.unwrap_or(EMPTY_OMMER_ROOT_HASH),
209                expected: block.ommers_hash(),
210            }
211            .into(),
212        ))
213    }
214
215    // EIP-4895: Beacon chain push withdrawals as operations
216    if chain_spec.is_shanghai_active_at_timestamp(block.timestamp()) {
217        validate_shanghai_withdrawals(block)?;
218    } else if block.body().withdrawals().is_some() {
219        // Pre-Shanghai headers carry no withdrawals root, so body withdrawals are not committed
220        // to by the block hash and must be absent.
221        return Err(ConsensusError::BodyWithdrawalsUnexpected)
222    }
223
224    if chain_spec.is_cancun_active_at_timestamp(block.timestamp()) {
225        validate_cancun_gas(block)?;
226    }
227
228    if chain_spec.is_osaka_active_at_timestamp(block.timestamp()) &&
229        block.rlp_length() > MAX_RLP_BLOCK_SIZE
230    {
231        return Err(ConsensusError::BlockTooLarge {
232            rlp_length: block.rlp_length(),
233            max_rlp_length: MAX_RLP_BLOCK_SIZE,
234        })
235    }
236
237    Ok(())
238}
239
240/// Validates that the EIP-4844 header fields exist and conform to the spec. This ensures that:
241///
242///  * `blob_gas_used` exists as a header field
243///  * `parent_beacon_block_root` exists as a header field
244///  * `blob_gas_used` is a multiple of `DATA_GAS_PER_BLOB`
245///  * `blob_gas_used` doesn't exceed the max allowed blob gas based on the given params
246pub fn validate_4844_header_standalone<H: BlockHeader>(
247    header: &H,
248    blob_params: BlobParams,
249) -> Result<(), ConsensusError> {
250    let blob_gas_used = header.blob_gas_used().ok_or(ConsensusError::BlobGasUsedMissing)?;
251
252    if header.parent_beacon_block_root().is_none() {
253        return Err(ConsensusError::ParentBeaconBlockRootMissing)
254    }
255
256    if !blob_gas_used.is_multiple_of(DATA_GAS_PER_BLOB) {
257        return Err(ConsensusError::BlobGasUsedNotMultipleOfBlobGasPerBlob {
258            blob_gas_used,
259            blob_gas_per_blob: DATA_GAS_PER_BLOB,
260        })
261    }
262
263    if blob_gas_used > blob_params.max_blob_gas_per_block() {
264        return Err(ConsensusError::BlobGasUsedExceedsMaxBlobGasPerBlock {
265            blob_gas_used,
266            max_blob_gas_per_block: blob_params.max_blob_gas_per_block(),
267        })
268    }
269
270    Ok(())
271}
272
273/// Validates the header's extra data according to the beacon consensus rules.
274///
275/// From yellow paper: extraData: An arbitrary byte array containing data relevant to this block.
276/// This must be 32 bytes or fewer; formally Hx.
277#[inline]
278pub fn validate_header_extra_data<H: BlockHeader>(
279    header: &H,
280    max_size: usize,
281) -> Result<(), ConsensusError> {
282    let extra_data_len = header.extra_data().len();
283    if extra_data_len > max_size {
284        Err(ConsensusError::ExtraDataExceedsMax { len: extra_data_len })
285    } else {
286        Ok(())
287    }
288}
289
290/// Validates against the parent hash and number.
291///
292/// This function ensures that the header block number is sequential and that the hash of the parent
293/// header matches the parent hash in the header.
294#[inline]
295pub fn validate_against_parent_hash_number<H: BlockHeader>(
296    header: &H,
297    parent: &SealedHeader<H>,
298) -> Result<(), ConsensusError> {
299    if parent.hash() != header.parent_hash() {
300        return Err(ConsensusError::ParentHashMismatch(
301            GotExpected { got: header.parent_hash(), expected: parent.hash() }.into(),
302        ))
303    }
304
305    let Some(parent_number) = parent.number().checked_add(1) else {
306        // parent block already reached the maximum
307        return Err(ConsensusError::ParentBlockNumberMismatch {
308            parent_block_number: parent.number(),
309            block_number: u64::MAX,
310        })
311    };
312
313    // Parent number is consistent.
314    if parent_number != header.number() {
315        return Err(ConsensusError::ParentBlockNumberMismatch {
316            parent_block_number: parent.number(),
317            block_number: header.number(),
318        })
319    }
320
321    Ok(())
322}
323
324/// Validates the base fee against the parent and EIP-1559 rules.
325#[inline]
326pub fn validate_against_parent_eip1559_base_fee<ChainSpec: EthChainSpec + EthereumHardforks>(
327    header: &ChainSpec::Header,
328    parent: &ChainSpec::Header,
329    chain_spec: &ChainSpec,
330) -> Result<(), ConsensusError> {
331    if chain_spec.is_london_active_at_block(header.number()) {
332        let base_fee = header.base_fee_per_gas().ok_or(ConsensusError::BaseFeeMissing)?;
333
334        let expected_base_fee = if chain_spec
335            .ethereum_fork_activation(EthereumHardfork::London)
336            .transitions_at_block(header.number())
337        {
338            alloy_eips::eip1559::INITIAL_BASE_FEE
339        } else {
340            chain_spec
341                .next_block_base_fee(parent, header.timestamp())
342                .ok_or(ConsensusError::BaseFeeMissing)?
343        };
344        if expected_base_fee != base_fee {
345            return Err(ConsensusError::BaseFeeDiff(GotExpected {
346                expected: expected_base_fee,
347                got: base_fee,
348            }))
349        }
350    }
351
352    Ok(())
353}
354
355/// Validates that the block timestamp is greater than the parent block timestamp.
356#[inline]
357pub fn validate_against_parent_timestamp<H: BlockHeader>(
358    header: &H,
359    parent: &H,
360) -> Result<(), ConsensusError> {
361    if header.timestamp() <= parent.timestamp() {
362        return Err(ConsensusError::TimestampIsInPast {
363            parent_timestamp: parent.timestamp(),
364            timestamp: header.timestamp(),
365        })
366    }
367    Ok(())
368}
369
370/// Validates gas limit against parent gas limit.
371///
372/// The maximum allowable difference between self and parent gas limits is determined by the
373/// parent's gas limit divided by the [`GAS_LIMIT_BOUND_DIVISOR`].
374#[inline]
375pub fn validate_against_parent_gas_limit<
376    H: BlockHeader,
377    ChainSpec: EthChainSpec + EthereumHardforks,
378>(
379    header: &SealedHeader<H>,
380    parent: &SealedHeader<H>,
381    chain_spec: &ChainSpec,
382) -> Result<(), ConsensusError> {
383    // Determine the parent gas limit, considering elasticity multiplier on the London fork.
384    let parent_gas_limit = if !chain_spec.is_london_active_at_block(parent.number()) &&
385        chain_spec.is_london_active_at_block(header.number())
386    {
387        parent.gas_limit() *
388            chain_spec.base_fee_params_at_timestamp(header.timestamp()).elasticity_multiplier
389                as u64
390    } else {
391        parent.gas_limit()
392    };
393
394    // Check for an increase in gas limit beyond the allowed threshold.
395    if header.gas_limit() > parent_gas_limit {
396        if header.gas_limit() - parent_gas_limit >= parent_gas_limit / GAS_LIMIT_BOUND_DIVISOR {
397            return Err(ConsensusError::GasLimitInvalidIncrease {
398                parent_gas_limit,
399                child_gas_limit: header.gas_limit(),
400            })
401        }
402    }
403    // Check for a decrease in gas limit beyond the allowed threshold.
404    else if parent_gas_limit - header.gas_limit() >= parent_gas_limit / GAS_LIMIT_BOUND_DIVISOR {
405        return Err(ConsensusError::GasLimitInvalidDecrease {
406            parent_gas_limit,
407            child_gas_limit: header.gas_limit(),
408        })
409    }
410    // Check if the self gas limit is below the minimum required limit.
411    if header.gas_limit() < MINIMUM_GAS_LIMIT {
412        return Err(ConsensusError::GasLimitInvalidMinimum { child_gas_limit: header.gas_limit() })
413    }
414
415    Ok(())
416}
417
418/// Validates that the EIP-4844 header fields are correct with respect to the parent block. This
419/// ensures that the `blob_gas_used` and `excess_blob_gas` fields exist in the child header, and
420/// that the `excess_blob_gas` field matches the expected `excess_blob_gas` calculated from the
421/// parent header fields.
422pub fn validate_against_parent_4844<H: BlockHeader>(
423    header: &H,
424    parent: &H,
425    blob_params: BlobParams,
426) -> Result<(), ConsensusError> {
427    // From [EIP-4844](https://eips.ethereum.org/EIPS/eip-4844#header-extension):
428    //
429    // > For the first post-fork block, both parent.blob_gas_used and parent.excess_blob_gas
430    // > are evaluated as 0.
431    //
432    // This means in the first post-fork block, calc_excess_blob_gas will return 0.
433    let parent_blob_gas_used = parent.blob_gas_used().unwrap_or(0);
434    let parent_excess_blob_gas = parent.excess_blob_gas().unwrap_or(0);
435
436    if header.blob_gas_used().is_none() {
437        return Err(ConsensusError::BlobGasUsedMissing)
438    }
439    let excess_blob_gas = header.excess_blob_gas().ok_or(ConsensusError::ExcessBlobGasMissing)?;
440
441    let parent_base_fee_per_gas = parent.base_fee_per_gas().unwrap_or(0);
442    let expected_excess_blob_gas = blob_params.next_block_excess_blob_gas_osaka(
443        parent_excess_blob_gas,
444        parent_blob_gas_used,
445        parent_base_fee_per_gas,
446    );
447    if expected_excess_blob_gas != excess_blob_gas {
448        return Err(ConsensusError::ExcessBlobGasDiff {
449            diff: GotExpected { got: excess_blob_gas, expected: expected_excess_blob_gas },
450            parent_excess_blob_gas,
451            parent_blob_gas_used,
452        })
453    }
454
455    Ok(())
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use alloy_consensus::{BlockBody, Header, TxEip4844};
462    use alloy_eips::{eip4844::DATA_GAS_PER_BLOB, eip4895::Withdrawals};
463    use alloy_primitives::{Address, Bytes, Signature, U256};
464    use rand::Rng;
465    use reth_chainspec::ChainSpecBuilder;
466    use reth_ethereum_primitives::{Transaction, TransactionSigned};
467    use reth_primitives_traits::proofs;
468
469    fn mock_blob_tx(nonce: u64, num_blobs: usize) -> TransactionSigned {
470        let mut rng = rand::rng();
471        let request = Transaction::Eip4844(TxEip4844 {
472            chain_id: 1u64,
473            nonce,
474            max_fee_per_gas: 0x28f000fff,
475            max_priority_fee_per_gas: 0x28f000fff,
476            max_fee_per_blob_gas: 0x7,
477            gas_limit: 10,
478            to: Address::default(),
479            value: U256::from(3_u64),
480            input: Bytes::from(vec![1, 2]),
481            access_list: Default::default(),
482            blob_versioned_hashes: std::iter::repeat_with(|| rng.random())
483                .take(num_blobs)
484                .collect(),
485        });
486
487        let signature = Signature::new(U256::default(), U256::default(), true);
488
489        TransactionSigned::new_unhashed(request, signature)
490    }
491
492    #[test]
493    fn cancun_block_incorrect_blob_gas_used() {
494        let chain_spec = ChainSpecBuilder::mainnet().cancun_activated().build();
495
496        // create a tx with 10 blobs
497        let transaction = mock_blob_tx(1, 10);
498
499        let header = Header {
500            base_fee_per_gas: Some(1337),
501            withdrawals_root: Some(proofs::calculate_withdrawals_root(&[])),
502            blob_gas_used: Some(1),
503            transactions_root: proofs::calculate_transaction_root(std::slice::from_ref(
504                &transaction,
505            )),
506            ..Default::default()
507        };
508        let body = BlockBody {
509            transactions: vec![transaction],
510            ommers: vec![],
511            withdrawals: Some(Withdrawals::default()),
512        };
513
514        let block = SealedBlock::seal_slow(alloy_consensus::Block { header, body });
515
516        // 10 blobs times the blob gas per blob.
517        let expected_blob_gas_used = 10 * DATA_GAS_PER_BLOB;
518
519        // validate blob, it should fail blob gas used validation
520        assert!(matches!(
521            validate_block_pre_execution(&block, &chain_spec).unwrap_err(),
522            ConsensusError::BlobGasUsedDiff(diff)
523                if diff.got == 1 && diff.expected == expected_blob_gas_used
524        ));
525    }
526
527    #[test]
528    fn validate_header_extra_data_with_custom_limit() {
529        // Test with default 32 bytes - should pass
530        let header_32 = Header { extra_data: Bytes::from(vec![0; 32]), ..Default::default() };
531        assert!(validate_header_extra_data(&header_32, 32).is_ok());
532
533        // Test exceeding default - should fail
534        let header_33 = Header { extra_data: Bytes::from(vec![0; 33]), ..Default::default() };
535        assert!(matches!(
536            validate_header_extra_data(&header_33, 32).unwrap_err(),
537            ConsensusError::ExtraDataExceedsMax { len } if len == 33
538        ));
539
540        // Test with custom larger limit - should pass
541        assert!(validate_header_extra_data(&header_33, 64).is_ok());
542    }
543
544    #[test]
545    fn precomputed_tx_root_correct_passes() {
546        let chain_spec = ChainSpecBuilder::mainnet().cancun_activated().build();
547
548        let transaction = mock_blob_tx(1, 1);
549        let tx_root = proofs::calculate_transaction_root(std::slice::from_ref(&transaction));
550
551        let header = Header {
552            base_fee_per_gas: Some(1337),
553            withdrawals_root: Some(proofs::calculate_withdrawals_root(&[])),
554            transactions_root: tx_root,
555            blob_gas_used: Some(DATA_GAS_PER_BLOB),
556            excess_blob_gas: Some(0),
557            ..Default::default()
558        };
559        let body = BlockBody {
560            transactions: vec![transaction],
561            ommers: vec![],
562            withdrawals: Some(Withdrawals::default()),
563        };
564
565        let block = SealedBlock::seal_slow(alloy_consensus::Block { header, body });
566
567        // Some(correct_root) should pass just like None
568        assert!(
569            validate_block_pre_execution_with_tx_root(&block, &chain_spec, Some(tx_root)).is_ok()
570        );
571        assert!(validate_block_pre_execution_with_tx_root(&block, &chain_spec, None).is_ok());
572    }
573
574    #[test]
575    fn precomputed_tx_root_wrong_fails() {
576        let chain_spec = ChainSpecBuilder::mainnet().cancun_activated().build();
577
578        let transaction = mock_blob_tx(1, 1);
579        let tx_root = proofs::calculate_transaction_root(std::slice::from_ref(&transaction));
580
581        let header = Header {
582            base_fee_per_gas: Some(1337),
583            withdrawals_root: Some(proofs::calculate_withdrawals_root(&[])),
584            transactions_root: tx_root,
585            blob_gas_used: Some(DATA_GAS_PER_BLOB),
586            excess_blob_gas: Some(0),
587            ..Default::default()
588        };
589        let body = BlockBody {
590            transactions: vec![transaction],
591            ommers: vec![],
592            withdrawals: Some(Withdrawals::default()),
593        };
594
595        let block = SealedBlock::seal_slow(alloy_consensus::Block { header, body });
596
597        let wrong_root = B256::repeat_byte(0xff);
598        assert!(matches!(
599            validate_block_pre_execution_with_tx_root(&block, &chain_spec, Some(wrong_root))
600                .unwrap_err(),
601            ConsensusError::BodyTransactionRootDiff(diff)
602                if diff.0.got == wrong_root && diff.0.expected == tx_root
603        ));
604    }
605
606    #[test]
607    fn pre_shanghai_block_with_withdrawals_fails() {
608        let chain_spec = ChainSpecBuilder::mainnet().paris_activated().build();
609
610        let header = Header {
611            transactions_root: proofs::calculate_transaction_root::<TransactionSigned>(&[]),
612            ..Default::default()
613        };
614        let body = BlockBody::<TransactionSigned>::default();
615
616        let block =
617            SealedBlock::seal_slow(alloy_consensus::Block::new(header.clone(), body.clone()));
618        assert!(validate_block_pre_execution(&block, &chain_spec).is_ok());
619
620        let body = BlockBody { withdrawals: Some(Withdrawals::default()), ..body };
621        let block = SealedBlock::seal_slow(alloy_consensus::Block::new(header, body));
622        assert!(matches!(
623            validate_block_pre_execution(&block, &chain_spec).unwrap_err(),
624            ConsensusError::BodyWithdrawalsUnexpected
625        ));
626    }
627}