diff --git a/Cargo.lock b/Cargo.lock index 11aef0d76..f5930aeb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2510,6 +2510,17 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "delegate" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297806318ef30ad066b15792a8372858020ae3ca2e414ee6c2133b1eb9e9e945" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "der" version = "0.7.9" @@ -6638,12 +6649,14 @@ dependencies = [ name = "reth-bench" version = "1.2.0" dependencies = [ + "alloy-consensus", "alloy-eips", "alloy-json-rpc", "alloy-primitives", "alloy-provider", "alloy-pubsub", "alloy-rpc-client", + "alloy-rpc-types", "alloy-rpc-types-engine", "alloy-transport", "alloy-transport-http", @@ -6652,17 +6665,21 @@ dependencies = [ "async-trait", "clap", "csv", + "delegate", "eyre", "futures", + "op-alloy-rpc-types", "reqwest", "reth-cli-runner", "reth-cli-util", + "reth-fs-util", "reth-node-api", "reth-node-core", "reth-primitives", "reth-primitives-traits", "reth-tracing", "serde", + "serde_json", "thiserror 2.0.11", "tokio", "tower 0.4.13", diff --git a/bin/reth-bench/Cargo.toml b/bin/reth-bench/Cargo.toml index 097a2541a..3fc7a8952 100644 --- a/bin/reth-bench/Cargo.toml +++ b/bin/reth-bench/Cargo.toml @@ -16,24 +16,28 @@ workspace = true # reth reth-cli-runner.workspace = true reth-cli-util.workspace = true -reth-node-core.workspace = true +reth-fs-util.workspace = true reth-node-api.workspace = true +reth-node-core.workspace = true reth-primitives = { workspace = true, features = ["alloy-compat"] } reth-primitives-traits.workspace = true reth-tracing.workspace = true # alloy -alloy-provider = { workspace = true, features = ["engine-api", "reqwest-rustls-tls"], default-features = false } -alloy-rpc-types-engine.workspace = true -alloy-transport.workspace = true -alloy-transport-http.workspace = true -alloy-transport-ws.workspace = true -alloy-transport-ipc.workspace = true -alloy-pubsub.workspace = true -alloy-json-rpc.workspace = true -alloy-rpc-client.workspace = true +alloy-consensus = { workspace = true } alloy-eips.workspace = true +alloy-json-rpc.workspace = true alloy-primitives.workspace = true +alloy-provider = { workspace = true, features = ["engine-api", "reqwest-rustls-tls"], default-features = false } +alloy-pubsub.workspace = true +alloy-rpc-client.workspace = true +alloy-rpc-types = { workspace = true } +alloy-rpc-types-engine = { workspace = true } +alloy-transport-http.workspace = true +alloy-transport-ipc.workspace = true +alloy-transport-ws.workspace = true +alloy-transport.workspace = true +op-alloy-rpc-types.workspace = true # reqwest reqwest = { workspace = true, default-features = false, features = ["rustls-tls-native-roots"] } @@ -44,18 +48,20 @@ tower.workspace = true # tracing tracing.workspace = true -# io +# serde serde.workspace = true +serde_json.workspace = true # async -tokio = { workspace = true, features = ["sync", "macros", "time", "rt-multi-thread"] } -futures.workspace = true async-trait.workspace = true +futures.workspace = true +tokio = { workspace = true, features = ["sync", "macros", "time", "rt-multi-thread"] } # misc +clap = { workspace = true, features = ["derive", "env"] } +delegate = "0.13" eyre.workspace = true thiserror.workspace = true -clap = { workspace = true, features = ["derive", "env"] } # for writing data csv = "1.3.0" diff --git a/bin/reth-bench/src/bench/mod.rs b/bin/reth-bench/src/bench/mod.rs index 076dbb4af..afc76b3b6 100644 --- a/bin/reth-bench/src/bench/mod.rs +++ b/bin/reth-bench/src/bench/mod.rs @@ -9,6 +9,7 @@ mod context; mod new_payload_fcu; mod new_payload_only; mod output; +mod send_payload; /// `reth bench` command #[derive(Debug, Parser)] @@ -28,6 +29,18 @@ pub enum Subcommands { /// Benchmark which only calls subsequent `newPayload` calls. NewPayloadOnly(new_payload_only::Command), + + /// Command for generating and sending an `engine_newPayload` request constructed from an RPC + /// block. + /// + /// This command takes a JSON block input (either from a file or stdin) and generates + /// an execution payload that can be used with the `engine_newPayloadV*` API. + /// + /// One powerful use case is pairing this command with the `cast block` command, for example: + /// + /// `cast block latest--full --json | reth-bench send-payload --rpc-url localhost:5000 + /// --jwt-secret $(cat ~/.local/share/reth/mainnet/jwt.hex)` + SendPayload(send_payload::Command), } impl BenchmarkCommand { @@ -39,6 +52,7 @@ impl BenchmarkCommand { match self.command { Subcommands::NewPayloadFcu(command) => command.execute(ctx).await, Subcommands::NewPayloadOnly(command) => command.execute(ctx).await, + Subcommands::SendPayload(command) => command.execute(ctx).await, } } diff --git a/bin/reth-bench/src/bench/send_payload.rs b/bin/reth-bench/src/bench/send_payload.rs new file mode 100644 index 000000000..e9e8f7b54 --- /dev/null +++ b/bin/reth-bench/src/bench/send_payload.rs @@ -0,0 +1,238 @@ +use alloy_consensus::{ + Block as PrimitiveBlock, BlockBody, Header as PrimitiveHeader, + Transaction as PrimitiveTransaction, +}; +use alloy_eips::{eip7702::SignedAuthorization, Encodable2718, Typed2718}; +use alloy_primitives::{bytes::BufMut, Bytes, ChainId, TxKind, B256, U256}; +use alloy_rpc_types::{ + AccessList, Block as RpcBlock, BlockTransactions, Transaction as EthRpcTransaction, +}; +use alloy_rpc_types_engine::ExecutionPayload; +use clap::Parser; +use delegate::delegate; +use eyre::{OptionExt, Result}; +use op_alloy_rpc_types::Transaction as OpRpcTransaction; +use reth_cli_runner::CliContext; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; + +/// Command for generating and sending an `engine_newPayload` request constructed from an RPC +/// block. +#[derive(Debug, Parser)] +pub struct Command { + /// Path to the json file to parse. If not specified, stdin will be used. + #[arg(short, long)] + path: Option, + + /// The engine RPC url to use. + #[arg( + short, + long, + // Required if `mode` is `execute` or `cast`. + required_if_eq_any([("mode", "execute"), ("mode", "cast")]), + // If `mode` is not specified, then `execute` is used, so we need to require it. + required_unless_present("mode") + )] + rpc_url: Option, + + /// The JWT secret to use. Can be either a path to a file containing the secret or the secret + /// itself. + #[arg(short, long)] + jwt_secret: Option, + + #[arg(long, default_value_t = 3)] + new_payload_version: u8, + + /// The mode to use. + #[arg(long, value_enum, default_value = "execute")] + mode: Mode, +} + +#[derive(Debug, Clone, clap::ValueEnum)] +enum Mode { + /// Execute the `cast` command. This works with blocks of any size, because it pipes the + /// payload into the `cast` command. + Execute, + /// Print the `cast` command. Caution: this may not work with large blocks because of the + /// command length limit. + Cast, + /// Print the JSON payload. Can be piped into `cast` command if the block is small enough. + Json, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +enum RpcTransaction { + Ethereum(EthRpcTransaction), + Optimism(OpRpcTransaction), +} + +impl Typed2718 for RpcTransaction { + delegate! { + to match self { + Self::Ethereum(tx) => tx, + Self::Optimism(tx) => tx, + } { + fn ty(&self) -> u8; + } + } +} + +impl PrimitiveTransaction for RpcTransaction { + delegate! { + to match self { + Self::Ethereum(tx) => tx, + Self::Optimism(tx) => tx, + } { + fn chain_id(&self) -> Option; + fn nonce(&self) -> u64; + fn gas_limit(&self) -> u64; + fn gas_price(&self) -> Option; + fn max_fee_per_gas(&self) -> u128; + fn max_priority_fee_per_gas(&self) -> Option; + fn max_fee_per_blob_gas(&self) -> Option; + fn priority_fee_or_price(&self) -> u128; + fn effective_gas_price(&self, base_fee: Option) -> u128; + fn is_dynamic_fee(&self) -> bool; + fn kind(&self) -> TxKind; + fn is_create(&self) -> bool; + fn value(&self) -> U256; + fn input(&self) -> &Bytes; + fn access_list(&self) -> Option<&AccessList>; + fn blob_versioned_hashes(&self) -> Option<&[B256]>; + fn authorization_list(&self) -> Option<&[SignedAuthorization]>; + } + } +} + +impl Encodable2718 for RpcTransaction { + delegate! { + to match self { + Self::Ethereum(tx) => tx.inner, + Self::Optimism(tx) => tx.inner.inner, + } { + fn encode_2718_len(&self) -> usize; + fn encode_2718(&self, out: &mut dyn BufMut); + } + } +} + +impl Command { + /// Read input from either a file or stdin + fn read_input(&self) -> Result { + Ok(match &self.path { + Some(path) => reth_fs_util::read_to_string(path)?, + None => String::from_utf8(std::io::stdin().bytes().collect::, _>>()?)?, + }) + } + + /// Load JWT secret from either a file or use the provided string directly + fn load_jwt_secret(&self) -> Result> { + match &self.jwt_secret { + Some(secret) => { + // Try to read as file first + match std::fs::read_to_string(secret) { + Ok(contents) => Ok(Some(contents.trim().to_string())), + // If file read fails, use the string directly + Err(_) => Ok(Some(secret.clone())), + } + } + None => Ok(None), + } + } + + /// Execute the generate payload command + pub async fn execute(self, _ctx: CliContext) -> Result<()> { + // Load block + let block_json = self.read_input()?; + + // Load JWT secret + let jwt_secret = self.load_jwt_secret()?; + + // Parse the block + let block: RpcBlock = serde_json::from_str(&block_json)?; + + // Extract parent beacon block root + let parent_beacon_block_root = block.header.parent_beacon_block_root; + + // Extract transactions + let transactions = match block.transactions { + BlockTransactions::Hashes(_) => { + return Err(eyre::eyre!("Block must include full transaction data. Send the eth_getBlockByHash request with full: `true`")); + } + BlockTransactions::Full(txs) => txs, + BlockTransactions::Uncle => { + return Err(eyre::eyre!("Cannot process uncle blocks")); + } + }; + + // Extract blob versioned hashes + let blob_versioned_hashes = transactions + .iter() + .filter_map(|tx| tx.blob_versioned_hashes().map(|v| v.to_vec())) + .flatten() + .collect::>(); + + // Convert to execution payload + let execution_payload = ExecutionPayload::from_block_slow(&PrimitiveBlock::new( + PrimitiveHeader::from(block.header), + BlockBody { transactions, ommers: vec![], withdrawals: block.withdrawals }, + )) + .0; + + // Create JSON request data + let json_request = serde_json::to_string(&( + execution_payload, + blob_versioned_hashes, + parent_beacon_block_root, + ))?; + + // Print output or execute command + match self.mode { + Mode::Execute => { + // Create cast command + let mut command = std::process::Command::new("cast"); + command.arg("rpc").arg("engine_newPayloadV3").arg("--raw"); + if let Some(rpc_url) = self.rpc_url { + command.arg("--rpc-url").arg(rpc_url); + } + if let Some(secret) = &jwt_secret { + command.arg("--jwt-secret").arg(secret); + } + + // Start cast process + let mut process = command.stdin(std::process::Stdio::piped()).spawn()?; + + // Write to cast's stdin + process + .stdin + .take() + .ok_or_eyre("stdin not available")? + .write_all(json_request.as_bytes())?; + + // Wait for cast to finish + process.wait()?; + } + Mode::Cast => { + let mut cmd = format!( + "cast rpc engine_newPayloadV{} --raw '{}'", + self.new_payload_version, json_request + ); + + if let Some(rpc_url) = self.rpc_url { + cmd += &format!(" --rpc-url {}", rpc_url); + } + if let Some(secret) = &jwt_secret { + cmd += &format!(" --jwt-secret {}", secret); + } + + println!("{cmd}"); + } + Mode::Json => { + println!("{json_request}"); + } + } + + Ok(()) + } +}