diff --git a/bin/reth-bench/Cargo.toml b/bin/reth-bench/Cargo.toml index 52bb9b036f..45ec0cb53d 100644 --- a/bin/reth-bench/Cargo.toml +++ b/bin/reth-bench/Cargo.toml @@ -32,7 +32,7 @@ alloy-eips.workspace = true alloy-json-rpc.workspace = true alloy-consensus.workspace = true alloy-network.workspace = true -alloy-primitives.workspace = true +alloy-primitives = { workspace = true, features = ["rand"] } alloy-provider = { workspace = true, features = ["engine-api", "pubsub", "reqwest-rustls-tls"], default-features = false } alloy-pubsub.workspace = true alloy-rpc-client = { workspace = true, features = ["pubsub"] } diff --git a/bin/reth-bench/src/bench/mod.rs b/bin/reth-bench/src/bench/mod.rs index fd1d0cccd3..5ccbc77546 100644 --- a/bin/reth-bench/src/bench/mod.rs +++ b/bin/reth-bench/src/bench/mod.rs @@ -16,6 +16,7 @@ mod new_payload_fcu; mod new_payload_only; mod output; mod replay_payloads; +mod send_invalid_payload; mod send_payload; /// `reth bench` command @@ -74,6 +75,18 @@ pub enum Subcommands { /// `reth-bench replay-payloads --payload-dir ./payloads --engine-rpc-url /// http://localhost:8551 --jwt-secret ~/.local/share/reth/mainnet/jwt.hex` ReplayPayloads(replay_payloads::Command), + + /// Generate and send an invalid `engine_newPayload` request for testing. + /// + /// Takes a valid block and modifies fields to make it invalid, allowing you to test + /// Engine API rejection behavior. Block hash is recalculated after modifications + /// unless `--invalid-block-hash` or `--skip-hash-recalc` is used. + /// + /// Example: + /// + /// `cast block latest --full --json | reth-bench send-invalid-payload --rpc-url localhost:5000 + /// --jwt-secret $(cat ~/.local/share/reth/mainnet/jwt.hex) --invalid-state-root` + SendInvalidPayload(Box), } impl BenchmarkCommand { @@ -89,6 +102,7 @@ impl BenchmarkCommand { Subcommands::SendPayload(command) => command.execute(ctx).await, Subcommands::GenerateBigBlock(command) => command.execute(ctx).await, Subcommands::ReplayPayloads(command) => command.execute(ctx).await, + Subcommands::SendInvalidPayload(command) => (*command).execute(ctx).await, } } diff --git a/bin/reth-bench/src/bench/send_invalid_payload/invalidation.rs b/bin/reth-bench/src/bench/send_invalid_payload/invalidation.rs new file mode 100644 index 0000000000..a9f1b60659 --- /dev/null +++ b/bin/reth-bench/src/bench/send_invalid_payload/invalidation.rs @@ -0,0 +1,219 @@ +use alloy_eips::eip4895::Withdrawal; +use alloy_primitives::{Address, Bloom, Bytes, B256, U256}; +use alloy_rpc_types_engine::{ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3}; + +/// Configuration for invalidating payload fields +#[derive(Debug, Default)] +pub(super) struct InvalidationConfig { + // Explicit value overrides (Option) + pub(super) parent_hash: Option, + pub(super) fee_recipient: Option
, + pub(super) state_root: Option, + pub(super) receipts_root: Option, + pub(super) logs_bloom: Option, + pub(super) prev_randao: Option, + pub(super) block_number: Option, + pub(super) gas_limit: Option, + pub(super) gas_used: Option, + pub(super) timestamp: Option, + pub(super) extra_data: Option, + pub(super) base_fee_per_gas: Option, + pub(super) block_hash: Option, + pub(super) blob_gas_used: Option, + pub(super) excess_blob_gas: Option, + + // Auto-invalidation flags + pub(super) invalidate_parent_hash: bool, + pub(super) invalidate_state_root: bool, + pub(super) invalidate_receipts_root: bool, + pub(super) invalidate_gas_used: bool, + pub(super) invalidate_block_number: bool, + pub(super) invalidate_timestamp: bool, + pub(super) invalidate_base_fee: bool, + pub(super) invalidate_transactions: bool, + pub(super) invalidate_block_hash: bool, + pub(super) invalidate_withdrawals: bool, + pub(super) invalidate_blob_gas_used: bool, + pub(super) invalidate_excess_blob_gas: bool, +} + +impl InvalidationConfig { + /// Returns true if `block_hash` is being explicitly set or auto-invalidated. + /// When true, the caller should skip recalculating the block hash since it will be overwritten. + pub(super) const fn should_skip_hash_recalc(&self) -> bool { + self.block_hash.is_some() || self.invalidate_block_hash + } + + /// Applies invalidations to a V1 payload, returns list of what was changed. + pub(super) fn apply_to_payload_v1(&self, payload: &mut ExecutionPayloadV1) -> Vec { + let mut changes = Vec::new(); + + // Explicit value overrides + if let Some(parent_hash) = self.parent_hash { + payload.parent_hash = parent_hash; + changes.push(format!("parent_hash = {parent_hash}")); + } + + if let Some(fee_recipient) = self.fee_recipient { + payload.fee_recipient = fee_recipient; + changes.push(format!("fee_recipient = {fee_recipient}")); + } + + if let Some(state_root) = self.state_root { + payload.state_root = state_root; + changes.push(format!("state_root = {state_root}")); + } + + if let Some(receipts_root) = self.receipts_root { + payload.receipts_root = receipts_root; + changes.push(format!("receipts_root = {receipts_root}")); + } + + if let Some(logs_bloom) = self.logs_bloom { + payload.logs_bloom = logs_bloom; + changes.push("logs_bloom = ".to_string()); + } + + if let Some(prev_randao) = self.prev_randao { + payload.prev_randao = prev_randao; + changes.push(format!("prev_randao = {prev_randao}")); + } + + if let Some(block_number) = self.block_number { + payload.block_number = block_number; + changes.push(format!("block_number = {block_number}")); + } + + if let Some(gas_limit) = self.gas_limit { + payload.gas_limit = gas_limit; + changes.push(format!("gas_limit = {gas_limit}")); + } + + if let Some(gas_used) = self.gas_used { + payload.gas_used = gas_used; + changes.push(format!("gas_used = {gas_used}")); + } + + if let Some(timestamp) = self.timestamp { + payload.timestamp = timestamp; + changes.push(format!("timestamp = {timestamp}")); + } + + if let Some(ref extra_data) = self.extra_data { + payload.extra_data = extra_data.clone(); + changes.push(format!("extra_data = {} bytes", extra_data.len())); + } + + if let Some(base_fee_per_gas) = self.base_fee_per_gas { + payload.base_fee_per_gas = U256::from_limbs([base_fee_per_gas, 0, 0, 0]); + changes.push(format!("base_fee_per_gas = {base_fee_per_gas}")); + } + + if let Some(block_hash) = self.block_hash { + payload.block_hash = block_hash; + changes.push(format!("block_hash = {block_hash}")); + } + + // Auto-invalidation flags + if self.invalidate_parent_hash { + let random_hash = B256::random(); + payload.parent_hash = random_hash; + changes.push(format!("parent_hash = {random_hash} (auto-invalidated: random)")); + } + + if self.invalidate_state_root { + payload.state_root = B256::ZERO; + changes.push("state_root = ZERO (auto-invalidated: empty trie root)".to_string()); + } + + if self.invalidate_receipts_root { + payload.receipts_root = B256::ZERO; + changes.push("receipts_root = ZERO (auto-invalidated)".to_string()); + } + + if self.invalidate_gas_used { + let invalid_gas = payload.gas_limit + 1; + payload.gas_used = invalid_gas; + changes.push(format!("gas_used = {invalid_gas} (auto-invalidated: exceeds gas_limit)")); + } + + if self.invalidate_block_number { + let invalid_number = payload.block_number + 999; + payload.block_number = invalid_number; + changes.push(format!("block_number = {invalid_number} (auto-invalidated: huge gap)")); + } + + if self.invalidate_timestamp { + payload.timestamp = 0; + changes.push("timestamp = 0 (auto-invalidated: impossibly old)".to_string()); + } + + if self.invalidate_base_fee { + payload.base_fee_per_gas = U256::ZERO; + changes + .push("base_fee_per_gas = 0 (auto-invalidated: invalid post-London)".to_string()); + } + + if self.invalidate_transactions { + let invalid_tx = Bytes::from_static(&[0xff, 0xff, 0xff]); + payload.transactions.insert(0, invalid_tx); + changes.push("transactions = prepended invalid RLP (auto-invalidated)".to_string()); + } + + if self.invalidate_block_hash { + let random_hash = B256::random(); + payload.block_hash = random_hash; + changes.push(format!("block_hash = {random_hash} (auto-invalidated: random)")); + } + + changes + } + + /// Applies invalidations to a V2 payload, returns list of what was changed. + pub(super) fn apply_to_payload_v2(&self, payload: &mut ExecutionPayloadV2) -> Vec { + let mut changes = self.apply_to_payload_v1(&mut payload.payload_inner); + + // Handle withdrawals invalidation (V2+) + if self.invalidate_withdrawals { + let fake_withdrawal = Withdrawal { + index: u64::MAX, + validator_index: u64::MAX, + address: Address::ZERO, + amount: u64::MAX, + }; + payload.withdrawals.push(fake_withdrawal); + changes.push("withdrawals = added fake withdrawal (auto-invalidated)".to_string()); + } + + changes + } + + /// Applies invalidations to a V3 payload, returns list of what was changed. + pub(super) fn apply_to_payload_v3(&self, payload: &mut ExecutionPayloadV3) -> Vec { + let mut changes = self.apply_to_payload_v2(&mut payload.payload_inner); + + // Explicit overrides for V3 fields + if let Some(blob_gas_used) = self.blob_gas_used { + payload.blob_gas_used = blob_gas_used; + changes.push(format!("blob_gas_used = {blob_gas_used}")); + } + + if let Some(excess_blob_gas) = self.excess_blob_gas { + payload.excess_blob_gas = excess_blob_gas; + changes.push(format!("excess_blob_gas = {excess_blob_gas}")); + } + + // Auto-invalidation for V3 fields + if self.invalidate_blob_gas_used { + payload.blob_gas_used = u64::MAX; + changes.push("blob_gas_used = MAX (auto-invalidated)".to_string()); + } + + if self.invalidate_excess_blob_gas { + payload.excess_blob_gas = u64::MAX; + changes.push("excess_blob_gas = MAX (auto-invalidated)".to_string()); + } + + changes + } +} diff --git a/bin/reth-bench/src/bench/send_invalid_payload/mod.rs b/bin/reth-bench/src/bench/send_invalid_payload/mod.rs new file mode 100644 index 0000000000..3fb2d9a71c --- /dev/null +++ b/bin/reth-bench/src/bench/send_invalid_payload/mod.rs @@ -0,0 +1,367 @@ +//! Command for sending invalid payloads to test Engine API rejection. + +mod invalidation; +use invalidation::InvalidationConfig; + +use alloy_primitives::{Address, B256}; +use alloy_provider::network::AnyRpcBlock; +use alloy_rpc_types_engine::ExecutionPayload; +use clap::Parser; +use eyre::{OptionExt, Result}; +use op_alloy_consensus::OpTxEnvelope; +use reth_cli_runner::CliContext; +use std::io::{BufReader, Read, Write}; + +/// Command for generating and sending an invalid `engine_newPayload` request. +/// +/// Takes a valid block and modifies fields to make it invalid for testing +/// Engine API rejection behavior. Block hash is recalculated after modifications +/// unless `--invalidate-block-hash` or `--skip-hash-recalc` is used. +#[derive(Debug, Parser)] +pub struct Command { + // ==================== Input Options ==================== + /// Path to the JSON file containing the block. If not specified, stdin will be used. + #[arg(short, long, help_heading = "Input Options")] + path: Option, + + /// The engine RPC URL to use. + #[arg( + short, + long, + help_heading = "Input Options", + required_if_eq_any([("mode", "execute"), ("mode", "cast")]), + 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, help_heading = "Input Options")] + jwt_secret: Option, + + /// The newPayload version to use (3 or 4). + #[arg(long, default_value_t = 3, help_heading = "Input Options")] + new_payload_version: u8, + + /// The output mode to use. + #[arg(long, value_enum, default_value = "execute", help_heading = "Input Options")] + mode: Mode, + + // ==================== Explicit Value Overrides ==================== + /// Override the parent hash with a specific value. + #[arg(long, value_name = "HASH", help_heading = "Explicit Value Overrides")] + parent_hash: Option, + + /// Override the fee recipient (coinbase) with a specific address. + #[arg(long, value_name = "ADDR", help_heading = "Explicit Value Overrides")] + fee_recipient: Option
, + + /// Override the state root with a specific value. + #[arg(long, value_name = "HASH", help_heading = "Explicit Value Overrides")] + state_root: Option, + + /// Override the receipts root with a specific value. + #[arg(long, value_name = "HASH", help_heading = "Explicit Value Overrides")] + receipts_root: Option, + + /// Override the block number with a specific value. + #[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")] + block_number: Option, + + /// Override the gas limit with a specific value. + #[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")] + gas_limit: Option, + + /// Override the gas used with a specific value. + #[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")] + gas_used: Option, + + /// Override the timestamp with a specific value. + #[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")] + timestamp: Option, + + /// Override the base fee per gas with a specific value. + #[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")] + base_fee_per_gas: Option, + + /// Override the block hash with a specific value (skips hash recalculation). + #[arg(long, value_name = "HASH", help_heading = "Explicit Value Overrides")] + block_hash: Option, + + /// Override the blob gas used with a specific value. + #[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")] + blob_gas_used: Option, + + /// Override the excess blob gas with a specific value. + #[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")] + excess_blob_gas: Option, + + /// Override the parent beacon block root with a specific value. + #[arg(long, value_name = "HASH", help_heading = "Explicit Value Overrides")] + parent_beacon_block_root: Option, + + /// Override the requests hash with a specific value (EIP-7685). + #[arg(long, value_name = "HASH", help_heading = "Explicit Value Overrides")] + requests_hash: Option, + + // ==================== Auto-Invalidation Flags ==================== + /// Invalidate the parent hash by setting it to a random value. + #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")] + invalidate_parent_hash: bool, + + /// Invalidate the state root by setting it to a random value. + #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")] + invalidate_state_root: bool, + + /// Invalidate the receipts root by setting it to a random value. + #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")] + invalidate_receipts_root: bool, + + /// Invalidate the gas used by setting it to an incorrect value. + #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")] + invalidate_gas_used: bool, + + /// Invalidate the block number by setting it to an incorrect value. + #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")] + invalidate_block_number: bool, + + /// Invalidate the timestamp by setting it to an incorrect value. + #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")] + invalidate_timestamp: bool, + + /// Invalidate the base fee by setting it to an incorrect value. + #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")] + invalidate_base_fee: bool, + + /// Invalidate the transactions by modifying them. + #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")] + invalidate_transactions: bool, + + /// Invalidate the block hash by not recalculating it after modifications. + #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")] + invalidate_block_hash: bool, + + /// Invalidate the withdrawals by modifying them. + #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")] + invalidate_withdrawals: bool, + + /// Invalidate the blob gas used by setting it to an incorrect value. + #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")] + invalidate_blob_gas_used: bool, + + /// Invalidate the excess blob gas by setting it to an incorrect value. + #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")] + invalidate_excess_blob_gas: bool, + + /// Invalidate the requests hash by setting it to a random value (EIP-7685). + #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")] + invalidate_requests_hash: bool, + + // ==================== Meta Flags ==================== + /// Skip block hash recalculation after modifications. + #[arg(long, default_value_t = false, help_heading = "Meta Flags")] + skip_hash_recalc: bool, + + /// Print what would be done without actually sending the payload. + #[arg(long, default_value_t = false, help_heading = "Meta Flags")] + dry_run: bool, +} + +#[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, +} + +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( + BufReader::new(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) => match std::fs::read_to_string(secret) { + Ok(contents) => Ok(Some(contents.trim().to_string())), + Err(_) => Ok(Some(secret.clone())), + }, + None => Ok(None), + } + } + + /// Build `InvalidationConfig` from command flags + const fn build_invalidation_config(&self) -> InvalidationConfig { + InvalidationConfig { + parent_hash: self.parent_hash, + fee_recipient: self.fee_recipient, + state_root: self.state_root, + receipts_root: self.receipts_root, + logs_bloom: None, + prev_randao: None, + block_number: self.block_number, + gas_limit: self.gas_limit, + gas_used: self.gas_used, + timestamp: self.timestamp, + extra_data: None, + base_fee_per_gas: self.base_fee_per_gas, + block_hash: self.block_hash, + blob_gas_used: self.blob_gas_used, + excess_blob_gas: self.excess_blob_gas, + invalidate_parent_hash: self.invalidate_parent_hash, + invalidate_state_root: self.invalidate_state_root, + invalidate_receipts_root: self.invalidate_receipts_root, + invalidate_gas_used: self.invalidate_gas_used, + invalidate_block_number: self.invalidate_block_number, + invalidate_timestamp: self.invalidate_timestamp, + invalidate_base_fee: self.invalidate_base_fee, + invalidate_transactions: self.invalidate_transactions, + invalidate_block_hash: self.invalidate_block_hash, + invalidate_withdrawals: self.invalidate_withdrawals, + invalidate_blob_gas_used: self.invalidate_blob_gas_used, + invalidate_excess_blob_gas: self.invalidate_excess_blob_gas, + } + } + + /// Execute the command + pub async fn execute(self, _ctx: CliContext) -> Result<()> { + let block_json = self.read_input()?; + let jwt_secret = self.load_jwt_secret()?; + + let block = serde_json::from_str::(&block_json)? + .into_inner() + .map_header(|header| header.map(|h| h.into_header_with_defaults())) + .try_map_transactions(|tx| tx.try_into_either::())? + .into_consensus(); + + let config = self.build_invalidation_config(); + + let parent_beacon_block_root = + self.parent_beacon_block_root.or(block.header.parent_beacon_block_root); + let blob_versioned_hashes = + block.body.blob_versioned_hashes_iter().copied().collect::>(); + let use_v4 = block.header.requests_hash.is_some(); + let requests_hash = self.requests_hash.or(block.header.requests_hash); + + let mut execution_payload = ExecutionPayload::from_block_slow(&block).0; + + let changes = match &mut execution_payload { + ExecutionPayload::V1(p) => config.apply_to_payload_v1(p), + ExecutionPayload::V2(p) => config.apply_to_payload_v2(p), + ExecutionPayload::V3(p) => config.apply_to_payload_v3(p), + }; + + let skip_recalc = self.skip_hash_recalc || config.should_skip_hash_recalc(); + if !skip_recalc { + let new_hash = match execution_payload.clone().into_block_raw() { + Ok(block) => block.header.hash_slow(), + Err(e) => { + eprintln!( + "Warning: Could not recalculate block hash: {e}. Using original hash." + ); + match &execution_payload { + ExecutionPayload::V1(p) => p.block_hash, + ExecutionPayload::V2(p) => p.payload_inner.block_hash, + ExecutionPayload::V3(p) => p.payload_inner.payload_inner.block_hash, + } + } + }; + + match &mut execution_payload { + ExecutionPayload::V1(p) => p.block_hash = new_hash, + ExecutionPayload::V2(p) => p.payload_inner.block_hash = new_hash, + ExecutionPayload::V3(p) => p.payload_inner.payload_inner.block_hash = new_hash, + } + } + + if self.dry_run { + println!("=== Dry Run ==="); + println!("Changes that would be applied:"); + for change in &changes { + println!(" - {}", change); + } + if changes.is_empty() { + println!(" (no changes)"); + } + if skip_recalc { + println!(" - Block hash recalculation: SKIPPED"); + } else { + println!(" - Block hash recalculation: PERFORMED"); + } + println!("\nResulting payload JSON:"); + let json = serde_json::to_string_pretty(&execution_payload)?; + println!("{}", json); + return Ok(()); + } + + let json_request = if use_v4 { + serde_json::to_string(&( + execution_payload, + blob_versioned_hashes, + parent_beacon_block_root, + requests_hash.unwrap_or_default(), + ))? + } else { + serde_json::to_string(&( + execution_payload, + blob_versioned_hashes, + parent_beacon_block_root, + ))? + }; + + match self.mode { + Mode::Execute => { + let mut command = std::process::Command::new("cast"); + let method = if use_v4 { "engine_newPayloadV4" } else { "engine_newPayloadV3" }; + command.arg("rpc").arg(method).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); + } + + let mut process = command.stdin(std::process::Stdio::piped()).spawn()?; + + process + .stdin + .take() + .ok_or_eyre("stdin not available")? + .write_all(json_request.as_bytes())?; + + 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(()) + } +}