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