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 core::fmt;
18use jsonrpsee::core::RpcResult;
19use jsonrpsee_types::error::ErrorObject;
20use reth_chainspec::{ChainSpecProvider, EthereumHardforks};
21use reth_consensus::FullConsensus;
22use reth_consensus_common::validation::MAX_RLP_BLOCK_SIZE;
23use reth_engine_primitives::PayloadValidator;
24use reth_errors::{BlockExecutionError, ConsensusError, ProviderError};
25use reth_evm::{
26    cached::CachedReads, database::StateProviderDatabase, execute::Executor, ConfigureEvm,
27    SenderRecoveryCache,
28};
29use reth_execution_types::BlockExecutionOutput;
30use reth_metrics::{
31    metrics,
32    metrics::{gauge, Gauge},
33    Metrics,
34};
35use reth_payload_primitives::{NewPayloadError, PayloadTypes};
36use reth_primitives_traits::{
37    block::error::SealedBlockRecoveryError, BlockBody, GotExpected, NodePrimitives, RecoveredBlock,
38    SealedBlock, SealedHeaderFor,
39};
40use reth_rpc_api::BlockSubmissionValidationApiServer;
41use reth_rpc_server_types::result::{internal_rpc_err, invalid_params_rpc_err};
42use reth_storage_api::{
43    BlockReaderIdExt, HashedPostStateProvider, StateProvider, StateProviderFactory,
44};
45use reth_tasks::Runtime;
46use serde::{Deserialize, Serialize};
47use sha2::{Digest, Sha256};
48use std::sync::Arc;
49use tokio::sync::{oneshot, RwLock};
50use tracing::warn;
51
52mod blob_cache;
53
54/// The type that implements the `validation` rpc namespace trait
55#[derive(Clone, Debug, derive_more::Deref)]
56pub struct ValidationApi<Provider, E: ConfigureEvm, T: PayloadTypes> {
57    #[deref]
58    inner: Arc<ValidationApiInner<Provider, E, T>>,
59}
60
61impl<Provider, E, T> ValidationApi<Provider, E, T>
62where
63    E: ConfigureEvm,
64    T: PayloadTypes,
65{
66    /// Create a new instance of the [`ValidationApi`]
67    ///
68    /// If a `sender_recovery_cache` is given, the senders of submitted blocks are recovered
69    /// through it, reusing senders that other node components have already recovered.
70    pub fn new(
71        provider: Provider,
72        consensus: Arc<dyn FullConsensus<E::Primitives>>,
73        evm_config: E,
74        config: ValidationApiConfig,
75        task_spawner: Runtime,
76        payload_validator: Arc<
77            dyn PayloadValidator<T, Block = <E::Primitives as NodePrimitives>::Block>,
78        >,
79        sender_recovery_cache: Option<SenderRecoveryCache>,
80    ) -> Self {
81        let ValidationApiConfig { disallow, validation_window } = config;
82
83        let inner = Arc::new(ValidationApiInner {
84            provider,
85            consensus,
86            payload_validator,
87            evm_config,
88            disallow,
89            validation_window,
90            cached_state: Default::default(),
91            validated_blobs: Default::default(),
92            task_spawner,
93            sender_recovery_cache,
94            metrics: Default::default(),
95        });
96
97        inner.metrics.disallow_size.set(inner.disallow.len() as f64);
98
99        let disallow_hash = hash_disallow_list(&inner.disallow);
100        let hash_gauge = gauge!("builder_validation_disallow_hash", "hash" => disallow_hash);
101        hash_gauge.set(1.0);
102
103        Self { inner }
104    }
105
106    /// Returns the cached reads for the given head hash.
107    async fn cached_reads(&self, head: B256) -> CachedReads {
108        let cache = self.inner.cached_state.read().await;
109        if cache.0 == head {
110            cache.1.clone()
111        } else {
112            Default::default()
113        }
114    }
115
116    /// Updates the cached state for the given head hash.
117    async fn update_cached_reads(&self, head: B256, cached_state: CachedReads) {
118        let mut cache = self.inner.cached_state.write().await;
119        if cache.0 == head {
120            cache.1.extend(cached_state);
121        } else {
122            *cache = (head, cached_state)
123        }
124    }
125
126    /// Runs the validation on a blocking task.
127    ///
128    /// The validation is skipped if the request is dropped before it starts and stopped if the
129    /// request is dropped while the validation waits for the cached reads.
130    async fn spawn_validation<F>(&self, validation: F) -> RpcResult<()>
131    where
132        F: Future<Output = Result<(), ValidationApiError>> + Send + 'static,
133    {
134        let (mut tx, rx) = oneshot::channel();
135
136        self.task_spawner.spawn_blocking_task(async move {
137            let result = tokio::select! {
138                biased;
139                _ = tx.closed() => return,
140                result = validation => result.map_err(ErrorObject::from),
141            };
142            let _ = tx.send(result);
143        });
144
145        rx.await.map_err(|_| internal_rpc_err("Internal blocking task error"))?
146    }
147}
148
149impl<Provider, E, T> ValidationApi<Provider, E, T>
150where
151    Provider: BlockReaderIdExt<Header = <E::Primitives as NodePrimitives>::BlockHeader>
152        + ChainSpecProvider<ChainSpec: EthereumHardforks>
153        + StateProviderFactory
154        + 'static,
155    E: ConfigureEvm + 'static,
156    T: PayloadTypes<ExecutionData = ExecutionData>,
157{
158    /// Validates the given block and a [`BidTrace`] against it.
159    pub async fn validate_message_against_block(
160        &self,
161        block: RecoveredBlock<<E::Primitives as NodePrimitives>::Block>,
162        message: BidTrace,
163        registered_gas_limit: u64,
164        decoded_bal: Option<DecodedBal>,
165    ) -> Result<(), ValidationApiError> {
166        self.validate_message_against_header(block.sealed_header(), &message)?;
167
168        self.consensus.validate_header(block.sealed_header())?;
169        self.consensus.validate_block_pre_execution(block.sealed_block())?;
170
171        if !self.disallow.is_empty() {
172            if self.disallow.contains(&block.beneficiary()) {
173                return Err(ValidationApiError::Blacklist(block.beneficiary()))
174            }
175            if self.disallow.contains(&message.proposer_fee_recipient) {
176                return Err(ValidationApiError::Blacklist(message.proposer_fee_recipient))
177            }
178            for (sender, tx) in block.senders_iter().zip(block.body().transactions()) {
179                if self.disallow.contains(sender) {
180                    return Err(ValidationApiError::Blacklist(*sender))
181                }
182                if let Some(to) = tx.to() &&
183                    self.disallow.contains(&to)
184                {
185                    return Err(ValidationApiError::Blacklist(to))
186                }
187            }
188        }
189
190        let latest_header =
191            self.provider.latest_header()?.ok_or_else(|| ValidationApiError::MissingLatestBlock)?;
192
193        let parent_header = if block.parent_hash() == latest_header.hash() {
194            latest_header
195        } else {
196            // parent is not the latest header so we need to fetch it and ensure it's not too old
197            let parent_header = self
198                .provider
199                .sealed_header_by_hash(block.parent_hash())?
200                .ok_or_else(|| ValidationApiError::MissingParentBlock)?;
201
202            if latest_header.number().saturating_sub(parent_header.number()) >
203                self.validation_window
204            {
205                return Err(ValidationApiError::BlockTooOld)
206            }
207            parent_header
208        };
209
210        self.consensus.validate_header_against_parent(block.sealed_header(), &parent_header)?;
211        parent_header.validate_gas_limit(registered_gas_limit, block.gas_limit()).map_err(
212            |err| {
213                ValidationApiError::GasLimitMismatch(GotExpected {
214                    got: err.got,
215                    expected: err.expected,
216                })
217            },
218        )?;
219
220        // Ensure the submitted block access list does not exceed the block gas limit (EIP-7928)
221        if let Some(decoded_bal) = decoded_bal {
222            decoded_bal
223                .as_bal()
224                .validate_gas_limit(block.gas_limit())
225                .map_err(ConsensusError::from)?;
226        }
227
228        let parent_header_hash = parent_header.hash();
229        let state_provider = self.provider.state_by_block_hash(parent_header_hash)?;
230
231        let mut request_cache = self.cached_reads(parent_header_hash).await;
232
233        let (output, block_access_list_hash) = {
234            let cached_db = request_cache
235                .as_db_mut(StateProviderDatabase::new((&state_provider).into_evm_state_provider()));
236            let mut executor = self.evm_config.batch_executor(cached_db);
237
238            let result = executor.execute_one(&block)?;
239
240            // The executor rebuilds the block access list whenever the block header contains a
241            // BAL hash. Comparing the rebuilt hash against the header post execution also
242            // commits to the submitted access list, because the header's BAL hash is derived
243            // from the submitted bytes.
244            let block_access_list_hash =
245                executor.take_bal().map(|bal| compute_block_access_list_hash(&bal));
246
247            let state = executor.into_state();
248            if !self.disallow.is_empty() {
249                // Check whether the submission interacted with any blacklisted account by
250                // scanning the cached reads and the accounts changed during execution.
251                for account in request_cache.accounts.keys().chain(state.state.keys()) {
252                    if self.disallow.contains(account) {
253                        return Err(ValidationApiError::Blacklist(*account))
254                    }
255                }
256            }
257
258            (BlockExecutionOutput { state, 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.account_state(&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
585impl<Provider, E, T> BlockSubmissionValidationApiServer for ValidationApi<Provider, E, T>
586where
587    Provider: BlockReaderIdExt<Header = <E::Primitives as NodePrimitives>::BlockHeader>
588        + ChainSpecProvider<ChainSpec: EthereumHardforks>
589        + StateProviderFactory
590        + Clone
591        + 'static,
592    E: ConfigureEvm + 'static,
593    T: PayloadTypes<ExecutionData = ExecutionData>,
594{
595    async fn validate_builder_submission_v1(
596        &self,
597        _request: BuilderBlockValidationRequest,
598    ) -> RpcResult<()> {
599        warn!(target: "rpc::flashbots", "Method `flashbots_validateBuilderSubmissionV1` is not supported");
600        Err(internal_rpc_err("unimplemented"))
601    }
602
603    async fn validate_builder_submission_v2(
604        &self,
605        _request: BuilderBlockValidationRequestV2,
606    ) -> RpcResult<()> {
607        warn!(target: "rpc::flashbots", "Method `flashbots_validateBuilderSubmissionV2` is not supported");
608        Err(internal_rpc_err("unimplemented"))
609    }
610
611    /// Validates a block submitted to the relay
612    async fn validate_builder_submission_v3(
613        &self,
614        request: BuilderBlockValidationRequestV3,
615    ) -> RpcResult<()> {
616        let this = self.clone();
617        let validation = async move { Self::validate_builder_submission_v3(&this, request).await };
618        self.spawn_validation(validation).await
619    }
620
621    /// Validates a block submitted to the relay
622    async fn validate_builder_submission_v4(
623        &self,
624        request: BuilderBlockValidationRequestV4,
625    ) -> RpcResult<()> {
626        let this = self.clone();
627        let validation = async move { Self::validate_builder_submission_v4(&this, request).await };
628        self.spawn_validation(validation).await
629    }
630
631    /// Validates a block submitted to the relay
632    async fn validate_builder_submission_v5(
633        &self,
634        request: BuilderBlockValidationRequestV5,
635    ) -> RpcResult<()> {
636        let this = self.clone();
637        let validation = async move { Self::validate_builder_submission_v5(&this, request).await };
638        self.spawn_validation(validation).await
639    }
640
641    /// Validates a block submitted to the relay
642    async fn validate_builder_submission_v6(
643        &self,
644        request: BuilderBlockValidationRequestV6,
645    ) -> RpcResult<()> {
646        let this = self.clone();
647        let validation = async move { Self::validate_builder_submission_v6(&this, request).await };
648        self.spawn_validation(validation).await
649    }
650}
651
652pub struct ValidationApiInner<Provider, E: ConfigureEvm, T: PayloadTypes> {
653    /// The provider that can interact with the chain.
654    provider: Provider,
655    /// Consensus implementation.
656    consensus: Arc<dyn FullConsensus<E::Primitives>>,
657    /// Execution payload validator.
658    payload_validator:
659        Arc<dyn PayloadValidator<T, Block = <E::Primitives as NodePrimitives>::Block>>,
660    /// Block executor factory.
661    evm_config: E,
662    /// Set of disallowed addresses
663    disallow: AddressSet,
664    /// The maximum block distance - parent to latest - allowed for validation
665    validation_window: u64,
666    /// Cached state reads to avoid redundant disk I/O across multiple validation attempts
667    /// targeting the same state. Stores a tuple of (`block_hash`, `cached_reads`) for the
668    /// latest head block state. Uses async `RwLock` to safely handle concurrent validation
669    /// requests.
670    cached_state: RwLock<(B256, CachedReads)>,
671    /// Recently validated blob, commitment, and cell-proof tuples shared by V2 submissions.
672    validated_blobs: BlobValidationCache,
673    /// Task spawner for blocking operations
674    task_spawner: Runtime,
675    /// Cache of recovered transaction senders shared with transaction ingress and payload
676    /// execution, if enabled.
677    sender_recovery_cache: Option<SenderRecoveryCache>,
678    /// Validation metrics
679    metrics: ValidationMetrics,
680}
681
682/// Ensures that the raw execution payload fields match the corresponding [`BidTrace`] fields.
683fn validate_message_against_payload(
684    message: &BidTrace,
685    payload: &ExecutionPayload,
686) -> Result<(), ValidationApiError> {
687    let payload = payload.as_v1();
688
689    if payload.block_hash != message.block_hash {
690        Err(ValidationApiError::BlockHashMismatch(GotExpected {
691            got: message.block_hash,
692            expected: payload.block_hash,
693        }))
694    } else if payload.parent_hash != message.parent_hash {
695        Err(ValidationApiError::ParentHashMismatch(GotExpected {
696            got: message.parent_hash,
697            expected: payload.parent_hash,
698        }))
699    } else if payload.gas_limit != message.gas_limit {
700        Err(ValidationApiError::GasLimitMismatch(GotExpected {
701            got: message.gas_limit,
702            expected: payload.gas_limit,
703        }))
704    } else if payload.gas_used != message.gas_used {
705        Err(ValidationApiError::GasUsedMismatch(GotExpected {
706            got: message.gas_used,
707            expected: payload.gas_used,
708        }))
709    } else {
710        Ok(())
711    }
712}
713
714/// Calculates a deterministic hash of the blocklist for change detection.
715///
716/// This function sorts addresses to ensure deterministic output regardless of
717/// insertion order, then computes a SHA256 hash of the concatenated addresses.
718fn hash_disallow_list(disallow: &AddressSet) -> String {
719    let mut sorted: Vec<_> = disallow.iter().collect();
720    sorted.sort_unstable(); // sort for deterministic hashing
721
722    let mut hasher = Sha256::new();
723    for addr in sorted {
724        hasher.update(addr.as_slice());
725    }
726
727    format!("{:x}", hasher.finalize())
728}
729
730impl<Provider, E: ConfigureEvm, T: PayloadTypes> fmt::Debug for ValidationApiInner<Provider, E, T> {
731    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
732        f.debug_struct("ValidationApiInner").finish_non_exhaustive()
733    }
734}
735
736/// Configuration for validation API.
737#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
738pub struct ValidationApiConfig {
739    /// Disallowed addresses.
740    pub disallow: AddressSet,
741    /// The maximum block distance - parent to latest - allowed for validation
742    pub validation_window: u64,
743}
744
745impl ValidationApiConfig {
746    /// Default validation blocks window of 3 blocks
747    pub const DEFAULT_VALIDATION_WINDOW: u64 = 3;
748}
749
750impl Default for ValidationApiConfig {
751    fn default() -> Self {
752        Self { disallow: Default::default(), validation_window: Self::DEFAULT_VALIDATION_WINDOW }
753    }
754}
755
756/// Errors thrown by the validation API.
757#[derive(Debug, thiserror::Error)]
758pub enum ValidationApiError {
759    #[error("block gas limit mismatch: {_0}")]
760    GasLimitMismatch(GotExpected<u64>),
761    #[error("block gas used mismatch: {_0}")]
762    GasUsedMismatch(GotExpected<u64>),
763    #[error("block parent hash mismatch: {_0}")]
764    ParentHashMismatch(GotExpected<B256>),
765    #[error("block hash mismatch: {_0}")]
766    BlockHashMismatch(GotExpected<B256>),
767    #[error("missing latest block in database")]
768    MissingLatestBlock,
769    #[error("parent block not found")]
770    MissingParentBlock,
771    #[error("block is too old, outside validation window")]
772    BlockTooOld,
773    #[error("could not verify proposer payment")]
774    ProposerPayment,
775    #[error("invalid blobs bundle")]
776    InvalidBlobsBundle,
777    #[error("invalid block access list: {_0}")]
778    InvalidBlockAccessList(alloy_rlp::Error),
779    #[error("block accesses blacklisted address: {_0}")]
780    Blacklist(Address),
781    #[error(transparent)]
782    Blob(#[from] BlobTransactionValidationError),
783    #[error(transparent)]
784    Consensus(#[from] ConsensusError),
785    #[error(transparent)]
786    Provider(#[from] ProviderError),
787    #[error(transparent)]
788    Execution(#[from] BlockExecutionError),
789    #[error(transparent)]
790    Payload(#[from] NewPayloadError),
791}
792
793impl From<ValidationApiError> for ErrorObject<'static> {
794    fn from(error: ValidationApiError) -> Self {
795        match error {
796            ValidationApiError::GasLimitMismatch(_) |
797            ValidationApiError::GasUsedMismatch(_) |
798            ValidationApiError::ParentHashMismatch(_) |
799            ValidationApiError::BlockHashMismatch(_) |
800            ValidationApiError::Blacklist(_) |
801            ValidationApiError::ProposerPayment |
802            ValidationApiError::InvalidBlobsBundle |
803            ValidationApiError::InvalidBlockAccessList(_) |
804            ValidationApiError::Blob(_) |
805            ValidationApiError::Consensus(_) => invalid_params_rpc_err(error.to_string()),
806
807            ValidationApiError::MissingLatestBlock |
808            ValidationApiError::MissingParentBlock |
809            ValidationApiError::BlockTooOld |
810            ValidationApiError::Provider(_) => internal_rpc_err(error.to_string()),
811            ValidationApiError::Execution(err) => match err {
812                error @ BlockExecutionError::Validation(_) => {
813                    invalid_params_rpc_err(error.to_string())
814                }
815                error @ BlockExecutionError::Internal(_) => internal_rpc_err(error.to_string()),
816            },
817            ValidationApiError::Payload(err) => match err {
818                error @ NewPayloadError::Eth(_) => invalid_params_rpc_err(error.to_string()),
819                error @ NewPayloadError::Other(_) => internal_rpc_err(error.to_string()),
820            },
821        }
822    }
823}
824
825/// Metrics for the validation endpoint.
826#[derive(Metrics)]
827#[metrics(scope = "builder.validation")]
828pub(crate) struct ValidationMetrics {
829    /// The number of entries configured in the builder validation disallow list.
830    pub(crate) disallow_size: Gauge,
831}
832
833#[cfg(test)]
834mod tests {
835    use super::{
836        hash_disallow_list, validate_message_against_payload, AddressSet, ValidationApi,
837        ValidationApiConfig, ValidationApiError,
838    };
839    use alloy_consensus::{BlockHeader, Header};
840    use alloy_primitives::{Address, B256, U256};
841    use alloy_rpc_types_beacon::relay::BidTrace;
842    use alloy_rpc_types_engine::{ExecutionData, ExecutionPayload, ExecutionPayloadV1};
843    use evm2::evm::AccountInfo;
844    use reth_consensus::noop::NoopConsensus;
845    use reth_engine_primitives::PayloadValidator;
846    use reth_errors::ConsensusError;
847    use reth_ethereum_engine_primitives::EthPayloadTypes;
848    use reth_ethereum_primitives::Block;
849    use reth_evm_ethereum::EthEvmConfig;
850    use reth_execution_types::BlockExecutionOutput;
851    use reth_payload_primitives::NewPayloadError;
852    use reth_primitives_traits::{RecoveredBlock, SealedBlock, SealedHeader};
853    use reth_provider::test_utils::MockEthProvider;
854    use reth_tasks::Runtime;
855    use reth_trie_common::bundle_state::{AccountStatus, BundleAccount, BundleState};
856    use std::sync::Arc;
857
858    fn test_execution_payload() -> ExecutionPayload {
859        ExecutionPayload::V1(ExecutionPayloadV1 {
860            parent_hash: B256::repeat_byte(0x11),
861            fee_recipient: Address::ZERO,
862            state_root: B256::ZERO,
863            receipts_root: B256::ZERO,
864            logs_bloom: Default::default(),
865            prev_randao: B256::ZERO,
866            block_number: 1,
867            gas_limit: 30_000_000,
868            gas_used: 15_000_000,
869            timestamp: 1,
870            extra_data: Default::default(),
871            base_fee_per_gas: Default::default(),
872            block_hash: B256::repeat_byte(0x22),
873            transactions: Default::default(),
874        })
875    }
876
877    fn matching_bid_trace(payload: &ExecutionPayload) -> BidTrace {
878        let payload = payload.as_v1();
879        BidTrace {
880            parent_hash: payload.parent_hash,
881            block_hash: payload.block_hash,
882            gas_limit: payload.gas_limit,
883            gas_used: payload.gas_used,
884            ..Default::default()
885        }
886    }
887
888    #[test]
889    fn test_validate_message_against_payload_block_hash_mismatch() {
890        let payload = test_execution_payload();
891        let mut message = matching_bid_trace(&payload);
892        message.block_hash = B256::repeat_byte(0x33);
893
894        let err = validate_message_against_payload(&message, &payload).unwrap_err();
895        let ValidationApiError::BlockHashMismatch(mismatch) = err else {
896            panic!("unexpected error: {err}")
897        };
898        assert_eq!(mismatch.got, message.block_hash);
899        assert_eq!(mismatch.expected, payload.block_hash());
900    }
901
902    #[test]
903    fn test_validate_message_against_payload_parent_hash_mismatch() {
904        let payload = test_execution_payload();
905        let mut message = matching_bid_trace(&payload);
906        message.parent_hash = B256::repeat_byte(0x33);
907
908        let err = validate_message_against_payload(&message, &payload).unwrap_err();
909        let ValidationApiError::ParentHashMismatch(mismatch) = err else {
910            panic!("unexpected error: {err}")
911        };
912        assert_eq!(mismatch.got, message.parent_hash);
913        assert_eq!(mismatch.expected, payload.parent_hash());
914    }
915
916    #[test]
917    fn test_validate_message_against_payload_gas_limit_mismatch() {
918        let payload = test_execution_payload();
919        let mut message = matching_bid_trace(&payload);
920        message.gas_limit += 1;
921
922        let err = validate_message_against_payload(&message, &payload).unwrap_err();
923        let ValidationApiError::GasLimitMismatch(mismatch) = err else {
924            panic!("unexpected error: {err}")
925        };
926        assert_eq!(mismatch.got, message.gas_limit);
927        assert_eq!(mismatch.expected, payload.gas_limit());
928    }
929
930    #[test]
931    fn test_validate_message_against_payload_gas_used_mismatch() {
932        let payload = test_execution_payload();
933        let mut message = matching_bid_trace(&payload);
934        message.gas_used += 1;
935
936        let err = validate_message_against_payload(&message, &payload).unwrap_err();
937        let ValidationApiError::GasUsedMismatch(mismatch) = err else {
938            panic!("unexpected error: {err}")
939        };
940        assert_eq!(mismatch.got, message.gas_used);
941        assert_eq!(mismatch.expected, payload.as_v1().gas_used);
942    }
943
944    #[test]
945    fn test_hash_disallow_list_deterministic() {
946        let mut addresses = AddressSet::default();
947        addresses.insert(Address::repeat_byte(1u8));
948        addresses.insert(Address::repeat_byte(2u8));
949
950        let hash1 = hash_disallow_list(&addresses);
951        let hash2 = hash_disallow_list(&addresses);
952
953        assert_eq!(hash1, hash2);
954    }
955
956    #[test]
957    fn test_hash_disallow_list_different_content() {
958        let mut addresses1 = AddressSet::default();
959        addresses1.insert(Address::repeat_byte(1u8));
960
961        let mut addresses2 = AddressSet::default();
962        addresses2.insert(Address::repeat_byte(2u8));
963
964        let hash1 = hash_disallow_list(&addresses1);
965        let hash2 = hash_disallow_list(&addresses2);
966
967        assert_ne!(hash1, hash2);
968    }
969
970    #[test]
971    fn test_hash_disallow_list_order_independent() {
972        let mut addresses1 = AddressSet::default();
973        addresses1.insert(Address::repeat_byte(1u8));
974        addresses1.insert(Address::repeat_byte(2u8));
975
976        let mut addresses2 = AddressSet::default();
977        addresses2.insert(Address::repeat_byte(2u8)); // Different insertion order
978        addresses2.insert(Address::repeat_byte(1u8));
979
980        let hash1 = hash_disallow_list(&addresses1);
981        let hash2 = hash_disallow_list(&addresses2);
982
983        assert_eq!(hash1, hash2);
984    }
985
986    #[test]
987    //ensures parity with rbuilder hashing https://github.com/flashbots/rbuilder/blob/962c8444cdd490a216beda22c7eec164db9fc3ac/crates/rbuilder/src/live_builder/block_list_provider.rs#L248
988    fn test_disallow_list_hash_rbuilder_parity() {
989        let json = r#"["0x05E0b5B40B7b66098C2161A5EE11C5740A3A7C45","0x01e2919679362dFBC9ee1644Ba9C6da6D6245BB1","0x03893a7c7463AE47D46bc7f091665f1893656003","0x04DBA1194ee10112fE6C3207C0687DEf0e78baCf"]"#;
990        let blocklist: Vec<Address> = serde_json::from_str(json).unwrap();
991        let blocklist: AddressSet = blocklist.into_iter().collect();
992        let expected_hash = "ee14e9d115e182f61871a5a385ab2f32ecf434f3b17bdbacc71044810d89e608";
993        let hash = hash_disallow_list(&blocklist);
994        assert_eq!(expected_hash, hash);
995    }
996
997    /// Only [`ValidationApi::validate_message_against_block`] is exercised below, which never
998    /// converts a payload.
999    #[derive(Debug)]
1000    struct UnusedPayloadValidator;
1001
1002    impl PayloadValidator<EthPayloadTypes> for UnusedPayloadValidator {
1003        type Block = Block;
1004
1005        fn convert_payload_to_block(
1006            &self,
1007            _payload: ExecutionData,
1008        ) -> Result<SealedBlock<Self::Block>, NewPayloadError> {
1009            unimplemented!()
1010        }
1011    }
1012
1013    #[derive(Debug)]
1014    struct SpecializedPayloadValidator;
1015
1016    impl PayloadValidator<EthPayloadTypes> for SpecializedPayloadValidator {
1017        type Block = Block;
1018
1019        fn convert_payload_to_block(
1020            &self,
1021            _payload: ExecutionData,
1022        ) -> Result<SealedBlock<Self::Block>, NewPayloadError> {
1023            panic!("the specialized recovery method must be used without a cache")
1024        }
1025
1026        fn ensure_well_formed_payload(
1027            &self,
1028            _payload: ExecutionData,
1029        ) -> Result<RecoveredBlock<Self::Block>, NewPayloadError> {
1030            Ok(SealedBlock::seal_slow(Block::default()).try_recover().unwrap())
1031        }
1032    }
1033
1034    #[test]
1035    fn recover_payload_without_cache_uses_validator_override() {
1036        let api = ValidationApi::new(
1037            MockEthProvider::default(),
1038            NoopConsensus::arc(),
1039            EthEvmConfig::mainnet(),
1040            ValidationApiConfig::default(),
1041            Runtime::test(),
1042            Arc::new(SpecializedPayloadValidator),
1043            None,
1044        );
1045
1046        api.recover_payload(ExecutionData {
1047            payload: test_execution_payload(),
1048            sidecar: Default::default(),
1049        })
1050        .unwrap();
1051    }
1052
1053    fn test_validation_api(
1054        provider: MockEthProvider,
1055    ) -> ValidationApi<MockEthProvider, EthEvmConfig, EthPayloadTypes> {
1056        ValidationApi::new(
1057            provider,
1058            NoopConsensus::arc(),
1059            EthEvmConfig::mainnet(),
1060            ValidationApiConfig::default(),
1061            Runtime::test(),
1062            Arc::new(UnusedPayloadValidator),
1063            None,
1064        )
1065    }
1066
1067    /// A submission whose payload pays the proposer nothing, like a trustless ePBS bid: there the
1068    /// payment settles from the builder's stake on the consensus layer.
1069    fn payment_free_submission() -> (MockEthProvider, RecoveredBlock<Block>, BidTrace) {
1070        let provider = MockEthProvider::default();
1071
1072        let parent = Header { gas_limit: 30_000_000, ..Default::default() };
1073        let parent = SealedHeader::seal_slow(parent);
1074        provider.add_block(
1075            parent.hash(),
1076            Block { header: parent.clone_header(), body: Default::default() },
1077        );
1078
1079        let header = Header {
1080            parent_hash: parent.hash(),
1081            number: parent.number() + 1,
1082            gas_limit: parent.gas_limit(),
1083            timestamp: parent.timestamp() + 12,
1084            ..Default::default()
1085        };
1086        let block = SealedBlock::seal_slow(Block { header, body: Default::default() })
1087            .try_recover()
1088            .unwrap();
1089        provider.state_roots.lock().push(block.state_root());
1090
1091        let message = BidTrace {
1092            parent_hash: block.parent_hash(),
1093            block_hash: block.hash(),
1094            gas_limit: block.gas_limit(),
1095            gas_used: block.gas_used(),
1096            proposer_fee_recipient: Address::repeat_byte(0x42),
1097            value: U256::from(1_000_000_000_000_000_000u64),
1098            ..Default::default()
1099        };
1100
1101        (provider, block, message)
1102    }
1103
1104    #[tokio::test]
1105    async fn execution_invalid_submission_is_invalid_params() {
1106        let (provider, parent_block, mut message) = payment_free_submission();
1107        let transaction = reth_ethereum_primitives::TransactionSigned::new_unhashed(
1108            reth_ethereum_primitives::Transaction::Legacy(alloy_consensus::TxLegacy {
1109                gas_limit: 20_999,
1110                to: alloy_primitives::TxKind::Call(Address::with_last_byte(1)),
1111                ..Default::default()
1112            }),
1113            alloy_primitives::Signature::test_signature(),
1114        );
1115        let block = RecoveredBlock::new_unhashed(
1116            Block {
1117                header: parent_block.clone_header(),
1118                body: alloy_consensus::BlockBody {
1119                    transactions: vec![transaction],
1120                    ..Default::default()
1121                },
1122            },
1123            vec![Address::ZERO],
1124        );
1125        message.block_hash = block.hash();
1126        let error = test_validation_api(provider)
1127            .validate_message_against_block(block, message, parent_block.gas_limit(), None)
1128            .await
1129            .unwrap_err();
1130        assert!(
1131            matches!(
1132                error,
1133                ValidationApiError::Execution(reth_errors::BlockExecutionError::Validation(_))
1134            ),
1135            "{error}"
1136        );
1137        let error = jsonrpsee::types::ErrorObjectOwned::from(error);
1138        assert_eq!(error.code(), jsonrpsee::types::error::INVALID_PARAMS_CODE);
1139    }
1140
1141    #[tokio::test]
1142    async fn consensus_invalid_submission_is_invalid_params() {
1143        let (provider, block, mut message) = payment_free_submission();
1144        // The mock provider hands out the recomputed state root from this list, so the block's
1145        // header no longer matches it.
1146        *provider.state_roots.lock() = vec![B256::repeat_byte(0xab)];
1147        // A zero-value bid skips the payment check, leaving the state root comparison as the
1148        // first failing check.
1149        message.value = U256::ZERO;
1150        let registered_gas_limit = block.gas_limit();
1151
1152        let error = test_validation_api(provider)
1153            .validate_message_against_block(block, message, registered_gas_limit, None)
1154            .await
1155            .unwrap_err();
1156        assert!(
1157            matches!(error, ValidationApiError::Consensus(ConsensusError::BodyStateRootDiff(_))),
1158            "{error}"
1159        );
1160        let error = jsonrpsee::types::ErrorObjectOwned::from(error);
1161        assert_eq!(error.code(), jsonrpsee::types::error::INVALID_PARAMS_CODE);
1162    }
1163
1164    #[test]
1165    fn node_fault_is_internal_error() {
1166        let error =
1167            jsonrpsee::types::ErrorObjectOwned::from(ValidationApiError::MissingParentBlock);
1168        assert_eq!(error.code(), jsonrpsee::types::error::INTERNAL_ERROR_CODE);
1169    }
1170
1171    #[tokio::test]
1172    async fn test_payment_check_runs_for_a_nonzero_bid() {
1173        let (provider, block, message) = payment_free_submission();
1174        let registered_gas_limit = block.gas_limit();
1175
1176        let err = test_validation_api(provider)
1177            .validate_message_against_block(block, message, registered_gas_limit, None)
1178            .await
1179            .unwrap_err();
1180
1181        assert!(matches!(err, ValidationApiError::ProposerPayment), "unexpected error: {err}");
1182    }
1183
1184    #[tokio::test]
1185    async fn test_zero_value_bid_skips_the_payment_check() {
1186        let (provider, block, mut message) = payment_free_submission();
1187        let registered_gas_limit = block.gas_limit();
1188        message.value = U256::ZERO;
1189
1190        test_validation_api(provider)
1191            .validate_message_against_block(block, message, registered_gas_limit, None)
1192            .await
1193            .unwrap();
1194    }
1195
1196    /// A zero-value bid promises the proposer nothing, so there is nothing to verify. The
1197    /// balance-delta branch already lets one through whenever the fee recipient's balance does
1198    /// not fall -- but a fee recipient that merely sends a transaction of its own in the same
1199    /// block ends it poorer, drops through to the last-transaction fallback and is rejected.
1200    #[test]
1201    fn test_zero_value_bid_is_accepted_when_the_fee_recipient_spends() {
1202        let (provider, block, mut message) = payment_free_submission();
1203        message.value = U256::ZERO;
1204
1205        let output = BlockExecutionOutput {
1206            result: Default::default(),
1207            state: fee_recipient_spent(message.proposer_fee_recipient),
1208        };
1209
1210        test_validation_api(provider)
1211            .ensure_payment(block.sealed_block(), &output, &message)
1212            .unwrap();
1213    }
1214
1215    /// Execution state for a block in which `address` ended up poorer than it started.
1216    fn fee_recipient_spent(address: Address) -> BundleState {
1217        let balance =
1218            |wei: u64| Some(AccountInfo { balance: U256::from(wei), ..Default::default() });
1219
1220        let mut state = BundleState::default();
1221        state.state.insert(
1222            address,
1223            BundleAccount {
1224                original_info: balance(1_000),
1225                info: balance(999),
1226                storage: Default::default(),
1227                status: AccountStatus::Changed,
1228            },
1229        );
1230        state
1231    }
1232}