reth_rpc/
validation.rs

1use alloy_consensus::{
2    BlobTransactionValidationError, BlockHeader, EnvKzgSettings, Transaction, TxReceipt,
3};
4use alloy_eips::{eip4844::kzg_to_versioned_hash, eip7685::RequestsOrHash};
5use alloy_rpc_types_beacon::relay::{
6    BidTrace, BuilderBlockValidationRequest, BuilderBlockValidationRequestV2,
7    BuilderBlockValidationRequestV3, BuilderBlockValidationRequestV4,
8};
9use alloy_rpc_types_engine::{
10    BlobsBundleV1, CancunPayloadFields, ExecutionData, ExecutionPayload, ExecutionPayloadSidecar,
11    PraguePayloadFields,
12};
13use async_trait::async_trait;
14use core::fmt;
15use jsonrpsee::core::RpcResult;
16use reth_chainspec::{ChainSpecProvider, EthereumHardforks};
17use reth_consensus::{Consensus, FullConsensus};
18use reth_engine_primitives::PayloadValidator;
19use reth_errors::{BlockExecutionError, ConsensusError, ProviderError};
20use reth_evm::execute::{BlockExecutorProvider, Executor};
21use reth_execution_types::BlockExecutionOutput;
22use reth_metrics::{metrics, metrics::Gauge, Metrics};
23use reth_node_api::NewPayloadError;
24use reth_primitives_traits::{
25    constants::GAS_LIMIT_BOUND_DIVISOR, BlockBody, GotExpected, NodePrimitives, RecoveredBlock,
26    SealedBlock, SealedHeaderFor,
27};
28use reth_revm::{cached::CachedReads, database::StateProviderDatabase};
29use reth_rpc_api::BlockSubmissionValidationApiServer;
30use reth_rpc_server_types::result::internal_rpc_err;
31use reth_storage_api::{BlockReaderIdExt, StateProviderFactory};
32use reth_tasks::TaskSpawner;
33use revm_primitives::{Address, B256, U256};
34use serde::{Deserialize, Serialize};
35use std::{collections::HashSet, sync::Arc};
36use tokio::sync::{oneshot, RwLock};
37use tracing::warn;
38
39/// The type that implements the `validation` rpc namespace trait
40#[derive(Clone, Debug, derive_more::Deref)]
41pub struct ValidationApi<Provider, E: BlockExecutorProvider> {
42    #[deref]
43    inner: Arc<ValidationApiInner<Provider, E>>,
44}
45
46impl<Provider, E> ValidationApi<Provider, E>
47where
48    E: BlockExecutorProvider,
49{
50    /// Create a new instance of the [`ValidationApi`]
51    pub fn new(
52        provider: Provider,
53        consensus: Arc<dyn FullConsensus<E::Primitives, Error = ConsensusError>>,
54        executor_provider: E,
55        config: ValidationApiConfig,
56        task_spawner: Box<dyn TaskSpawner>,
57        payload_validator: Arc<
58            dyn PayloadValidator<
59                Block = <E::Primitives as NodePrimitives>::Block,
60                ExecutionData = ExecutionData,
61            >,
62        >,
63    ) -> Self {
64        let ValidationApiConfig { disallow, validation_window } = config;
65
66        let inner = Arc::new(ValidationApiInner {
67            provider,
68            consensus,
69            payload_validator,
70            executor_provider,
71            disallow,
72            validation_window,
73            cached_state: Default::default(),
74            task_spawner,
75            metrics: Default::default(),
76        });
77
78        inner.metrics.disallow_size.set(inner.disallow.len() as f64);
79        Self { inner }
80    }
81
82    /// Returns the cached reads for the given head hash.
83    async fn cached_reads(&self, head: B256) -> CachedReads {
84        let cache = self.inner.cached_state.read().await;
85        if cache.0 == head {
86            cache.1.clone()
87        } else {
88            Default::default()
89        }
90    }
91
92    /// Updates the cached state for the given head hash.
93    async fn update_cached_reads(&self, head: B256, cached_state: CachedReads) {
94        let mut cache = self.inner.cached_state.write().await;
95        if cache.0 == head {
96            cache.1.extend(cached_state);
97        } else {
98            *cache = (head, cached_state)
99        }
100    }
101}
102
103impl<Provider, E> ValidationApi<Provider, E>
104where
105    Provider: BlockReaderIdExt<Header = <E::Primitives as NodePrimitives>::BlockHeader>
106        + ChainSpecProvider<ChainSpec: EthereumHardforks>
107        + StateProviderFactory
108        + 'static,
109    E: BlockExecutorProvider,
110{
111    /// Validates the given block and a [`BidTrace`] against it.
112    pub async fn validate_message_against_block(
113        &self,
114        block: RecoveredBlock<<E::Primitives as NodePrimitives>::Block>,
115        message: BidTrace,
116        registered_gas_limit: u64,
117    ) -> Result<(), ValidationApiError> {
118        self.validate_message_against_header(block.sealed_header(), &message)?;
119
120        self.consensus.validate_header_with_total_difficulty(block.sealed_header(), U256::MAX)?;
121        self.consensus.validate_header(block.sealed_header())?;
122        self.consensus.validate_block_pre_execution(block.sealed_block())?;
123
124        if !self.disallow.is_empty() {
125            if self.disallow.contains(&block.beneficiary()) {
126                return Err(ValidationApiError::Blacklist(block.beneficiary()))
127            }
128            if self.disallow.contains(&message.proposer_fee_recipient) {
129                return Err(ValidationApiError::Blacklist(message.proposer_fee_recipient))
130            }
131            for (sender, tx) in block.senders_iter().zip(block.body().transactions()) {
132                if self.disallow.contains(sender) {
133                    return Err(ValidationApiError::Blacklist(*sender))
134                }
135                if let Some(to) = tx.to() {
136                    if self.disallow.contains(&to) {
137                        return Err(ValidationApiError::Blacklist(to))
138                    }
139                }
140            }
141        }
142
143        let latest_header =
144            self.provider.latest_header()?.ok_or_else(|| ValidationApiError::MissingLatestBlock)?;
145
146        let parent_header = if block.parent_hash() == latest_header.hash() {
147            latest_header
148        } else {
149            // parent is not the latest header so we need to fetch it and ensure it's not too old
150            let parent_header = self
151                .provider
152                .sealed_header_by_hash(block.parent_hash())?
153                .ok_or_else(|| ValidationApiError::MissingParentBlock)?;
154
155            if latest_header.number().saturating_sub(parent_header.number()) >
156                self.validation_window
157            {
158                return Err(ValidationApiError::BlockTooOld)
159            }
160            parent_header
161        };
162
163        self.consensus.validate_header_against_parent(block.sealed_header(), &parent_header)?;
164        self.validate_gas_limit(registered_gas_limit, &parent_header, block.sealed_header())?;
165        let parent_header_hash = parent_header.hash();
166        let state_provider = self.provider.state_by_block_hash(parent_header_hash)?;
167
168        let mut request_cache = self.cached_reads(parent_header_hash).await;
169
170        let cached_db = request_cache.as_db_mut(StateProviderDatabase::new(&state_provider));
171        let executor = self.executor_provider.executor(cached_db);
172
173        let mut accessed_blacklisted = None;
174        let output = executor.execute_with_state_closure(&block, |state| {
175            if !self.disallow.is_empty() {
176                for account in state.cache.accounts.keys() {
177                    if self.disallow.contains(account) {
178                        accessed_blacklisted = Some(*account);
179                    }
180                }
181            }
182        })?;
183
184        // update the cached reads
185        self.update_cached_reads(parent_header_hash, request_cache).await;
186
187        if let Some(account) = accessed_blacklisted {
188            return Err(ValidationApiError::Blacklist(account))
189        }
190
191        self.consensus.validate_block_post_execution(&block, &output)?;
192
193        self.ensure_payment(&block, &output, &message)?;
194
195        let state_root =
196            state_provider.state_root(state_provider.hashed_post_state(&output.state))?;
197
198        if state_root != block.header().state_root() {
199            return Err(ConsensusError::BodyStateRootDiff(
200                GotExpected { got: state_root, expected: block.header().state_root() }.into(),
201            )
202            .into())
203        }
204
205        Ok(())
206    }
207
208    /// Ensures that fields of [`BidTrace`] match the fields of the [`SealedHeaderFor`].
209    fn validate_message_against_header(
210        &self,
211        header: &SealedHeaderFor<E::Primitives>,
212        message: &BidTrace,
213    ) -> Result<(), ValidationApiError> {
214        if header.hash() != message.block_hash {
215            Err(ValidationApiError::BlockHashMismatch(GotExpected {
216                got: message.block_hash,
217                expected: header.hash(),
218            }))
219        } else if header.parent_hash() != message.parent_hash {
220            Err(ValidationApiError::ParentHashMismatch(GotExpected {
221                got: message.parent_hash,
222                expected: header.parent_hash(),
223            }))
224        } else if header.gas_limit() != message.gas_limit {
225            Err(ValidationApiError::GasLimitMismatch(GotExpected {
226                got: message.gas_limit,
227                expected: header.gas_limit(),
228            }))
229        } else if header.gas_used() != message.gas_used {
230            return Err(ValidationApiError::GasUsedMismatch(GotExpected {
231                got: message.gas_used,
232                expected: header.gas_used(),
233            }))
234        } else {
235            Ok(())
236        }
237    }
238
239    /// Ensures that the chosen gas limit is the closest possible value for the validator's
240    /// registered gas limit.
241    ///
242    /// Ref: <https://github.com/flashbots/builder/blob/a742641e24df68bc2fc476199b012b0abce40ffe/core/blockchain.go#L2474-L2477>
243    fn validate_gas_limit(
244        &self,
245        registered_gas_limit: u64,
246        parent_header: &SealedHeaderFor<E::Primitives>,
247        header: &SealedHeaderFor<E::Primitives>,
248    ) -> Result<(), ValidationApiError> {
249        let max_gas_limit =
250            parent_header.gas_limit() + parent_header.gas_limit() / GAS_LIMIT_BOUND_DIVISOR - 1;
251        let min_gas_limit =
252            parent_header.gas_limit() - parent_header.gas_limit() / GAS_LIMIT_BOUND_DIVISOR + 1;
253
254        let best_gas_limit =
255            std::cmp::max(min_gas_limit, std::cmp::min(max_gas_limit, registered_gas_limit));
256
257        if best_gas_limit != header.gas_limit() {
258            return Err(ValidationApiError::GasLimitMismatch(GotExpected {
259                got: header.gas_limit(),
260                expected: best_gas_limit,
261            }))
262        }
263
264        Ok(())
265    }
266
267    /// Ensures that the proposer has received [`BidTrace::value`] for this block.
268    ///
269    /// Firstly attempts to verify the payment by checking the state changes, otherwise falls back
270    /// to checking the latest block transaction.
271    fn ensure_payment(
272        &self,
273        block: &SealedBlock<<E::Primitives as NodePrimitives>::Block>,
274        output: &BlockExecutionOutput<<E::Primitives as NodePrimitives>::Receipt>,
275        message: &BidTrace,
276    ) -> Result<(), ValidationApiError> {
277        let (mut balance_before, balance_after) = if let Some(acc) =
278            output.state.state.get(&message.proposer_fee_recipient)
279        {
280            let balance_before = acc.original_info.as_ref().map(|i| i.balance).unwrap_or_default();
281            let balance_after = acc.info.as_ref().map(|i| i.balance).unwrap_or_default();
282
283            (balance_before, balance_after)
284        } else {
285            // account might have balance but considering it zero is fine as long as we know
286            // that balance have not changed
287            (U256::ZERO, U256::ZERO)
288        };
289
290        if let Some(withdrawals) = block.body().withdrawals() {
291            for withdrawal in withdrawals {
292                if withdrawal.address == message.proposer_fee_recipient {
293                    balance_before += withdrawal.amount_wei();
294                }
295            }
296        }
297
298        if balance_after >= balance_before + message.value {
299            return Ok(())
300        }
301
302        let (receipt, tx) = output
303            .receipts
304            .last()
305            .zip(block.body().transactions().last())
306            .ok_or(ValidationApiError::ProposerPayment)?;
307
308        if !receipt.status() {
309            return Err(ValidationApiError::ProposerPayment)
310        }
311
312        if tx.to() != Some(message.proposer_fee_recipient) {
313            return Err(ValidationApiError::ProposerPayment)
314        }
315
316        if tx.value() != message.value {
317            return Err(ValidationApiError::ProposerPayment)
318        }
319
320        if !tx.input().is_empty() {
321            return Err(ValidationApiError::ProposerPayment)
322        }
323
324        if let Some(block_base_fee) = block.header().base_fee_per_gas() {
325            if tx.effective_tip_per_gas(block_base_fee).unwrap_or_default() != 0 {
326                return Err(ValidationApiError::ProposerPayment)
327            }
328        }
329
330        Ok(())
331    }
332
333    /// Validates the given [`BlobsBundleV1`] and returns versioned hashes for blobs.
334    pub fn validate_blobs_bundle(
335        &self,
336        mut blobs_bundle: BlobsBundleV1,
337    ) -> Result<Vec<B256>, ValidationApiError> {
338        if blobs_bundle.commitments.len() != blobs_bundle.proofs.len() ||
339            blobs_bundle.commitments.len() != blobs_bundle.blobs.len()
340        {
341            return Err(ValidationApiError::InvalidBlobsBundle)
342        }
343
344        let versioned_hashes = blobs_bundle
345            .commitments
346            .iter()
347            .map(|c| kzg_to_versioned_hash(c.as_slice()))
348            .collect::<Vec<_>>();
349
350        let sidecar = blobs_bundle.pop_sidecar(blobs_bundle.blobs.len());
351
352        sidecar.validate(&versioned_hashes, EnvKzgSettings::default().get())?;
353
354        Ok(versioned_hashes)
355    }
356
357    /// Core logic for validating the builder submission v3
358    async fn validate_builder_submission_v3(
359        &self,
360        request: BuilderBlockValidationRequestV3,
361    ) -> Result<(), ValidationApiError> {
362        let block = self.payload_validator.ensure_well_formed_payload(ExecutionData {
363            payload: ExecutionPayload::V3(request.request.execution_payload),
364            sidecar: ExecutionPayloadSidecar::v3(CancunPayloadFields {
365                parent_beacon_block_root: request.parent_beacon_block_root,
366                versioned_hashes: self.validate_blobs_bundle(request.request.blobs_bundle)?,
367            }),
368        })?;
369
370        self.validate_message_against_block(
371            block,
372            request.request.message,
373            request.registered_gas_limit,
374        )
375        .await
376    }
377
378    /// Core logic for validating the builder submission v4
379    async fn validate_builder_submission_v4(
380        &self,
381        request: BuilderBlockValidationRequestV4,
382    ) -> Result<(), ValidationApiError> {
383        let block = self.payload_validator.ensure_well_formed_payload(ExecutionData {
384            payload: ExecutionPayload::V3(request.request.execution_payload),
385            sidecar: ExecutionPayloadSidecar::v4(
386                CancunPayloadFields {
387                    parent_beacon_block_root: request.parent_beacon_block_root,
388                    versioned_hashes: self.validate_blobs_bundle(request.request.blobs_bundle)?,
389                },
390                PraguePayloadFields {
391                    requests: RequestsOrHash::Requests(
392                        request.request.execution_requests.to_requests(),
393                    ),
394                },
395            ),
396        })?;
397
398        self.validate_message_against_block(
399            block,
400            request.request.message,
401            request.registered_gas_limit,
402        )
403        .await
404    }
405}
406
407#[async_trait]
408impl<Provider, E> BlockSubmissionValidationApiServer for ValidationApi<Provider, E>
409where
410    Provider: BlockReaderIdExt<Header = <E::Primitives as NodePrimitives>::BlockHeader>
411        + ChainSpecProvider<ChainSpec: EthereumHardforks>
412        + StateProviderFactory
413        + Clone
414        + 'static,
415    E: BlockExecutorProvider,
416{
417    async fn validate_builder_submission_v1(
418        &self,
419        _request: BuilderBlockValidationRequest,
420    ) -> RpcResult<()> {
421        warn!(target: "rpc::flashbots", "Method `flashbots_validateBuilderSubmissionV1` is not supported");
422        Err(internal_rpc_err("unimplemented"))
423    }
424
425    async fn validate_builder_submission_v2(
426        &self,
427        _request: BuilderBlockValidationRequestV2,
428    ) -> RpcResult<()> {
429        warn!(target: "rpc::flashbots", "Method `flashbots_validateBuilderSubmissionV2` is not supported");
430        Err(internal_rpc_err("unimplemented"))
431    }
432
433    /// Validates a block submitted to the relay
434    async fn validate_builder_submission_v3(
435        &self,
436        request: BuilderBlockValidationRequestV3,
437    ) -> RpcResult<()> {
438        let this = self.clone();
439        let (tx, rx) = oneshot::channel();
440
441        self.task_spawner.spawn_blocking(Box::pin(async move {
442            let result = Self::validate_builder_submission_v3(&this, request)
443                .await
444                .map_err(|err| internal_rpc_err(err.to_string()));
445            let _ = tx.send(result);
446        }));
447
448        rx.await.map_err(|_| internal_rpc_err("Internal blocking task error"))?
449    }
450
451    /// Validates a block submitted to the relay
452    async fn validate_builder_submission_v4(
453        &self,
454        request: BuilderBlockValidationRequestV4,
455    ) -> RpcResult<()> {
456        let this = self.clone();
457        let (tx, rx) = oneshot::channel();
458
459        self.task_spawner.spawn_blocking(Box::pin(async move {
460            let result = Self::validate_builder_submission_v4(&this, request)
461                .await
462                .map_err(|err| internal_rpc_err(err.to_string()));
463            let _ = tx.send(result);
464        }));
465
466        rx.await.map_err(|_| internal_rpc_err("Internal blocking task error"))?
467    }
468}
469
470pub struct ValidationApiInner<Provider, E: BlockExecutorProvider> {
471    /// The provider that can interact with the chain.
472    provider: Provider,
473    /// Consensus implementation.
474    consensus: Arc<dyn FullConsensus<E::Primitives, Error = ConsensusError>>,
475    /// Execution payload validator.
476    payload_validator: Arc<
477        dyn PayloadValidator<
478            Block = <E::Primitives as NodePrimitives>::Block,
479            ExecutionData = ExecutionData,
480        >,
481    >,
482    /// Block executor factory.
483    executor_provider: E,
484    /// Set of disallowed addresses
485    disallow: HashSet<Address>,
486    /// The maximum block distance - parent to latest - allowed for validation
487    validation_window: u64,
488    /// Cached state reads to avoid redundant disk I/O across multiple validation attempts
489    /// targeting the same state. Stores a tuple of (`block_hash`, `cached_reads`) for the
490    /// latest head block state. Uses async `RwLock` to safely handle concurrent validation
491    /// requests.
492    cached_state: RwLock<(B256, CachedReads)>,
493    /// Task spawner for blocking operations
494    task_spawner: Box<dyn TaskSpawner>,
495    /// Validation metrics
496    metrics: ValidationMetrics,
497}
498
499impl<Provider, E: BlockExecutorProvider> fmt::Debug for ValidationApiInner<Provider, E> {
500    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
501        f.debug_struct("ValidationApiInner").finish_non_exhaustive()
502    }
503}
504
505/// Configuration for validation API.
506#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
507pub struct ValidationApiConfig {
508    /// Disallowed addresses.
509    pub disallow: HashSet<Address>,
510    /// The maximum block distance - parent to latest - allowed for validation
511    pub validation_window: u64,
512}
513
514impl ValidationApiConfig {
515    /// Default validation blocks window of 3 blocks
516    pub const DEFAULT_VALIDATION_WINDOW: u64 = 3;
517}
518
519impl Default for ValidationApiConfig {
520    fn default() -> Self {
521        Self { disallow: Default::default(), validation_window: Self::DEFAULT_VALIDATION_WINDOW }
522    }
523}
524
525/// Errors thrown by the validation API.
526#[derive(Debug, thiserror::Error)]
527pub enum ValidationApiError {
528    #[error("block gas limit mismatch: {_0}")]
529    GasLimitMismatch(GotExpected<u64>),
530    #[error("block gas used mismatch: {_0}")]
531    GasUsedMismatch(GotExpected<u64>),
532    #[error("block parent hash mismatch: {_0}")]
533    ParentHashMismatch(GotExpected<B256>),
534    #[error("block hash mismatch: {_0}")]
535    BlockHashMismatch(GotExpected<B256>),
536    #[error("missing latest block in database")]
537    MissingLatestBlock,
538    #[error("parent block not found")]
539    MissingParentBlock,
540    #[error("block is too old, outside validation window")]
541    BlockTooOld,
542    #[error("could not verify proposer payment")]
543    ProposerPayment,
544    #[error("invalid blobs bundle")]
545    InvalidBlobsBundle,
546    /// When the transaction signature is invalid
547    #[error("invalid transaction signature")]
548    InvalidTransactionSignature,
549    #[error("block accesses blacklisted address: {_0}")]
550    Blacklist(Address),
551    #[error(transparent)]
552    Blob(#[from] BlobTransactionValidationError),
553    #[error(transparent)]
554    Consensus(#[from] ConsensusError),
555    #[error(transparent)]
556    Provider(#[from] ProviderError),
557    #[error(transparent)]
558    Execution(#[from] BlockExecutionError),
559    #[error(transparent)]
560    Payload(#[from] NewPayloadError),
561}
562
563/// Metrics for the validation endpoint.
564#[derive(Metrics)]
565#[metrics(scope = "builder.validation")]
566pub(crate) struct ValidationMetrics {
567    /// The number of entries configured in the builder validation disallow list.
568    pub(crate) disallow_size: Gauge,
569}