Skip to main content

reth_rpc/
validation.rs

1use self::blob_cache::BlobValidationCache;
2use alloy_consensus::{
3    BlobTransactionValidationError, BlockHeader, EnvKzgSettings, Transaction, TxReceipt,
4};
5use alloy_eip7928::{bal::DecodedBal, compute_block_access_list_hash};
6use alloy_eips::eip7685::RequestsOrHash;
7use alloy_primitives::{map::AddressSet, Address, B256, U256};
8use alloy_rpc_types_beacon::relay::{
9    BidTrace, BuilderBlockValidationRequest, BuilderBlockValidationRequestV2,
10    BuilderBlockValidationRequestV3, BuilderBlockValidationRequestV4,
11    BuilderBlockValidationRequestV5, BuilderBlockValidationRequestV6,
12};
13use alloy_rpc_types_engine::{
14    BlobsBundleV1, BlobsBundleV2, CancunPayloadFields, ExecutionData, ExecutionPayload,
15    ExecutionPayloadSidecar, PraguePayloadFields,
16};
17use async_trait::async_trait;
18use core::fmt;
19use jsonrpsee::core::RpcResult;
20use jsonrpsee_types::error::ErrorObject;
21use reth_chainspec::{ChainSpecProvider, EthereumHardforks};
22use reth_consensus::{Consensus, FullConsensus};
23use reth_consensus_common::validation::MAX_RLP_BLOCK_SIZE;
24use reth_engine_primitives::PayloadValidator;
25use reth_errors::{BlockExecutionError, ConsensusError, ProviderError};
26use reth_evm::{execute::Executor, ConfigureEvm, SenderRecoveryCache};
27use reth_execution_types::BlockExecutionOutput;
28use reth_metrics::{
29    metrics,
30    metrics::{gauge, Gauge},
31    Metrics,
32};
33use reth_node_api::{NewPayloadError, PayloadTypes};
34use reth_primitives_traits::{
35    block::error::SealedBlockRecoveryError, BlockBody, GotExpected, NodePrimitives, RecoveredBlock,
36    SealedBlock, SealedHeaderFor,
37};
38use reth_revm::{cached::CachedReads, database::StateProviderDatabase};
39use reth_rpc_api::BlockSubmissionValidationApiServer;
40use reth_rpc_server_types::result::{internal_rpc_err, invalid_params_rpc_err};
41use reth_storage_api::{
42    BlockReaderIdExt, HashedPostStateProvider, StateProvider, StateProviderFactory,
43};
44use reth_tasks::Runtime;
45use serde::{Deserialize, Serialize};
46use sha2::{Digest, Sha256};
47use std::sync::Arc;
48use tokio::sync::{oneshot, RwLock};
49use tracing::warn;
50
51mod blob_cache;
52
53/// The type that implements the `validation` rpc namespace trait
54#[derive(Clone, Debug, derive_more::Deref)]
55pub struct ValidationApi<Provider, E: ConfigureEvm, T: PayloadTypes> {
56    #[deref]
57    inner: Arc<ValidationApiInner<Provider, E, T>>,
58}
59
60impl<Provider, E, T> ValidationApi<Provider, E, T>
61where
62    E: ConfigureEvm,
63    T: PayloadTypes,
64{
65    /// Create a new instance of the [`ValidationApi`]
66    ///
67    /// If a `sender_recovery_cache` is given, the senders of submitted blocks are recovered
68    /// through it, reusing senders that other node components have already recovered.
69    pub fn new(
70        provider: Provider,
71        consensus: Arc<dyn FullConsensus<E::Primitives>>,
72        evm_config: E,
73        config: ValidationApiConfig,
74        task_spawner: Runtime,
75        payload_validator: Arc<
76            dyn PayloadValidator<T, Block = <E::Primitives as NodePrimitives>::Block>,
77        >,
78        sender_recovery_cache: Option<SenderRecoveryCache>,
79    ) -> Self {
80        let ValidationApiConfig { disallow, validation_window } = config;
81
82        let inner = Arc::new(ValidationApiInner {
83            provider,
84            consensus,
85            payload_validator,
86            evm_config,
87            disallow,
88            validation_window,
89            cached_state: Default::default(),
90            validated_blobs: Default::default(),
91            task_spawner,
92            sender_recovery_cache,
93            metrics: Default::default(),
94        });
95
96        inner.metrics.disallow_size.set(inner.disallow.len() as f64);
97
98        let disallow_hash = hash_disallow_list(&inner.disallow);
99        let hash_gauge = gauge!("builder_validation_disallow_hash", "hash" => disallow_hash);
100        hash_gauge.set(1.0);
101
102        Self { inner }
103    }
104
105    /// Returns the cached reads for the given head hash.
106    async fn cached_reads(&self, head: B256) -> CachedReads {
107        let cache = self.inner.cached_state.read().await;
108        if cache.0 == head {
109            cache.1.clone()
110        } else {
111            Default::default()
112        }
113    }
114
115    /// Updates the cached state for the given head hash.
116    async fn update_cached_reads(&self, head: B256, cached_state: CachedReads) {
117        let mut cache = self.inner.cached_state.write().await;
118        if cache.0 == head {
119            cache.1.extend(cached_state);
120        } else {
121            *cache = (head, cached_state)
122        }
123    }
124
125    /// Runs the validation on a blocking task.
126    ///
127    /// The validation is skipped if the request is dropped before it starts and stopped if the
128    /// request is dropped while the validation waits for the cached reads.
129    async fn spawn_validation<F>(&self, validation: F) -> RpcResult<()>
130    where
131        F: Future<Output = Result<(), ValidationApiError>> + Send + 'static,
132    {
133        let (mut tx, rx) = oneshot::channel();
134
135        self.task_spawner.spawn_blocking_task(async move {
136            let result = tokio::select! {
137                biased;
138                _ = tx.closed() => return,
139                result = validation => result.map_err(ErrorObject::from),
140            };
141            let _ = tx.send(result);
142        });
143
144        rx.await.map_err(|_| internal_rpc_err("Internal blocking task error"))?
145    }
146}
147
148impl<Provider, E, T> ValidationApi<Provider, E, T>
149where
150    Provider: BlockReaderIdExt<Header = <E::Primitives as NodePrimitives>::BlockHeader>
151        + ChainSpecProvider<ChainSpec: EthereumHardforks>
152        + StateProviderFactory
153        + 'static,
154    E: ConfigureEvm + 'static,
155    T: PayloadTypes<ExecutionData = ExecutionData>,
156{
157    /// Validates the given block and a [`BidTrace`] against it.
158    pub async fn validate_message_against_block(
159        &self,
160        block: RecoveredBlock<<E::Primitives as NodePrimitives>::Block>,
161        message: BidTrace,
162        registered_gas_limit: u64,
163        decoded_bal: Option<DecodedBal>,
164    ) -> Result<(), ValidationApiError> {
165        self.validate_message_against_header(block.sealed_header(), &message)?;
166
167        self.consensus.validate_header(block.sealed_header())?;
168        self.consensus.validate_block_pre_execution(block.sealed_block())?;
169
170        if !self.disallow.is_empty() {
171            if self.disallow.contains(&block.beneficiary()) {
172                return Err(ValidationApiError::Blacklist(block.beneficiary()))
173            }
174            if self.disallow.contains(&message.proposer_fee_recipient) {
175                return Err(ValidationApiError::Blacklist(message.proposer_fee_recipient))
176            }
177            for (sender, tx) in block.senders_iter().zip(block.body().transactions()) {
178                if self.disallow.contains(sender) {
179                    return Err(ValidationApiError::Blacklist(*sender))
180                }
181                if let Some(to) = tx.to() &&
182                    self.disallow.contains(&to)
183                {
184                    return Err(ValidationApiError::Blacklist(to))
185                }
186            }
187        }
188
189        let latest_header =
190            self.provider.latest_header()?.ok_or_else(|| ValidationApiError::MissingLatestBlock)?;
191
192        let parent_header = if block.parent_hash() == latest_header.hash() {
193            latest_header
194        } else {
195            // parent is not the latest header so we need to fetch it and ensure it's not too old
196            let parent_header = self
197                .provider
198                .sealed_header_by_hash(block.parent_hash())?
199                .ok_or_else(|| ValidationApiError::MissingParentBlock)?;
200
201            if latest_header.number().saturating_sub(parent_header.number()) >
202                self.validation_window
203            {
204                return Err(ValidationApiError::BlockTooOld)
205            }
206            parent_header
207        };
208
209        self.consensus.validate_header_against_parent(block.sealed_header(), &parent_header)?;
210        parent_header.validate_gas_limit(registered_gas_limit, block.gas_limit()).map_err(
211            |err| {
212                ValidationApiError::GasLimitMismatch(GotExpected {
213                    got: err.got,
214                    expected: err.expected,
215                })
216            },
217        )?;
218
219        // Ensure the submitted block access list does not exceed the block gas limit (EIP-7928)
220        if let Some(decoded_bal) = decoded_bal {
221            decoded_bal
222                .as_bal()
223                .validate_gas_limit(block.gas_limit())
224                .map_err(ConsensusError::from)?;
225        }
226
227        let parent_header_hash = parent_header.hash();
228        let state_provider = self.provider.state_by_block_hash(parent_header_hash)?;
229
230        let mut request_cache = self.cached_reads(parent_header_hash).await;
231
232        let (output, block_access_list_hash) = {
233            let cached_db = request_cache
234                .as_db_mut(StateProviderDatabase::new((&state_provider).into_evm_state_provider()));
235            let mut executor = self.evm_config.batch_executor(cached_db);
236
237            let result = executor.execute_one(&block)?;
238
239            // The executor rebuilds the block access list whenever the block header contains a
240            // BAL hash. Comparing the rebuilt hash against the header post execution also
241            // commits to the submitted access list, because the header's BAL hash is derived
242            // from the submitted bytes.
243            let block_access_list_hash =
244                executor.take_bal().map(|bal| compute_block_access_list_hash(&bal));
245
246            let mut state = executor.into_state();
247            if !self.disallow.is_empty() {
248                // Check whether the submission interacted with any blacklisted account by
249                // scanning the `State`'s cache that records everything read from database
250                // during execution.
251                for account in state.cache.accounts.keys() {
252                    if self.disallow.contains(account) {
253                        return Err(ValidationApiError::Blacklist(*account))
254                    }
255                }
256            }
257
258            (BlockExecutionOutput { state: state.take_bundle(), result }, block_access_list_hash)
259        };
260
261        // update the cached reads
262        self.update_cached_reads(parent_header_hash, request_cache).await;
263
264        self.consensus.validate_block_post_execution(
265            &block,
266            &output,
267            None,
268            block_access_list_hash,
269        )?;
270
271        self.ensure_payment(&block, &output, &message)?;
272
273        let hashed_state = state_provider.hashed_post_state(&output.state)?;
274        let state_root = state_provider.state_root(hashed_state)?;
275
276        if state_root != block.header().state_root() {
277            return Err(ConsensusError::BodyStateRootDiff(
278                GotExpected { got: state_root, expected: block.header().state_root() }.into(),
279            )
280            .into())
281        }
282
283        Ok(())
284    }
285
286    /// Ensures that fields of [`BidTrace`] match the fields of the [`SealedHeaderFor`].
287    fn validate_message_against_header(
288        &self,
289        header: &SealedHeaderFor<E::Primitives>,
290        message: &BidTrace,
291    ) -> Result<(), ValidationApiError> {
292        if header.hash() != message.block_hash {
293            Err(ValidationApiError::BlockHashMismatch(GotExpected {
294                got: message.block_hash,
295                expected: header.hash(),
296            }))
297        } else if header.parent_hash() != message.parent_hash {
298            Err(ValidationApiError::ParentHashMismatch(GotExpected {
299                got: message.parent_hash,
300                expected: header.parent_hash(),
301            }))
302        } else if header.gas_limit() != message.gas_limit {
303            Err(ValidationApiError::GasLimitMismatch(GotExpected {
304                got: message.gas_limit,
305                expected: header.gas_limit(),
306            }))
307        } else if header.gas_used() != message.gas_used {
308            Err(ValidationApiError::GasUsedMismatch(GotExpected {
309                got: message.gas_used,
310                expected: header.gas_used(),
311            }))
312        } else {
313            Ok(())
314        }
315    }
316
317    /// Ensures that the proposer has received [`BidTrace::value`] for this block.
318    ///
319    /// Firstly attempts to verify the payment by checking the state changes, otherwise falls back
320    /// to checking the latest block transaction.
321    fn ensure_payment(
322        &self,
323        block: &SealedBlock<<E::Primitives as NodePrimitives>::Block>,
324        output: &BlockExecutionOutput<<E::Primitives as NodePrimitives>::Receipt>,
325        message: &BidTrace,
326    ) -> Result<(), ValidationApiError> {
327        // A zero-value bid promises the proposer nothing, so the payload owes nothing.
328        if message.value.is_zero() {
329            return Ok(())
330        }
331
332        let (mut balance_before, balance_after) = if let Some(acc) =
333            output.state.account(&message.proposer_fee_recipient)
334        {
335            let balance_before = acc.original_info.as_ref().map(|i| i.balance).unwrap_or_default();
336            let balance_after = acc.info.as_ref().map(|i| i.balance).unwrap_or_default();
337
338            (balance_before, balance_after)
339        } else {
340            // account might have balance but considering it zero is fine as long as we know
341            // that balance have not changed
342            (U256::ZERO, U256::ZERO)
343        };
344
345        if let Some(withdrawals) = block.body().withdrawals() {
346            for withdrawal in withdrawals {
347                if withdrawal.address == message.proposer_fee_recipient {
348                    balance_before += withdrawal.amount_wei();
349                }
350            }
351        }
352
353        if balance_after >= balance_before.saturating_add(message.value) {
354            return Ok(())
355        }
356
357        let (receipt, tx) = output
358            .receipts
359            .last()
360            .zip(block.body().transactions().last())
361            .ok_or(ValidationApiError::ProposerPayment)?;
362
363        if !receipt.status() {
364            return Err(ValidationApiError::ProposerPayment)
365        }
366
367        if tx.to() != Some(message.proposer_fee_recipient) {
368            return Err(ValidationApiError::ProposerPayment)
369        }
370
371        if tx.value() != message.value {
372            return Err(ValidationApiError::ProposerPayment)
373        }
374
375        if !tx.input().is_empty() {
376            return Err(ValidationApiError::ProposerPayment)
377        }
378
379        if let Some(block_base_fee) = block.header().base_fee_per_gas() &&
380            tx.effective_tip_per_gas(block_base_fee).unwrap_or_default() != 0
381        {
382            return Err(ValidationApiError::ProposerPayment)
383        }
384
385        Ok(())
386    }
387
388    /// Validates the given [`BlobsBundleV1`] and returns versioned hashes for blobs.
389    pub fn validate_blobs_bundle(
390        &self,
391        blobs_bundle: BlobsBundleV1,
392    ) -> Result<Vec<B256>, ValidationApiError> {
393        let versioned_hashes = blobs_bundle.versioned_hashes();
394        let sidecar =
395            blobs_bundle.try_into_sidecar().map_err(|_| ValidationApiError::InvalidBlobsBundle)?;
396
397        sidecar.validate(&versioned_hashes, EnvKzgSettings::default().get())?;
398        Ok(versioned_hashes)
399    }
400
401    /// Validates the given [`BlobsBundleV2`] and returns versioned hashes for blobs.
402    ///
403    /// Exact blob, commitment, and cell-proof matches from recent submissions reuse KZG
404    /// validation. The resulting hashes are still checked against the block's EIP-4844
405    /// transactions during payload validation.
406    pub fn validate_blobs_bundle_v2(
407        &self,
408        blobs_bundle: BlobsBundleV2,
409    ) -> Result<Vec<B256>, ValidationApiError> {
410        self.validated_blobs.validate(blobs_bundle)
411    }
412
413    /// Converts the payload into a block and recovers the transaction senders.
414    ///
415    /// Like the engine's `newPayload` handling, this leaves payload validation and conversion to
416    /// the payload validator and recovers senders through the shared [`SenderRecoveryCache`] if
417    /// one is configured, so senders already recovered on transaction ingress or payload
418    /// execution are reused. Without a cache, this calls
419    /// [`PayloadValidator::ensure_well_formed_payload`] so validator overrides are preserved.
420    ///
421    /// Senders recovered here are cached before the block is validated. Competing submissions for
422    /// the same slot share most of their transactions, so the senders are likely to be needed again
423    /// even if this submission is rejected, and only successfully recovered senders can be cached.
424    fn recover_payload(
425        &self,
426        payload: ExecutionData,
427    ) -> Result<RecoveredBlock<<E::Primitives as NodePrimitives>::Block>, ValidationApiError> {
428        let Some(cache) = &self.sender_recovery_cache else {
429            return self.payload_validator.ensure_well_formed_payload(payload).map_err(Into::into)
430        };
431
432        let block = self.payload_validator.convert_payload_to_block(payload)?;
433        let recovered = match cache.recover_signers(block.body().transactions()) {
434            Ok(senders) => Ok(RecoveredBlock::new_sealed(block, senders)),
435            Err(_) => Err(SealedBlockRecoveryError::new(block)),
436        };
437        recovered.map_err(|err| NewPayloadError::Other(err.into()).into())
438    }
439
440    /// Core logic for validating the builder submission v3
441    async fn validate_builder_submission_v3(
442        &self,
443        request: BuilderBlockValidationRequestV3,
444    ) -> Result<(), ValidationApiError> {
445        let block = self.recover_payload(ExecutionData {
446            payload: ExecutionPayload::V3(request.request.execution_payload),
447            sidecar: ExecutionPayloadSidecar::v3(CancunPayloadFields {
448                parent_beacon_block_root: request.parent_beacon_block_root,
449                versioned_hashes: self.validate_blobs_bundle(request.request.blobs_bundle)?,
450            }),
451        })?;
452
453        self.validate_message_against_block(
454            block,
455            request.request.message,
456            request.registered_gas_limit,
457            None,
458        )
459        .await
460    }
461
462    /// Core logic for validating the builder submission v4
463    async fn validate_builder_submission_v4(
464        &self,
465        request: BuilderBlockValidationRequestV4,
466    ) -> Result<(), ValidationApiError> {
467        let block = self.recover_payload(ExecutionData {
468            payload: ExecutionPayload::V3(request.request.execution_payload),
469            sidecar: ExecutionPayloadSidecar::v4(
470                CancunPayloadFields {
471                    parent_beacon_block_root: request.parent_beacon_block_root,
472                    versioned_hashes: self.validate_blobs_bundle(request.request.blobs_bundle)?,
473                },
474                PraguePayloadFields {
475                    requests: RequestsOrHash::Requests(
476                        request.request.execution_requests.to_requests(),
477                    ),
478                },
479            ),
480        })?;
481
482        self.validate_message_against_block(
483            block,
484            request.request.message,
485            request.registered_gas_limit,
486            None,
487        )
488        .await
489    }
490
491    /// Core logic for validating the builder submission v5
492    async fn validate_builder_submission_v5(
493        &self,
494        request: BuilderBlockValidationRequestV5,
495    ) -> Result<(), ValidationApiError> {
496        let payload = ExecutionPayload::V3(request.request.execution_payload);
497        validate_message_against_payload(&request.request.message, &payload)?;
498
499        let block = self.recover_payload(ExecutionData {
500            payload,
501            sidecar: ExecutionPayloadSidecar::v4(
502                CancunPayloadFields {
503                    parent_beacon_block_root: request.parent_beacon_block_root,
504                    versioned_hashes: self
505                        .validate_blobs_bundle_v2(request.request.blobs_bundle)?,
506                },
507                PraguePayloadFields {
508                    requests: RequestsOrHash::Requests(
509                        request.request.execution_requests.to_requests(),
510                    ),
511                },
512            ),
513        })?;
514
515        // Check block size as per EIP-7934 (only applies when Osaka hardfork is active)
516        let chain_spec = self.provider.chain_spec();
517        if chain_spec.is_osaka_active_at_timestamp(block.timestamp()) {
518            let rlp_length = block.rlp_length();
519            if rlp_length > MAX_RLP_BLOCK_SIZE {
520                return Err(ValidationApiError::Consensus(ConsensusError::BlockTooLarge {
521                    rlp_length,
522                    max_rlp_length: MAX_RLP_BLOCK_SIZE,
523                }));
524            }
525        }
526
527        self.validate_message_against_block(
528            block,
529            request.request.message,
530            request.registered_gas_limit,
531            None,
532        )
533        .await
534    }
535
536    /// Core logic for validating the builder submission v6
537    async fn validate_builder_submission_v6(
538        &self,
539        request: BuilderBlockValidationRequestV6,
540    ) -> Result<(), ValidationApiError> {
541        let payload = ExecutionPayload::V4(request.request.execution_payload);
542        validate_message_against_payload(&request.request.message, &payload)?;
543
544        let decoded_bal =
545            DecodedBal::from_rlp_bytes(payload.as_v4().unwrap().block_access_list.clone())
546                .map_err(ValidationApiError::InvalidBlockAccessList)?;
547
548        let block = self.recover_payload(ExecutionData {
549            payload,
550            sidecar: ExecutionPayloadSidecar::v4(
551                CancunPayloadFields {
552                    parent_beacon_block_root: request.parent_beacon_block_root,
553                    versioned_hashes: self
554                        .validate_blobs_bundle_v2(request.request.blobs_bundle)?,
555                },
556                PraguePayloadFields {
557                    requests: RequestsOrHash::Requests(
558                        request.request.execution_requests.to_requests(),
559                    ),
560                },
561            ),
562        })?;
563
564        let chain_spec = self.provider.chain_spec();
565        if chain_spec.is_osaka_active_at_timestamp(block.timestamp()) {
566            let rlp_length = block.rlp_length();
567            if rlp_length > MAX_RLP_BLOCK_SIZE {
568                return Err(ValidationApiError::Consensus(ConsensusError::BlockTooLarge {
569                    rlp_length,
570                    max_rlp_length: MAX_RLP_BLOCK_SIZE,
571                }));
572            }
573        }
574
575        self.validate_message_against_block(
576            block,
577            request.request.message,
578            request.registered_gas_limit,
579            Some(decoded_bal),
580        )
581        .await
582    }
583}
584
585#[async_trait]
586impl<Provider, E, T> BlockSubmissionValidationApiServer for ValidationApi<Provider, E, T>
587where
588    Provider: BlockReaderIdExt<Header = <E::Primitives as NodePrimitives>::BlockHeader>
589        + ChainSpecProvider<ChainSpec: EthereumHardforks>
590        + StateProviderFactory
591        + Clone
592        + 'static,
593    E: ConfigureEvm + 'static,
594    T: PayloadTypes<ExecutionData = ExecutionData>,
595{
596    async fn validate_builder_submission_v1(
597        &self,
598        _request: BuilderBlockValidationRequest,
599    ) -> RpcResult<()> {
600        warn!(target: "rpc::flashbots", "Method `flashbots_validateBuilderSubmissionV1` is not supported");
601        Err(internal_rpc_err("unimplemented"))
602    }
603
604    async fn validate_builder_submission_v2(
605        &self,
606        _request: BuilderBlockValidationRequestV2,
607    ) -> RpcResult<()> {
608        warn!(target: "rpc::flashbots", "Method `flashbots_validateBuilderSubmissionV2` is not supported");
609        Err(internal_rpc_err("unimplemented"))
610    }
611
612    /// Validates a block submitted to the relay
613    async fn validate_builder_submission_v3(
614        &self,
615        request: BuilderBlockValidationRequestV3,
616    ) -> RpcResult<()> {
617        let this = self.clone();
618        let validation = async move { Self::validate_builder_submission_v3(&this, request).await };
619        self.spawn_validation(validation).await
620    }
621
622    /// Validates a block submitted to the relay
623    async fn validate_builder_submission_v4(
624        &self,
625        request: BuilderBlockValidationRequestV4,
626    ) -> RpcResult<()> {
627        let this = self.clone();
628        let validation = async move { Self::validate_builder_submission_v4(&this, request).await };
629        self.spawn_validation(validation).await
630    }
631
632    /// Validates a block submitted to the relay
633    async fn validate_builder_submission_v5(
634        &self,
635        request: BuilderBlockValidationRequestV5,
636    ) -> RpcResult<()> {
637        let this = self.clone();
638        let validation = async move { Self::validate_builder_submission_v5(&this, request).await };
639        self.spawn_validation(validation).await
640    }
641
642    /// Validates a block submitted to the relay
643    async fn validate_builder_submission_v6(
644        &self,
645        request: BuilderBlockValidationRequestV6,
646    ) -> RpcResult<()> {
647        let this = self.clone();
648        let validation = async move { Self::validate_builder_submission_v6(&this, request).await };
649        self.spawn_validation(validation).await
650    }
651}
652
653pub struct ValidationApiInner<Provider, E: ConfigureEvm, T: PayloadTypes> {
654    /// The provider that can interact with the chain.
655    provider: Provider,
656    /// Consensus implementation.
657    consensus: Arc<dyn FullConsensus<E::Primitives>>,
658    /// Execution payload validator.
659    payload_validator:
660        Arc<dyn PayloadValidator<T, Block = <E::Primitives as NodePrimitives>::Block>>,
661    /// Block executor factory.
662    evm_config: E,
663    /// Set of disallowed addresses
664    disallow: AddressSet,
665    /// The maximum block distance - parent to latest - allowed for validation
666    validation_window: u64,
667    /// Cached state reads to avoid redundant disk I/O across multiple validation attempts
668    /// targeting the same state. Stores a tuple of (`block_hash`, `cached_reads`) for the
669    /// latest head block state. Uses async `RwLock` to safely handle concurrent validation
670    /// requests.
671    cached_state: RwLock<(B256, CachedReads)>,
672    /// Recently validated blob, commitment, and cell-proof tuples shared by V2 submissions.
673    validated_blobs: BlobValidationCache,
674    /// Task spawner for blocking operations
675    task_spawner: Runtime,
676    /// Cache of recovered transaction senders shared with transaction ingress and payload
677    /// execution, if enabled.
678    sender_recovery_cache: Option<SenderRecoveryCache>,
679    /// Validation metrics
680    metrics: ValidationMetrics,
681}
682
683/// Ensures that the raw execution payload fields match the corresponding [`BidTrace`] fields.
684fn validate_message_against_payload(
685    message: &BidTrace,
686    payload: &ExecutionPayload,
687) -> Result<(), ValidationApiError> {
688    let payload = payload.as_v1();
689
690    if payload.block_hash != message.block_hash {
691        Err(ValidationApiError::BlockHashMismatch(GotExpected {
692            got: message.block_hash,
693            expected: payload.block_hash,
694        }))
695    } else if payload.parent_hash != message.parent_hash {
696        Err(ValidationApiError::ParentHashMismatch(GotExpected {
697            got: message.parent_hash,
698            expected: payload.parent_hash,
699        }))
700    } else if payload.gas_limit != message.gas_limit {
701        Err(ValidationApiError::GasLimitMismatch(GotExpected {
702            got: message.gas_limit,
703            expected: payload.gas_limit,
704        }))
705    } else if payload.gas_used != message.gas_used {
706        Err(ValidationApiError::GasUsedMismatch(GotExpected {
707            got: message.gas_used,
708            expected: payload.gas_used,
709        }))
710    } else {
711        Ok(())
712    }
713}
714
715/// Calculates a deterministic hash of the blocklist for change detection.
716///
717/// This function sorts addresses to ensure deterministic output regardless of
718/// insertion order, then computes a SHA256 hash of the concatenated addresses.
719fn hash_disallow_list(disallow: &AddressSet) -> String {
720    let mut sorted: Vec<_> = disallow.iter().collect();
721    sorted.sort_unstable(); // sort for deterministic hashing
722
723    let mut hasher = Sha256::new();
724    for addr in sorted {
725        hasher.update(addr.as_slice());
726    }
727
728    format!("{:x}", hasher.finalize())
729}
730
731impl<Provider, E: ConfigureEvm, T: PayloadTypes> fmt::Debug for ValidationApiInner<Provider, E, T> {
732    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
733        f.debug_struct("ValidationApiInner").finish_non_exhaustive()
734    }
735}
736
737/// Configuration for validation API.
738#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
739pub struct ValidationApiConfig {
740    /// Disallowed addresses.
741    pub disallow: AddressSet,
742    /// The maximum block distance - parent to latest - allowed for validation
743    pub validation_window: u64,
744}
745
746impl ValidationApiConfig {
747    /// Default validation blocks window of 3 blocks
748    pub const DEFAULT_VALIDATION_WINDOW: u64 = 3;
749}
750
751impl Default for ValidationApiConfig {
752    fn default() -> Self {
753        Self { disallow: Default::default(), validation_window: Self::DEFAULT_VALIDATION_WINDOW }
754    }
755}
756
757/// Errors thrown by the validation API.
758#[derive(Debug, thiserror::Error)]
759pub enum ValidationApiError {
760    #[error("block gas limit mismatch: {_0}")]
761    GasLimitMismatch(GotExpected<u64>),
762    #[error("block gas used mismatch: {_0}")]
763    GasUsedMismatch(GotExpected<u64>),
764    #[error("block parent hash mismatch: {_0}")]
765    ParentHashMismatch(GotExpected<B256>),
766    #[error("block hash mismatch: {_0}")]
767    BlockHashMismatch(GotExpected<B256>),
768    #[error("missing latest block in database")]
769    MissingLatestBlock,
770    #[error("parent block not found")]
771    MissingParentBlock,
772    #[error("block is too old, outside validation window")]
773    BlockTooOld,
774    #[error("could not verify proposer payment")]
775    ProposerPayment,
776    #[error("invalid blobs bundle")]
777    InvalidBlobsBundle,
778    #[error("invalid block access list: {_0}")]
779    InvalidBlockAccessList(alloy_rlp::Error),
780    #[error("block accesses blacklisted address: {_0}")]
781    Blacklist(Address),
782    #[error(transparent)]
783    Blob(#[from] BlobTransactionValidationError),
784    #[error(transparent)]
785    Consensus(#[from] ConsensusError),
786    #[error(transparent)]
787    Provider(#[from] ProviderError),
788    #[error(transparent)]
789    Execution(#[from] BlockExecutionError),
790    #[error(transparent)]
791    Payload(#[from] NewPayloadError),
792}
793
794impl From<ValidationApiError> for ErrorObject<'static> {
795    fn from(error: ValidationApiError) -> Self {
796        match error {
797            ValidationApiError::GasLimitMismatch(_) |
798            ValidationApiError::GasUsedMismatch(_) |
799            ValidationApiError::ParentHashMismatch(_) |
800            ValidationApiError::BlockHashMismatch(_) |
801            ValidationApiError::Blacklist(_) |
802            ValidationApiError::ProposerPayment |
803            ValidationApiError::InvalidBlobsBundle |
804            ValidationApiError::InvalidBlockAccessList(_) |
805            ValidationApiError::Blob(_) => invalid_params_rpc_err(error.to_string()),
806
807            ValidationApiError::Consensus(
808                error @ (ConsensusError::BlockAccessListCostMoreThanGasLimit(_) |
809                ConsensusError::BlockAccessListHashMismatch(_)),
810            ) => invalid_params_rpc_err(error.to_string()),
811            ValidationApiError::MissingLatestBlock |
812            ValidationApiError::MissingParentBlock |
813            ValidationApiError::BlockTooOld |
814            ValidationApiError::Consensus(_) |
815            ValidationApiError::Provider(_) => internal_rpc_err(error.to_string()),
816            ValidationApiError::Execution(err) => match err {
817                error @ BlockExecutionError::Validation(_) => {
818                    invalid_params_rpc_err(error.to_string())
819                }
820                error @ BlockExecutionError::Internal(_) => internal_rpc_err(error.to_string()),
821            },
822            ValidationApiError::Payload(err) => match err {
823                error @ NewPayloadError::Eth(_) => invalid_params_rpc_err(error.to_string()),
824                error @ NewPayloadError::Other(_) => internal_rpc_err(error.to_string()),
825            },
826        }
827    }
828}
829
830/// Metrics for the validation endpoint.
831#[derive(Metrics)]
832#[metrics(scope = "builder.validation")]
833pub(crate) struct ValidationMetrics {
834    /// The number of entries configured in the builder validation disallow list.
835    pub(crate) disallow_size: Gauge,
836}
837
838#[cfg(test)]
839mod tests {
840    use super::{
841        hash_disallow_list, validate_message_against_payload, AddressSet, ValidationApi,
842        ValidationApiConfig, ValidationApiError,
843    };
844    use alloy_consensus::{BlockHeader, Header};
845    use alloy_primitives::{Address, B256, U256};
846    use alloy_rpc_types_beacon::relay::BidTrace;
847    use alloy_rpc_types_engine::{ExecutionData, ExecutionPayload, ExecutionPayloadV1};
848    use reth_consensus::noop::NoopConsensus;
849    use reth_engine_primitives::PayloadValidator;
850    use reth_ethereum_engine_primitives::EthPayloadTypes;
851    use reth_ethereum_primitives::Block;
852    use reth_evm_ethereum::EthEvmConfig;
853    use reth_execution_types::BlockExecutionOutput;
854    use reth_node_api::NewPayloadError;
855    use reth_primitives_traits::{RecoveredBlock, SealedBlock, SealedHeader};
856    use reth_provider::test_utils::MockEthProvider;
857    use reth_revm::db::{states::bundle_state::BundleState, AccountStatus, BundleAccount};
858    use reth_tasks::Runtime;
859    use revm::state::AccountInfo;
860    use std::sync::Arc;
861
862    fn test_execution_payload() -> ExecutionPayload {
863        ExecutionPayload::V1(ExecutionPayloadV1 {
864            parent_hash: B256::repeat_byte(0x11),
865            fee_recipient: Address::ZERO,
866            state_root: B256::ZERO,
867            receipts_root: B256::ZERO,
868            logs_bloom: Default::default(),
869            prev_randao: B256::ZERO,
870            block_number: 1,
871            gas_limit: 30_000_000,
872            gas_used: 15_000_000,
873            timestamp: 1,
874            extra_data: Default::default(),
875            base_fee_per_gas: Default::default(),
876            block_hash: B256::repeat_byte(0x22),
877            transactions: Default::default(),
878        })
879    }
880
881    fn matching_bid_trace(payload: &ExecutionPayload) -> BidTrace {
882        let payload = payload.as_v1();
883        BidTrace {
884            parent_hash: payload.parent_hash,
885            block_hash: payload.block_hash,
886            gas_limit: payload.gas_limit,
887            gas_used: payload.gas_used,
888            ..Default::default()
889        }
890    }
891
892    #[test]
893    fn test_validate_message_against_payload_block_hash_mismatch() {
894        let payload = test_execution_payload();
895        let mut message = matching_bid_trace(&payload);
896        message.block_hash = B256::repeat_byte(0x33);
897
898        let err = validate_message_against_payload(&message, &payload).unwrap_err();
899        let ValidationApiError::BlockHashMismatch(mismatch) = err else {
900            panic!("unexpected error: {err}")
901        };
902        assert_eq!(mismatch.got, message.block_hash);
903        assert_eq!(mismatch.expected, payload.block_hash());
904    }
905
906    #[test]
907    fn test_validate_message_against_payload_parent_hash_mismatch() {
908        let payload = test_execution_payload();
909        let mut message = matching_bid_trace(&payload);
910        message.parent_hash = B256::repeat_byte(0x33);
911
912        let err = validate_message_against_payload(&message, &payload).unwrap_err();
913        let ValidationApiError::ParentHashMismatch(mismatch) = err else {
914            panic!("unexpected error: {err}")
915        };
916        assert_eq!(mismatch.got, message.parent_hash);
917        assert_eq!(mismatch.expected, payload.parent_hash());
918    }
919
920    #[test]
921    fn test_validate_message_against_payload_gas_limit_mismatch() {
922        let payload = test_execution_payload();
923        let mut message = matching_bid_trace(&payload);
924        message.gas_limit += 1;
925
926        let err = validate_message_against_payload(&message, &payload).unwrap_err();
927        let ValidationApiError::GasLimitMismatch(mismatch) = err else {
928            panic!("unexpected error: {err}")
929        };
930        assert_eq!(mismatch.got, message.gas_limit);
931        assert_eq!(mismatch.expected, payload.gas_limit());
932    }
933
934    #[test]
935    fn test_validate_message_against_payload_gas_used_mismatch() {
936        let payload = test_execution_payload();
937        let mut message = matching_bid_trace(&payload);
938        message.gas_used += 1;
939
940        let err = validate_message_against_payload(&message, &payload).unwrap_err();
941        let ValidationApiError::GasUsedMismatch(mismatch) = err else {
942            panic!("unexpected error: {err}")
943        };
944        assert_eq!(mismatch.got, message.gas_used);
945        assert_eq!(mismatch.expected, payload.as_v1().gas_used);
946    }
947
948    #[test]
949    fn test_hash_disallow_list_deterministic() {
950        let mut addresses = AddressSet::default();
951        addresses.insert(Address::from([1u8; 20]));
952        addresses.insert(Address::from([2u8; 20]));
953
954        let hash1 = hash_disallow_list(&addresses);
955        let hash2 = hash_disallow_list(&addresses);
956
957        assert_eq!(hash1, hash2);
958    }
959
960    #[test]
961    fn test_hash_disallow_list_different_content() {
962        let mut addresses1 = AddressSet::default();
963        addresses1.insert(Address::from([1u8; 20]));
964
965        let mut addresses2 = AddressSet::default();
966        addresses2.insert(Address::from([2u8; 20]));
967
968        let hash1 = hash_disallow_list(&addresses1);
969        let hash2 = hash_disallow_list(&addresses2);
970
971        assert_ne!(hash1, hash2);
972    }
973
974    #[test]
975    fn test_hash_disallow_list_order_independent() {
976        let mut addresses1 = AddressSet::default();
977        addresses1.insert(Address::from([1u8; 20]));
978        addresses1.insert(Address::from([2u8; 20]));
979
980        let mut addresses2 = AddressSet::default();
981        addresses2.insert(Address::from([2u8; 20])); // Different insertion order
982        addresses2.insert(Address::from([1u8; 20]));
983
984        let hash1 = hash_disallow_list(&addresses1);
985        let hash2 = hash_disallow_list(&addresses2);
986
987        assert_eq!(hash1, hash2);
988    }
989
990    #[test]
991    //ensures parity with rbuilder hashing https://github.com/flashbots/rbuilder/blob/962c8444cdd490a216beda22c7eec164db9fc3ac/crates/rbuilder/src/live_builder/block_list_provider.rs#L248
992    fn test_disallow_list_hash_rbuilder_parity() {
993        let json = r#"["0x05E0b5B40B7b66098C2161A5EE11C5740A3A7C45","0x01e2919679362dFBC9ee1644Ba9C6da6D6245BB1","0x03893a7c7463AE47D46bc7f091665f1893656003","0x04DBA1194ee10112fE6C3207C0687DEf0e78baCf"]"#;
994        let blocklist: Vec<Address> = serde_json::from_str(json).unwrap();
995        let blocklist: AddressSet = blocklist.into_iter().collect();
996        let expected_hash = "ee14e9d115e182f61871a5a385ab2f32ecf434f3b17bdbacc71044810d89e608";
997        let hash = hash_disallow_list(&blocklist);
998        assert_eq!(expected_hash, hash);
999    }
1000
1001    /// Only [`ValidationApi::validate_message_against_block`] is exercised below, which never
1002    /// converts a payload.
1003    #[derive(Debug)]
1004    struct UnusedPayloadValidator;
1005
1006    impl PayloadValidator<EthPayloadTypes> for UnusedPayloadValidator {
1007        type Block = Block;
1008
1009        fn convert_payload_to_block(
1010            &self,
1011            _payload: ExecutionData,
1012        ) -> Result<SealedBlock<Self::Block>, NewPayloadError> {
1013            unimplemented!()
1014        }
1015    }
1016
1017    #[derive(Debug)]
1018    struct SpecializedPayloadValidator;
1019
1020    impl PayloadValidator<EthPayloadTypes> for SpecializedPayloadValidator {
1021        type Block = Block;
1022
1023        fn convert_payload_to_block(
1024            &self,
1025            _payload: ExecutionData,
1026        ) -> Result<SealedBlock<Self::Block>, NewPayloadError> {
1027            panic!("the specialized recovery method must be used without a cache")
1028        }
1029
1030        fn ensure_well_formed_payload(
1031            &self,
1032            _payload: ExecutionData,
1033        ) -> Result<RecoveredBlock<Self::Block>, NewPayloadError> {
1034            Ok(SealedBlock::seal_slow(Block::default()).try_recover().unwrap())
1035        }
1036    }
1037
1038    #[test]
1039    fn recover_payload_without_cache_uses_validator_override() {
1040        let api = ValidationApi::new(
1041            MockEthProvider::default(),
1042            NoopConsensus::arc(),
1043            EthEvmConfig::mainnet(),
1044            ValidationApiConfig::default(),
1045            Runtime::test(),
1046            Arc::new(SpecializedPayloadValidator),
1047            None,
1048        );
1049
1050        api.recover_payload(ExecutionData {
1051            payload: test_execution_payload(),
1052            sidecar: Default::default(),
1053        })
1054        .unwrap();
1055    }
1056
1057    fn test_validation_api(
1058        provider: MockEthProvider,
1059    ) -> ValidationApi<MockEthProvider, EthEvmConfig, EthPayloadTypes> {
1060        ValidationApi::new(
1061            provider,
1062            NoopConsensus::arc(),
1063            EthEvmConfig::mainnet(),
1064            ValidationApiConfig::default(),
1065            Runtime::test(),
1066            Arc::new(UnusedPayloadValidator),
1067            None,
1068        )
1069    }
1070
1071    /// A submission whose payload pays the proposer nothing, like a trustless ePBS bid: there the
1072    /// payment settles from the builder's stake on the consensus layer.
1073    fn payment_free_submission() -> (MockEthProvider, RecoveredBlock<Block>, BidTrace) {
1074        let provider = MockEthProvider::default();
1075
1076        let parent = Header { gas_limit: 30_000_000, ..Default::default() };
1077        let parent = SealedHeader::seal_slow(parent);
1078        provider.add_block(
1079            parent.hash(),
1080            Block { header: parent.clone_header(), body: Default::default() },
1081        );
1082
1083        let header = Header {
1084            parent_hash: parent.hash(),
1085            number: parent.number() + 1,
1086            gas_limit: parent.gas_limit(),
1087            timestamp: parent.timestamp() + 12,
1088            ..Default::default()
1089        };
1090        let block = SealedBlock::seal_slow(Block { header, body: Default::default() })
1091            .try_recover()
1092            .unwrap();
1093        provider.state_roots.lock().push(block.state_root());
1094
1095        let message = BidTrace {
1096            parent_hash: block.parent_hash(),
1097            block_hash: block.hash(),
1098            gas_limit: block.gas_limit(),
1099            gas_used: block.gas_used(),
1100            proposer_fee_recipient: Address::repeat_byte(0x42),
1101            value: U256::from(1_000_000_000_000_000_000u64),
1102            ..Default::default()
1103        };
1104
1105        (provider, block, message)
1106    }
1107
1108    #[tokio::test]
1109    async fn test_payment_check_runs_for_a_nonzero_bid() {
1110        let (provider, block, message) = payment_free_submission();
1111        let registered_gas_limit = block.gas_limit();
1112
1113        let err = test_validation_api(provider)
1114            .validate_message_against_block(block, message, registered_gas_limit, None)
1115            .await
1116            .unwrap_err();
1117
1118        assert!(matches!(err, ValidationApiError::ProposerPayment), "unexpected error: {err}");
1119    }
1120
1121    #[tokio::test]
1122    async fn test_zero_value_bid_skips_the_payment_check() {
1123        let (provider, block, mut message) = payment_free_submission();
1124        let registered_gas_limit = block.gas_limit();
1125        message.value = U256::ZERO;
1126
1127        test_validation_api(provider)
1128            .validate_message_against_block(block, message, registered_gas_limit, None)
1129            .await
1130            .unwrap();
1131    }
1132
1133    /// A zero-value bid promises the proposer nothing, so there is nothing to verify. The
1134    /// balance-delta branch already lets one through whenever the fee recipient's balance does
1135    /// not fall -- but a fee recipient that merely sends a transaction of its own in the same
1136    /// block ends it poorer, drops through to the last-transaction fallback and is rejected.
1137    #[test]
1138    fn test_zero_value_bid_is_accepted_when_the_fee_recipient_spends() {
1139        let (provider, block, mut message) = payment_free_submission();
1140        message.value = U256::ZERO;
1141
1142        let output = BlockExecutionOutput {
1143            result: Default::default(),
1144            state: fee_recipient_spent(message.proposer_fee_recipient),
1145        };
1146
1147        test_validation_api(provider)
1148            .ensure_payment(block.sealed_block(), &output, &message)
1149            .unwrap();
1150    }
1151
1152    /// Execution state for a block in which `address` ended up poorer than it started.
1153    fn fee_recipient_spent(address: Address) -> BundleState {
1154        let balance =
1155            |wei: u64| Some(AccountInfo { balance: U256::from(wei), ..Default::default() });
1156
1157        let mut state = BundleState::default();
1158        state.state.insert(
1159            address,
1160            BundleAccount {
1161                original_info: balance(1_000),
1162                info: balance(999),
1163                storage: Default::default(),
1164                status: AccountStatus::Changed,
1165            },
1166        );
1167        state
1168    }
1169}