feat: add support for testing_ rpc namespace and testing_buildBlockV1 (#20094)

Co-authored-by: Arsenii Kulikov <klkvrr@gmail.com>
Co-authored-by: Matthias Seitz <matthias.seitz@outlook.de>
This commit is contained in:
Karl Yu
2025-12-11 16:56:46 +08:00
committed by GitHub
parent 8b27ca6fa2
commit e90cfedf3d
19 changed files with 322 additions and 17 deletions

5
Cargo.lock generated
View File

@@ -9232,6 +9232,7 @@ dependencies = [
"alloy-sol-types",
"eyre",
"futures",
"jsonrpsee-core",
"rand 0.9.2",
"reth-chainspec",
"reth-db",
@@ -9267,6 +9268,7 @@ dependencies = [
"serde",
"serde_json",
"similar-asserts",
"tempfile",
"tokio",
]
@@ -10157,6 +10159,7 @@ dependencies = [
"reth-db-api",
"reth-engine-primitives",
"reth-errors",
"reth-ethereum-engine-primitives",
"reth-ethereum-primitives",
"reth-evm",
"reth-evm-ethereum",
@@ -10219,7 +10222,9 @@ dependencies = [
"reth-network-peers",
"reth-rpc-eth-api",
"reth-trie-common",
"serde",
"serde_json",
"tokio",
]
[[package]]

View File

@@ -376,11 +376,11 @@ reth-era-utils = { path = "crates/era-utils" }
reth-errors = { path = "crates/errors" }
reth-eth-wire = { path = "crates/net/eth-wire" }
reth-eth-wire-types = { path = "crates/net/eth-wire-types" }
reth-ethereum-payload-builder = { path = "crates/ethereum/payload" }
reth-ethereum-cli = { path = "crates/ethereum/cli", default-features = false }
reth-ethereum-consensus = { path = "crates/ethereum/consensus", default-features = false }
reth-ethereum-engine-primitives = { path = "crates/ethereum/engine-primitives", default-features = false }
reth-ethereum-forks = { path = "crates/ethereum/hardforks", default-features = false }
reth-ethereum-payload-builder = { path = "crates/ethereum/payload" }
reth-ethereum-primitives = { path = "crates/ethereum/primitives", default-features = false }
reth-ethereum = { path = "crates/ethereum/reth" }
reth-etl = { path = "crates/etl" }

View File

@@ -61,6 +61,8 @@ reth-node-core.workspace = true
reth-e2e-test-utils.workspace = true
reth-tasks.workspace = true
reth-testing-utils.workspace = true
tempfile.workspace = true
jsonrpsee-core.workspace = true
alloy-primitives.workspace = true
alloy-provider.workspace = true

View File

@@ -38,9 +38,9 @@ use reth_payload_primitives::PayloadTypes;
use reth_provider::{providers::ProviderFactoryBuilder, EthStorage};
use reth_rpc::{
eth::core::{EthApiFor, EthRpcConverterFor},
ValidationApi,
TestingApi, ValidationApi,
};
use reth_rpc_api::servers::BlockSubmissionValidationApiServer;
use reth_rpc_api::servers::{BlockSubmissionValidationApiServer, TestingApiServer};
use reth_rpc_builder::{config::RethRpcServerConfig, middleware::RethRpcMiddleware};
use reth_rpc_eth_api::{
helpers::{
@@ -313,6 +313,17 @@ where
.modules
.merge_if_module_configured(RethRpcModule::Eth, eth_config.into_rpc())?;
// testing_buildBlockV1: only wire when the hidden testing module is explicitly
// requested on any transport. Default stays disabled to honor security guidance.
let testing_api = TestingApi::new(
container.registry.eth_api().clone(),
container.registry.evm_config().clone(),
)
.into_rpc();
container
.modules
.merge_if_module_configured(RethRpcModule::Testing, testing_api)?;
Ok(())
})
.await

View File

@@ -2,5 +2,6 @@
mod builder;
mod exex;
mod testing;
const fn main() {}

View File

@@ -0,0 +1,84 @@
//! E2E tests for the testing RPC namespace.
use alloy_primitives::{Address, B256};
use alloy_rpc_types_engine::ExecutionPayloadEnvelopeV4;
use jsonrpsee_core::client::ClientT;
use reth_db::test_utils::create_test_rw_db;
use reth_ethereum_engine_primitives::EthPayloadAttributes;
use reth_node_builder::{NodeBuilder, NodeConfig};
use reth_node_core::{
args::DatadirArgs,
dirs::{DataDirPath, MaybePlatformPath},
};
use reth_node_ethereum::{node::EthereumAddOns, EthereumNode};
use reth_rpc_api::TestingBuildBlockRequestV1;
use reth_rpc_server_types::{RethRpcModule, RpcModuleSelection};
use reth_tasks::TaskManager;
use std::str::FromStr;
use tempfile::tempdir;
use tokio::sync::oneshot;
#[tokio::test(flavor = "multi_thread")]
async fn testing_rpc_build_block_works() -> eyre::Result<()> {
let tasks = TaskManager::current();
let mut rpc_args = reth_node_core::args::RpcServerArgs::default().with_http();
rpc_args.http_api = Some(RpcModuleSelection::from_iter([RethRpcModule::Testing]));
let tempdir = tempdir().expect("temp datadir");
let datadir_args = DatadirArgs {
datadir: MaybePlatformPath::<DataDirPath>::from_str(tempdir.path().to_str().unwrap())
.expect("valid datadir"),
static_files_path: Some(tempdir.path().join("static")),
};
let config = NodeConfig::test().with_datadir_args(datadir_args).with_rpc(rpc_args);
let db = create_test_rw_db();
let (tx, rx): (
oneshot::Sender<eyre::Result<ExecutionPayloadEnvelopeV4>>,
oneshot::Receiver<eyre::Result<ExecutionPayloadEnvelopeV4>>,
) = oneshot::channel();
let builder = NodeBuilder::new(config)
.with_database(db)
.with_launch_context(tasks.executor())
.with_types::<EthereumNode>()
.with_components(EthereumNode::components())
.with_add_ons(EthereumAddOns::default())
.on_rpc_started(move |ctx, handles| {
let Some(client) = handles.rpc.http_client() else { return Ok(()) };
let chain = ctx.config().chain.clone();
let parent_block_hash = chain.genesis_hash();
let payload_attributes = EthPayloadAttributes {
timestamp: chain.genesis().timestamp + 1,
prev_randao: B256::ZERO,
suggested_fee_recipient: Address::ZERO,
withdrawals: None,
parent_beacon_block_root: None,
};
let request = TestingBuildBlockRequestV1 {
parent_block_hash,
payload_attributes,
transactions: vec![],
extra_data: None,
};
tokio::spawn(async move {
let res: eyre::Result<ExecutionPayloadEnvelopeV4> =
client.request("testing_buildBlockV1", [request]).await.map_err(Into::into);
let _ = tx.send(res);
});
Ok(())
});
// Launch the node with the default engine launcher.
let launcher = builder.engine_api_launcher();
let _node = builder.launch_with(launcher).await?;
// Wait for the testing RPC call to return.
let res = rx.await.expect("testing_buildBlockV1 response");
assert!(res.is_ok(), "testing_buildBlockV1 failed: {:?}", res.err());
Ok(())
}

View File

@@ -168,6 +168,7 @@ where
gas_limit: builder_config.gas_limit(parent_header.gas_limit),
parent_beacon_block_root: attributes.parent_beacon_block_root(),
withdrawals: Some(attributes.withdrawals().clone()),
extra_data: None,
},
)
.map_err(PayloadBuilderError::other)?;

View File

@@ -28,7 +28,7 @@ use alloy_evm::{
block::{BlockExecutorFactory, BlockExecutorFor},
precompiles::PrecompilesMap,
};
use alloy_primitives::{Address, B256};
use alloy_primitives::{Address, Bytes, B256};
use core::{error::Error, fmt::Debug};
use execute::{BasicBlockExecutor, BlockAssembler, BlockBuilder};
use reth_execution_errors::BlockExecutionError;
@@ -501,6 +501,8 @@ pub struct NextBlockEnvAttributes {
pub parent_beacon_block_root: Option<B256>,
/// Withdrawals
pub withdrawals: Option<Withdrawals>,
/// Optional extra data.
pub extra_data: Option<Bytes>,
}
/// Abstraction over transaction environment.

View File

@@ -35,6 +35,7 @@ alloy-serde.workspace = true
alloy-rpc-types-beacon.workspace = true
alloy-rpc-types-engine.workspace = true
alloy-genesis.workspace = true
serde = { workspace = true, features = ["derive"] }
# misc
jsonrpsee = { workspace = true, features = ["server", "macros"] }
@@ -46,3 +47,8 @@ client = [
"jsonrpsee/async-client",
"reth-rpc-eth-api/client",
]
[dev-dependencies]
serde_json = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
jsonrpsee = { workspace = true, features = ["client", "async-client", "http-client"] }

View File

@@ -25,11 +25,14 @@ mod net;
mod otterscan;
mod reth;
mod rpc;
mod testing;
mod trace;
mod txpool;
mod validation;
mod web3;
pub use testing::{TestingBuildBlockRequestV1, TESTING_BUILD_BLOCK_V1};
/// re-export of all server traits
pub use servers::*;
@@ -45,6 +48,7 @@ pub mod servers {
otterscan::OtterscanServer,
reth::RethApiServer,
rpc::RpcApiServer,
testing::TestingApiServer,
trace::TraceApiServer,
txpool::TxPoolApiServer,
validation::BlockSubmissionValidationApiServer,
@@ -75,6 +79,7 @@ pub mod clients {
otterscan::OtterscanClient,
reth::RethApiClient,
rpc::RpcApiServer,
testing::TestingApiClient,
trace::TraceApiClient,
txpool::TxPoolApiClient,
validation::BlockSubmissionValidationApiClient,

View File

@@ -0,0 +1,45 @@
//! Testing namespace for building a block in a single call.
//!
//! This follows the `testing_buildBlockV1` specification. **Highly sensitive:**
//! testing-only, powerful enough to include arbitrary transactions; must stay
//! disabled by default and never be exposed on public-facing RPC without an
//! explicit operator flag.
use alloy_primitives::{Bytes, B256};
use alloy_rpc_types_engine::{
ExecutionPayloadEnvelopeV5, PayloadAttributes as EthPayloadAttributes,
};
use jsonrpsee::proc_macros::rpc;
use serde::{Deserialize, Serialize};
/// Capability string for `testing_buildBlockV1`.
pub const TESTING_BUILD_BLOCK_V1: &str = "testing_buildBlockV1";
/// Request payload for `testing_buildBlockV1`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TestingBuildBlockRequestV1 {
/// Parent block hash of the block to build.
pub parent_block_hash: B256,
/// Payload attributes (Cancun version).
pub payload_attributes: EthPayloadAttributes,
/// Raw signed transactions to force-include in order.
pub transactions: Vec<Bytes>,
/// Optional extra data for the block header.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra_data: Option<Bytes>,
}
/// Testing RPC interface for building a block in a single call.
#[cfg_attr(not(feature = "client"), rpc(server, namespace = "testing"))]
#[cfg_attr(feature = "client", rpc(server, client, namespace = "testing"))]
pub trait TestingApi {
/// Builds a block using the provided parent, payload attributes, and transactions.
///
/// See <https://github.com/marcindsobczak/execution-apis/blob/main/src/testing/testing_buildBlockV1.md>
#[method(name = "buildBlockV1")]
async fn build_block_v1(
&self,
request: TestingBuildBlockRequestV1,
) -> jsonrpsee::core::RpcResult<ExecutionPayloadEnvelopeV5>;
}

View File

@@ -52,6 +52,7 @@ use reth_rpc_eth_api::{
};
use reth_rpc_eth_types::{receipt::EthReceiptConverter, EthConfig, EthSubscriptionIdProvider};
use reth_rpc_layer::{AuthLayer, Claims, CompressionLayer, JwtAuthValidator, JwtSecret};
pub use reth_rpc_server_types::RethRpcModule;
use reth_storage_api::{
AccountReader, BlockReader, ChangeSetReader, FullRpcProvider, NodePrimitivesProvider,
StateProviderFactory,
@@ -76,7 +77,7 @@ use jsonrpsee::server::ServerConfigBuilder;
pub use reth_ipc::server::{
Builder as IpcServerBuilder, RpcServiceBuilder as IpcRpcServiceBuilder,
};
pub use reth_rpc_server_types::{constants, RethRpcModule, RpcModuleSelection};
pub use reth_rpc_server_types::{constants, RpcModuleSelection};
pub use tower::layer::util::{Identity, Stack};
/// Auth server utilities.
@@ -561,8 +562,8 @@ where
}
}
impl<Provider, Pool, Network, EthApi, BlockExecutor, Consensus>
RpcRegistryInner<Provider, Pool, Network, EthApi, BlockExecutor, Consensus>
impl<Provider, Pool, Network, EthApi, Evm, Consensus>
RpcRegistryInner<Provider, Pool, Network, EthApi, Evm, Consensus>
where
EthApi: EthApiTypes,
{
@@ -591,6 +592,11 @@ where
&self.provider
}
/// Returns a reference to the evm config
pub const fn evm_config(&self) -> &Evm {
&self.evm_config
}
/// Returns all installed methods
pub fn methods(&self) -> Vec<Methods> {
self.modules.values().cloned().collect()
@@ -992,18 +998,18 @@ where
.into_rpc()
.into()
}
// only relevant for Ethereum and configured in `EthereumAddOns`
// implementation
// TODO: can we get rid of this here?
// Custom modules are not handled here - they should be registered via
// extend_rpc_modules
RethRpcModule::Flashbots | RethRpcModule::Other(_) => Default::default(),
RethRpcModule::Miner => MinerApi::default().into_rpc().into(),
RethRpcModule::Mev => {
EthSimBundle::new(eth_api.clone(), self.blocking_pool_guard.clone())
.into_rpc()
.into()
}
// these are implementation specific and need to be handled during
// initialization and should be registered via extend_rpc_modules in the
// nodebuilder rpc addon stack
RethRpcModule::Flashbots |
RethRpcModule::Testing |
RethRpcModule::Other(_) => Default::default(),
})
.clone()
})

View File

@@ -420,6 +420,7 @@ impl<H: BlockHeader> BuildPendingEnv<H> for NextBlockEnvAttributes {
gas_limit: parent.gas_limit(),
parent_beacon_block_root: parent.parent_beacon_block_root(),
withdrawals: parent.withdrawals_root().map(|_| Default::default()),
extra_data: None,
}
}
}

View File

@@ -323,6 +323,8 @@ pub enum RethRpcModule {
Miner,
/// `mev_` module
Mev,
/// `testing_` module
Testing,
/// Custom RPC module not part of the standard set
#[strum(default)]
#[serde(untagged)]
@@ -347,6 +349,7 @@ impl RethRpcModule {
Self::Flashbots,
Self::Miner,
Self::Mev,
Self::Testing,
];
/// Returns the number of standard variants (excludes Other)
@@ -406,6 +409,7 @@ impl AsRef<str> for RethRpcModule {
Self::Flashbots => "flashbots",
Self::Miner => "miner",
Self::Mev => "mev",
Self::Testing => "testing",
}
}
}
@@ -428,6 +432,7 @@ impl FromStr for RethRpcModule {
"flashbots" => Self::Flashbots,
"miner" => Self::Miner,
"mev" => Self::Mev,
"testing" => Self::Testing,
// Any unknown module becomes Other
other => Self::Other(other.to_string()),
})

View File

@@ -38,6 +38,8 @@ reth-rpc-server-types.workspace = true
reth-network-types.workspace = true
reth-consensus.workspace = true
reth-consensus-common.workspace = true
reth-ethereum-primitives.workspace = true
reth-ethereum-engine-primitives.workspace = true
reth-node-api.workspace = true
reth-trie-common.workspace = true

View File

@@ -42,6 +42,7 @@ mod net;
mod otterscan;
mod reth;
mod rpc;
mod testing;
mod trace;
mod txpool;
mod validation;
@@ -58,6 +59,7 @@ pub use otterscan::OtterscanApi;
pub use reth::RethApi;
pub use reth_rpc_convert::RpcTypes;
pub use rpc::RPCApi;
pub use testing::TestingApi;
pub use trace::TraceApi;
pub use txpool::TxPoolApi;
pub use validation::{ValidationApi, ValidationApiConfig};

View File

@@ -0,0 +1,127 @@
//! Implementation of the `testing` namespace.
//!
//! This exposes `testing_buildBlockV1`, intended for non-production/debug use.
use alloy_consensus::{Header, Transaction};
use alloy_evm::Evm;
use alloy_primitives::U256;
use alloy_rpc_types_engine::ExecutionPayloadEnvelopeV5;
use async_trait::async_trait;
use jsonrpsee::core::RpcResult;
use reth_errors::RethError;
use reth_ethereum_engine_primitives::EthBuiltPayload;
use reth_ethereum_primitives::EthPrimitives;
use reth_evm::{execute::BlockBuilder, ConfigureEvm, NextBlockEnvAttributes};
use reth_primitives_traits::{AlloyBlockHeader as BlockTrait, Recovered, TxTy};
use reth_revm::{database::StateProviderDatabase, db::State};
use reth_rpc_api::{TestingApiServer, TestingBuildBlockRequestV1};
use reth_rpc_eth_api::{helpers::Call, FromEthApiError};
use reth_rpc_eth_types::{utils::recover_raw_transaction, EthApiError};
use reth_storage_api::{BlockReader, HeaderProvider};
use revm::context::Block;
use std::sync::Arc;
/// Testing API handler.
#[derive(Debug, Clone)]
pub struct TestingApi<Eth, Evm> {
eth_api: Eth,
evm_config: Evm,
}
impl<Eth, Evm> TestingApi<Eth, Evm> {
/// Create a new testing API handler.
pub const fn new(eth_api: Eth, evm_config: Evm) -> Self {
Self { eth_api, evm_config }
}
}
impl<Eth, Evm> TestingApi<Eth, Evm>
where
Eth: Call<Provider: BlockReader<Header = Header>>,
Evm: ConfigureEvm<NextBlockEnvCtx = NextBlockEnvAttributes, Primitives = EthPrimitives>
+ 'static,
{
async fn build_block_v1(
&self,
request: TestingBuildBlockRequestV1,
) -> Result<ExecutionPayloadEnvelopeV5, Eth::Error> {
let evm_config = self.evm_config.clone();
self.eth_api
.spawn_with_state_at_block(request.parent_block_hash, move |eth_api, state| {
let state = state.database.0;
let mut db = State::builder()
.with_bundle_update()
.with_database(StateProviderDatabase::new(&state))
.build();
let parent = eth_api
.provider()
.sealed_header_by_hash(request.parent_block_hash)?
.ok_or_else(|| {
EthApiError::HeaderNotFound(request.parent_block_hash.into())
})?;
let env_attrs = NextBlockEnvAttributes {
timestamp: request.payload_attributes.timestamp,
suggested_fee_recipient: request.payload_attributes.suggested_fee_recipient,
prev_randao: request.payload_attributes.prev_randao,
gas_limit: parent.gas_limit(),
parent_beacon_block_root: request.payload_attributes.parent_beacon_block_root,
withdrawals: request.payload_attributes.withdrawals.map(Into::into),
extra_data: request.extra_data,
};
let mut builder = evm_config
.builder_for_next_block(&mut db, &parent, env_attrs)
.map_err(RethError::other)
.map_err(Eth::Error::from_eth_err)?;
builder.apply_pre_execution_changes().map_err(Eth::Error::from_eth_err)?;
let mut total_fees = U256::ZERO;
let base_fee = builder.evm_mut().block().basefee();
for tx in request.transactions {
let tx: Recovered<TxTy<Evm::Primitives>> = recover_raw_transaction(&tx)?;
let tip = tx.effective_tip_per_gas(base_fee).unwrap_or_default();
let gas_used =
builder.execute_transaction(tx).map_err(Eth::Error::from_eth_err)?;
total_fees += U256::from(tip) * U256::from(gas_used);
}
let outcome = builder.finish(&state).map_err(Eth::Error::from_eth_err)?;
let requests = outcome
.block
.requests_hash()
.is_some()
.then_some(outcome.execution_result.requests);
EthBuiltPayload::new(
alloy_rpc_types_engine::PayloadId::default(),
Arc::new(outcome.block.into_sealed_block()),
total_fees,
requests,
)
.try_into_v5()
.map_err(RethError::other)
.map_err(Eth::Error::from_eth_err)
})
.await
}
}
#[async_trait]
impl<Eth, Evm> TestingApiServer for TestingApi<Eth, Evm>
where
Eth: Call<Provider: BlockReader<Header = Header>>,
Evm: ConfigureEvm<NextBlockEnvCtx = NextBlockEnvAttributes, Primitives = EthPrimitives>
+ 'static,
{
/// Handles `testing_buildBlockV1` by gating concurrency via a semaphore and offloading heavy
/// work to the blocking pool to avoid stalling the async runtime.
async fn build_block_v1(
&self,
request: TestingBuildBlockRequestV1,
) -> RpcResult<ExecutionPayloadEnvelopeV5> {
self.build_block_v1(request).await.map_err(Into::into)
}
}

View File

@@ -302,7 +302,7 @@ RPC:
--http.api <HTTP_API>
Rpc Modules to be configured for the HTTP server
[possible values: admin, debug, eth, net, trace, txpool, web3, rpc, reth, ots, flashbots, miner, mev]
[possible values: admin, debug, eth, net, trace, txpool, web3, rpc, reth, ots, flashbots, miner, mev, testing]
--http.corsdomain <HTTP_CORSDOMAIN>
Http Corsdomain to allow request from
@@ -326,7 +326,7 @@ RPC:
--ws.api <WS_API>
Rpc Modules to be configured for the WS server
[possible values: admin, debug, eth, net, trace, txpool, web3, rpc, reth, ots, flashbots, miner, mev]
[possible values: admin, debug, eth, net, trace, txpool, web3, rpc, reth, ots, flashbots, miner, mev, testing]
--ipcdisable
Disable the IPC-RPC server

View File

@@ -302,7 +302,7 @@ RPC:
--http.api <HTTP_API>
Rpc Modules to be configured for the HTTP server
[possible values: admin, debug, eth, net, trace, txpool, web3, rpc, reth, ots, flashbots, miner, mev]
[possible values: admin, debug, eth, net, trace, txpool, web3, rpc, reth, ots, flashbots, miner, mev, testing]
--http.corsdomain <HTTP_CORSDOMAIN>
Http Corsdomain to allow request from
@@ -326,7 +326,7 @@ RPC:
--ws.api <WS_API>
Rpc Modules to be configured for the WS server
[possible values: admin, debug, eth, net, trace, txpool, web3, rpc, reth, ots, flashbots, miner, mev]
[possible values: admin, debug, eth, net, trace, txpool, web3, rpc, reth, ots, flashbots, miner, mev, testing]
--ipcdisable
Disable the IPC-RPC server