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