Skip to main content

reth_rpc/
testing.rs

1//! Implementation of the `testing` namespace.
2//!
3//! This exposes `testing_buildBlockV1`, intended for non-production/debug use.
4//!
5//! # Enabling the testing namespace
6//!
7//! The `testing_` namespace is disabled by default for security reasons.
8//! To enable it, add `testing` to the `--http.api` flag when starting the node:
9//!
10//! ```sh
11//! reth node --http --http.api eth,testing
12//! ```
13//!
14//! **Warning:** This namespace allows building arbitrary blocks. Never expose it
15//! on public-facing RPC endpoints without proper authentication.
16
17use alloy_consensus::Transaction;
18use alloy_eips::{eip1559::calculate_block_gas_limit, eip2718::Decodable2718};
19use alloy_evm::{Evm, RecoveredTx};
20use alloy_primitives::{
21    map::{DefaultHashBuilder, HashSet},
22    Address, Bytes, B256, U256,
23};
24use alloy_rlp::Encodable;
25use alloy_rpc_types_engine::{
26    BlobsBundleV2, ExecutionData, ExecutionPayloadEnvelopeV5, ExecutionPayloadSidecar,
27    ExecutionPayloadV3, ForkchoiceState, PayloadAttributes, PraguePayloadFields,
28};
29use async_trait::async_trait;
30use jsonrpsee::core::RpcResult;
31use reth_chainspec::{ChainSpecProvider, EthereumHardforks};
32use reth_consensus_common::validation::MAX_RLP_BLOCK_SIZE;
33use reth_engine_primitives::ConsensusEngineHandle;
34use reth_errors::RethError;
35use reth_ethereum_engine_primitives::EthBuiltPayload;
36use reth_evm::{execute::BlockBuilder, ConfigureEvm, NextBlockEnvAttributes};
37use reth_payload_primitives::{BuiltPayload, PayloadTypes};
38use reth_primitives_traits::{
39    transaction::{recover::try_recover_signers, signed::RecoveryError},
40    AlloyBlockHeader as BlockTrait, Block as _, HeaderTy, TxTy,
41};
42use reth_revm::{database::StateProviderDatabase, db::State};
43use reth_rpc_api::{TestingApiServer, TestingBuildBlockRequestV1};
44use reth_rpc_eth_api::{helpers::Call, FromEthApiError};
45use reth_rpc_eth_types::EthApiError;
46use reth_storage_api::{BlockReader, BlockReaderIdExt, HeaderProvider};
47use reth_transaction_pool::{BestTransactionsAttributes, PoolTransaction, TransactionPool};
48use revm::context::Block;
49use std::sync::Arc;
50use tracing::debug;
51
52/// Testing API handler.
53#[derive(Debug, Clone)]
54pub struct TestingApi<
55    Eth,
56    Evm,
57    Payload: PayloadTypes = reth_ethereum_engine_primitives::EthEngineTypes,
58> {
59    eth_api: Eth,
60    evm_config: Evm,
61    /// Desired gas limit to move toward while respecting the consensus gas limit bounds.
62    desired_gas_limit: u64,
63    engine_handle: ConsensusEngineHandle<Payload>,
64    /// If true, skip invalid transactions instead of failing.
65    skip_invalid_transactions: bool,
66    /// If set, override the block gas limit in `testing_buildBlockV1`.
67    gas_limit_override: Option<u64>,
68}
69
70impl<Eth, Evm, Payload: PayloadTypes> TestingApi<Eth, Evm, Payload> {
71    /// Create a new testing API handler.
72    pub const fn new(
73        eth_api: Eth,
74        evm_config: Evm,
75        desired_gas_limit: u64,
76        engine_handle: ConsensusEngineHandle<Payload>,
77    ) -> Self {
78        Self {
79            eth_api,
80            evm_config,
81            desired_gas_limit,
82            engine_handle,
83            skip_invalid_transactions: false,
84            gas_limit_override: None,
85        }
86    }
87
88    /// Enable skipping invalid transactions instead of failing.
89    /// When a transaction fails, all subsequent transactions from the same sender are also
90    /// skipped.
91    pub const fn with_skip_invalid_transactions(mut self) -> Self {
92        self.skip_invalid_transactions = true;
93        self
94    }
95
96    /// Override the gas limit used by `testing_buildBlockV1`.
97    pub const fn with_gas_limit_override(mut self, gas_limit: u64) -> Self {
98        self.gas_limit_override = Some(gas_limit);
99        self
100    }
101}
102
103impl<Eth, Evm, Payload> TestingApi<Eth, Evm, Payload>
104where
105    Payload: PayloadTypes<
106        ExecutionData = ExecutionData,
107        BuiltPayload: BuiltPayload<Primitives = Evm::Primitives>,
108    >,
109    Eth: Call<
110        Provider: BlockReader<Header = HeaderTy<Evm::Primitives>>
111                      + BlockReaderIdExt<Header = HeaderTy<Evm::Primitives>>
112                      + ChainSpecProvider<ChainSpec: EthereumHardforks>,
113        Pool: TransactionPool<Transaction: PoolTransaction<Consensus = TxTy<Evm::Primitives>>>,
114    >,
115    Evm: ConfigureEvm<NextBlockEnvCtx = NextBlockEnvAttributes> + 'static,
116{
117    async fn build_payload_v1(
118        &self,
119        request: TestingBuildBlockRequestV1,
120        skip_invalid_transactions: bool,
121        use_pool_transactions: bool,
122    ) -> Result<EthBuiltPayload<Evm::Primitives>, Eth::Error> {
123        let evm_config = self.evm_config.clone();
124        let desired_gas_limit = self.desired_gas_limit;
125        let gas_limit_override = self.gas_limit_override;
126        self.eth_api
127            .spawn_with_state_at_block(request.parent_block_hash, move |eth_api, state| {
128                let state = state.database.0;
129                let parent = eth_api
130                    .provider()
131                    .sealed_header_by_hash(request.parent_block_hash)?
132                    .ok_or_else(|| {
133                    EthApiError::HeaderNotFound(request.parent_block_hash.into())
134                })?;
135
136                let chain_spec = eth_api.provider().chain_spec();
137                let is_amsterdam = chain_spec
138                    .is_amsterdam_active_at_timestamp(request.payload_attributes.timestamp);
139                let is_osaka =
140                    chain_spec.is_osaka_active_at_timestamp(request.payload_attributes.timestamp);
141                let mut db = State::builder()
142                    .with_bundle_update()
143                    .with_database(StateProviderDatabase::new(&state))
144                    .with_bal_builder_if(is_amsterdam)
145                    .build();
146
147                let withdrawals = request.payload_attributes.withdrawals.clone();
148                let withdrawals_rlp_length = withdrawals.as_ref().map(|w| w.length()).unwrap_or(0);
149
150                let env_attrs = NextBlockEnvAttributes {
151                    timestamp: request.payload_attributes.timestamp,
152                    suggested_fee_recipient: request.payload_attributes.suggested_fee_recipient,
153                    prev_randao: request.payload_attributes.prev_randao,
154                    gas_limit: gas_limit_override.unwrap_or_else(|| {
155                        calculate_block_gas_limit(
156                            parent.gas_limit(),
157                            request
158                                .payload_attributes
159                                .target_gas_limit
160                                .unwrap_or(desired_gas_limit),
161                        )
162                    }),
163                    parent_beacon_block_root: request.payload_attributes.parent_beacon_block_root,
164                    withdrawals: withdrawals.map(Into::into),
165                    extra_data: request.extra_data.unwrap_or_default(),
166                    slot_number: request.payload_attributes.slot_number,
167                };
168
169                let mut builder = evm_config
170                    .builder_for_next_block(&mut db, &parent, env_attrs)
171                    .map_err(RethError::other)
172                    .map_err(Eth::Error::from_eth_err)?;
173                builder.apply_pre_execution_changes().map_err(Eth::Error::from_eth_err)?;
174
175                let mut total_fees = U256::ZERO;
176                let base_fee = builder.evm_mut().block().basefee();
177
178                let mut invalid_senders: HashSet<Address, DefaultHashBuilder> = HashSet::default();
179                let mut block_transactions_rlp_length = 0usize;
180
181                // If no transactions are provided in the request, use transactions from the pool.
182                let recovered_txs = if use_pool_transactions {
183                    let mut best_txs = eth_api.pool().best_transactions_with_attributes(
184                        BestTransactionsAttributes::new(
185                            base_fee,
186                            builder
187                                .evm_mut()
188                                .block()
189                                .blob_gasprice()
190                                .map(|gasprice| gasprice as u64),
191                        ),
192                    );
193                    best_txs.no_updates();
194                    best_txs.map(|tx| tx.to_consensus()).collect()
195                } else {
196                    // Decode and recover all transactions in parallel
197                    try_recover_signers(&request.transactions, |tx| {
198                        TxTy::<Evm::Primitives>::decode_2718_exact(tx.as_ref())
199                            .map_err(RecoveryError::from_source)
200                    })
201                    .or(Err(EthApiError::InvalidTransactionSignature))?
202                };
203                let allow_skip_invalid_transactions =
204                    skip_invalid_transactions || use_pool_transactions;
205
206                for (idx, tx) in recovered_txs.into_iter().enumerate() {
207                    let signer = tx.signer();
208                    if allow_skip_invalid_transactions && invalid_senders.contains(&signer) {
209                        continue;
210                    }
211
212                    // EIP-7934: Check estimated block size before adding transaction
213                    let tx_rlp_len = tx.tx().length();
214                    if is_osaka {
215                        // 1KB overhead for block header
216                        let estimated_block_size = block_transactions_rlp_length +
217                            tx_rlp_len +
218                            withdrawals_rlp_length +
219                            1024;
220                        if estimated_block_size > MAX_RLP_BLOCK_SIZE {
221                            if allow_skip_invalid_transactions {
222                                debug!(
223                                    target: "rpc::testing",
224                                    tx_idx = idx,
225                                    ?signer,
226                                    estimated_block_size,
227                                    max_size = MAX_RLP_BLOCK_SIZE,
228                                    "Skipping transaction: would exceed block size limit"
229                                );
230                                invalid_senders.insert(signer);
231                                continue;
232                            }
233                            return Err(Eth::Error::from_eth_err(EthApiError::InvalidParams(
234                                format!(
235                                    "transaction at index {} would exceed max block size: {} > {}",
236                                    idx, estimated_block_size, MAX_RLP_BLOCK_SIZE
237                                ),
238                            )));
239                        }
240                    }
241
242                    let tip = tx.effective_tip_per_gas(base_fee).unwrap_or_default();
243                    let gas_used = match builder.execute_transaction(tx) {
244                        Ok(gas_used) => gas_used.tx_gas_used(),
245                        Err(err) => {
246                            if allow_skip_invalid_transactions {
247                                debug!(
248                                    target: "rpc::testing",
249                                    tx_idx = idx,
250                                    ?signer,
251                                    error = ?err,
252                                    "Skipping invalid transaction"
253                                );
254                                invalid_senders.insert(signer);
255                                continue;
256                            }
257                            debug!(
258                                target: "rpc::testing",
259                                tx_idx = idx,
260                                ?signer,
261                                error = ?err,
262                                "Transaction execution failed"
263                            );
264                            return Err(Eth::Error::from_eth_err(err));
265                        }
266                    };
267
268                    block_transactions_rlp_length += tx_rlp_len;
269                    total_fees += U256::from(tip) * U256::from(gas_used);
270                }
271                let outcome = builder.finish(&state, None).map_err(Eth::Error::from_eth_err)?;
272
273                let has_requests = outcome.block.requests_hash().is_some();
274                let requests = has_requests.then_some(outcome.execution_result.requests);
275                let block_access_list = outcome
276                    .block_access_list
277                    .map(|block_access_list| alloy_rlp::encode(&block_access_list).into());
278
279                Ok(EthBuiltPayload::new(
280                    Arc::new(outcome.block),
281                    total_fees,
282                    requests,
283                    block_access_list,
284                ))
285            })
286            .await
287    }
288
289    async fn build_block_v1(
290        &self,
291        request: TestingBuildBlockRequestV1,
292        use_pool_transactions: bool,
293    ) -> Result<ExecutionPayloadEnvelopeV5, Eth::Error> {
294        let payload = self
295            .build_payload_v1(request, self.skip_invalid_transactions, use_pool_transactions)
296            .await?;
297        let fees = payload.fees();
298        let requests = payload.requests().unwrap_or_default();
299        let block = Arc::unwrap_or_clone(payload.into_block_arc());
300        let block_hash = block.hash();
301        let block = block.into_block().into_ethereum_block();
302
303        Ok(ExecutionPayloadEnvelopeV5 {
304            execution_payload: ExecutionPayloadV3::from_block_unchecked(block_hash, &block),
305            block_value: fees,
306            blobs_bundle: BlobsBundleV2::empty(),
307            should_override_builder: false,
308            execution_requests: requests,
309        })
310    }
311
312    async fn commit_block_v1(
313        &self,
314        payload_attributes: PayloadAttributes,
315        transactions: Option<Vec<Bytes>>,
316        extra_data: Option<Bytes>,
317    ) -> Result<B256, Eth::Error> {
318        let parent = self
319            .eth_api
320            .provider()
321            .latest_header()
322            .map_err(EthApiError::from)?
323            .ok_or_else(|| EthApiError::HeaderNotFound(alloy_eips::BlockId::latest()))?;
324        let safe_block_hash = self
325            .eth_api
326            .provider()
327            .safe_header()
328            .map_err(EthApiError::from)?
329            .map(|header| header.hash())
330            .unwrap_or_else(|| parent.hash());
331        let finalized_block_hash = self
332            .eth_api
333            .provider()
334            .finalized_header()
335            .map_err(EthApiError::from)?
336            .map(|header| header.hash())
337            .unwrap_or_else(|| parent.hash());
338
339        let use_pool_transactions = transactions.is_none();
340        let payload = self
341            .build_payload_v1(
342                TestingBuildBlockRequestV1 {
343                    parent_block_hash: parent.hash(),
344                    payload_attributes,
345                    transactions: transactions.unwrap_or_default(),
346                    extra_data,
347                },
348                false,
349                use_pool_transactions,
350            )
351            .await?;
352
353        let block_hash = payload.block().hash();
354        let requests = payload.requests();
355        let block_access_list = payload.block_access_list().cloned();
356        let block = Arc::unwrap_or_clone(payload.into_block_arc()).into_sealed_block();
357        let execution_data = Payload::block_to_payload(block, block_access_list);
358        let execution_data = match (requests, execution_data.sidecar.cancun()) {
359            (Some(requests), Some(cancun)) => ExecutionData::new(
360                execution_data.payload,
361                ExecutionPayloadSidecar::v4(cancun.clone(), PraguePayloadFields::new(requests)),
362            ),
363            _ => execution_data,
364        };
365        let status = self
366            .engine_handle
367            .new_payload(execution_data)
368            .await
369            .map_err(RethError::other)
370            .map_err(Eth::Error::from_eth_err)?;
371        if !status.is_valid() {
372            return Err(Eth::Error::from_eth_err(EthApiError::InvalidParams(format!(
373                "new payload returned non-valid status: {:?}",
374                status.status
375            ))));
376        }
377
378        let fcu = self
379            .engine_handle
380            .fork_choice_updated(
381                ForkchoiceState {
382                    head_block_hash: block_hash,
383                    safe_block_hash,
384                    finalized_block_hash,
385                },
386                None,
387            )
388            .await
389            .map_err(RethError::other)
390            .map_err(Eth::Error::from_eth_err)?;
391        if !fcu.is_valid() {
392            return Err(Eth::Error::from_eth_err(EthApiError::InvalidParams(format!(
393                "forkchoice update returned non-valid status: {:?}",
394                fcu.payload_status.status
395            ))));
396        }
397
398        Ok(block_hash)
399    }
400}
401
402#[async_trait]
403impl<Eth, Evm, Payload> TestingApiServer for TestingApi<Eth, Evm, Payload>
404where
405    Payload: PayloadTypes<
406        ExecutionData = ExecutionData,
407        BuiltPayload: BuiltPayload<Primitives = Evm::Primitives>,
408    >,
409    Eth: Call<
410        Provider: BlockReader<Header = HeaderTy<Evm::Primitives>>
411                      + BlockReaderIdExt<Header = HeaderTy<Evm::Primitives>>
412                      + ChainSpecProvider<ChainSpec: EthereumHardforks>,
413        Pool: TransactionPool<Transaction: PoolTransaction<Consensus = TxTy<Evm::Primitives>>>,
414    >,
415    Evm: ConfigureEvm<NextBlockEnvCtx = NextBlockEnvAttributes> + 'static,
416{
417    /// Handles `testing_buildBlockV1` by gating concurrency via a semaphore and offloading heavy
418    /// work to the blocking pool to avoid stalling the async runtime.
419    async fn build_block_v1(
420        &self,
421        parent_block_hash: B256,
422        payload_attributes: PayloadAttributes,
423        transactions: Option<Vec<Bytes>>,
424        extra_data: Option<Bytes>,
425    ) -> RpcResult<ExecutionPayloadEnvelopeV5> {
426        let use_pool_transactions = transactions.is_none();
427        let request = TestingBuildBlockRequestV1 {
428            parent_block_hash,
429            payload_attributes,
430            transactions: transactions.unwrap_or_default(),
431            extra_data,
432        };
433        self.build_block_v1(request, use_pool_transactions).await.map_err(Into::into)
434    }
435
436    /// Handles `testing_commitBlockV1` by building on the current canonical head, then submitting
437    /// the payload and advancing forkchoice through the same engine handle used by the Engine API.
438    async fn commit_block_v1(
439        &self,
440        payload_attributes: PayloadAttributes,
441        transactions: Option<Vec<Bytes>>,
442        extra_data: Option<Bytes>,
443    ) -> RpcResult<B256> {
444        self.commit_block_v1(payload_attributes, transactions, extra_data).await.map_err(Into::into)
445    }
446}