reth_bench/bench/send_invalid_payload/
mod.rs

1//! Command for sending invalid payloads to test Engine API rejection.
2
3mod invalidation;
4use invalidation::InvalidationConfig;
5
6use alloy_primitives::{Address, B256};
7use alloy_provider::network::AnyRpcBlock;
8use alloy_rpc_types_engine::ExecutionPayload;
9use clap::Parser;
10use eyre::{OptionExt, Result};
11use op_alloy_consensus::OpTxEnvelope;
12use reth_cli_runner::CliContext;
13use std::io::{BufReader, Read, Write};
14
15/// Command for generating and sending an invalid `engine_newPayload` request.
16///
17/// Takes a valid block and modifies fields to make it invalid for testing
18/// Engine API rejection behavior. Block hash is recalculated after modifications
19/// unless `--invalidate-block-hash` or `--skip-hash-recalc` is used.
20#[derive(Debug, Parser)]
21pub struct Command {
22    // ==================== Input Options ====================
23    /// Path to the JSON file containing the block. If not specified, stdin will be used.
24    #[arg(short, long, help_heading = "Input Options")]
25    path: Option<String>,
26
27    /// The engine RPC URL to use.
28    #[arg(
29        short,
30        long,
31        help_heading = "Input Options",
32        required_if_eq_any([("mode", "execute"), ("mode", "cast")]),
33        required_unless_present("mode")
34    )]
35    rpc_url: Option<String>,
36
37    /// The JWT secret to use. Can be either a path to a file containing the secret or the secret
38    /// itself.
39    #[arg(short, long, help_heading = "Input Options")]
40    jwt_secret: Option<String>,
41
42    /// The newPayload version to use (3 or 4).
43    #[arg(long, default_value_t = 3, help_heading = "Input Options")]
44    new_payload_version: u8,
45
46    /// The output mode to use.
47    #[arg(long, value_enum, default_value = "execute", help_heading = "Input Options")]
48    mode: Mode,
49
50    // ==================== Explicit Value Overrides ====================
51    /// Override the parent hash with a specific value.
52    #[arg(long, value_name = "HASH", help_heading = "Explicit Value Overrides")]
53    parent_hash: Option<B256>,
54
55    /// Override the fee recipient (coinbase) with a specific address.
56    #[arg(long, value_name = "ADDR", help_heading = "Explicit Value Overrides")]
57    fee_recipient: Option<Address>,
58
59    /// Override the state root with a specific value.
60    #[arg(long, value_name = "HASH", help_heading = "Explicit Value Overrides")]
61    state_root: Option<B256>,
62
63    /// Override the receipts root with a specific value.
64    #[arg(long, value_name = "HASH", help_heading = "Explicit Value Overrides")]
65    receipts_root: Option<B256>,
66
67    /// Override the block number with a specific value.
68    #[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")]
69    block_number: Option<u64>,
70
71    /// Override the gas limit with a specific value.
72    #[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")]
73    gas_limit: Option<u64>,
74
75    /// Override the gas used with a specific value.
76    #[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")]
77    gas_used: Option<u64>,
78
79    /// Override the timestamp with a specific value.
80    #[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")]
81    timestamp: Option<u64>,
82
83    /// Override the base fee per gas with a specific value.
84    #[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")]
85    base_fee_per_gas: Option<u64>,
86
87    /// Override the block hash with a specific value (skips hash recalculation).
88    #[arg(long, value_name = "HASH", help_heading = "Explicit Value Overrides")]
89    block_hash: Option<B256>,
90
91    /// Override the blob gas used with a specific value.
92    #[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")]
93    blob_gas_used: Option<u64>,
94
95    /// Override the excess blob gas with a specific value.
96    #[arg(long, value_name = "U64", help_heading = "Explicit Value Overrides")]
97    excess_blob_gas: Option<u64>,
98
99    /// Override the parent beacon block root with a specific value.
100    #[arg(long, value_name = "HASH", help_heading = "Explicit Value Overrides")]
101    parent_beacon_block_root: Option<B256>,
102
103    /// Override the requests hash with a specific value (EIP-7685).
104    #[arg(long, value_name = "HASH", help_heading = "Explicit Value Overrides")]
105    requests_hash: Option<B256>,
106
107    // ==================== Auto-Invalidation Flags ====================
108    /// Invalidate the parent hash by setting it to a random value.
109    #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
110    invalidate_parent_hash: bool,
111
112    /// Invalidate the state root by setting it to a random value.
113    #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
114    invalidate_state_root: bool,
115
116    /// Invalidate the receipts root by setting it to a random value.
117    #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
118    invalidate_receipts_root: bool,
119
120    /// Invalidate the gas used by setting it to an incorrect value.
121    #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
122    invalidate_gas_used: bool,
123
124    /// Invalidate the block number by setting it to an incorrect value.
125    #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
126    invalidate_block_number: bool,
127
128    /// Invalidate the timestamp by setting it to an incorrect value.
129    #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
130    invalidate_timestamp: bool,
131
132    /// Invalidate the base fee by setting it to an incorrect value.
133    #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
134    invalidate_base_fee: bool,
135
136    /// Invalidate the transactions by modifying them.
137    #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
138    invalidate_transactions: bool,
139
140    /// Invalidate the block hash by not recalculating it after modifications.
141    #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
142    invalidate_block_hash: bool,
143
144    /// Invalidate the withdrawals by modifying them.
145    #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
146    invalidate_withdrawals: bool,
147
148    /// Invalidate the blob gas used by setting it to an incorrect value.
149    #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
150    invalidate_blob_gas_used: bool,
151
152    /// Invalidate the excess blob gas by setting it to an incorrect value.
153    #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
154    invalidate_excess_blob_gas: bool,
155
156    /// Invalidate the requests hash by setting it to a random value (EIP-7685).
157    #[arg(long, default_value_t = false, help_heading = "Auto-Invalidation Flags")]
158    invalidate_requests_hash: bool,
159
160    // ==================== Meta Flags ====================
161    /// Skip block hash recalculation after modifications.
162    #[arg(long, default_value_t = false, help_heading = "Meta Flags")]
163    skip_hash_recalc: bool,
164
165    /// Print what would be done without actually sending the payload.
166    #[arg(long, default_value_t = false, help_heading = "Meta Flags")]
167    dry_run: bool,
168}
169
170#[derive(Debug, Clone, clap::ValueEnum)]
171enum Mode {
172    /// Execute the `cast` command. This works with blocks of any size, because it pipes the
173    /// payload into the `cast` command.
174    Execute,
175    /// Print the `cast` command. Caution: this may not work with large blocks because of the
176    /// command length limit.
177    Cast,
178    /// Print the JSON payload. Can be piped into `cast` command if the block is small enough.
179    Json,
180}
181
182impl Command {
183    /// Read input from either a file or stdin
184    fn read_input(&self) -> Result<String> {
185        Ok(match &self.path {
186            Some(path) => reth_fs_util::read_to_string(path)?,
187            None => String::from_utf8(
188                BufReader::new(std::io::stdin()).bytes().collect::<Result<Vec<_>, _>>()?,
189            )?,
190        })
191    }
192
193    /// Load JWT secret from either a file or use the provided string directly
194    fn load_jwt_secret(&self) -> Result<Option<String>> {
195        match &self.jwt_secret {
196            Some(secret) => match std::fs::read_to_string(secret) {
197                Ok(contents) => Ok(Some(contents.trim().to_string())),
198                Err(_) => Ok(Some(secret.clone())),
199            },
200            None => Ok(None),
201        }
202    }
203
204    /// Build `InvalidationConfig` from command flags
205    const fn build_invalidation_config(&self) -> InvalidationConfig {
206        InvalidationConfig {
207            parent_hash: self.parent_hash,
208            fee_recipient: self.fee_recipient,
209            state_root: self.state_root,
210            receipts_root: self.receipts_root,
211            logs_bloom: None,
212            prev_randao: None,
213            block_number: self.block_number,
214            gas_limit: self.gas_limit,
215            gas_used: self.gas_used,
216            timestamp: self.timestamp,
217            extra_data: None,
218            base_fee_per_gas: self.base_fee_per_gas,
219            block_hash: self.block_hash,
220            blob_gas_used: self.blob_gas_used,
221            excess_blob_gas: self.excess_blob_gas,
222            invalidate_parent_hash: self.invalidate_parent_hash,
223            invalidate_state_root: self.invalidate_state_root,
224            invalidate_receipts_root: self.invalidate_receipts_root,
225            invalidate_gas_used: self.invalidate_gas_used,
226            invalidate_block_number: self.invalidate_block_number,
227            invalidate_timestamp: self.invalidate_timestamp,
228            invalidate_base_fee: self.invalidate_base_fee,
229            invalidate_transactions: self.invalidate_transactions,
230            invalidate_block_hash: self.invalidate_block_hash,
231            invalidate_withdrawals: self.invalidate_withdrawals,
232            invalidate_blob_gas_used: self.invalidate_blob_gas_used,
233            invalidate_excess_blob_gas: self.invalidate_excess_blob_gas,
234        }
235    }
236
237    /// Execute the command
238    pub async fn execute(self, _ctx: CliContext) -> Result<()> {
239        let block_json = self.read_input()?;
240        let jwt_secret = self.load_jwt_secret()?;
241
242        let block = serde_json::from_str::<AnyRpcBlock>(&block_json)?
243            .into_inner()
244            .map_header(|header| header.map(|h| h.into_header_with_defaults()))
245            .try_map_transactions(|tx| tx.try_into_either::<OpTxEnvelope>())?
246            .into_consensus();
247
248        let config = self.build_invalidation_config();
249
250        let parent_beacon_block_root =
251            self.parent_beacon_block_root.or(block.header.parent_beacon_block_root);
252        let blob_versioned_hashes =
253            block.body.blob_versioned_hashes_iter().copied().collect::<Vec<_>>();
254        let use_v4 = block.header.requests_hash.is_some();
255        let requests_hash = self.requests_hash.or(block.header.requests_hash);
256
257        let mut execution_payload = ExecutionPayload::from_block_slow(&block).0;
258
259        let changes = match &mut execution_payload {
260            ExecutionPayload::V1(p) => config.apply_to_payload_v1(p),
261            ExecutionPayload::V2(p) => config.apply_to_payload_v2(p),
262            ExecutionPayload::V3(p) => config.apply_to_payload_v3(p),
263        };
264
265        let skip_recalc = self.skip_hash_recalc || config.should_skip_hash_recalc();
266        if !skip_recalc {
267            let new_hash = match execution_payload.clone().into_block_raw() {
268                Ok(block) => block.header.hash_slow(),
269                Err(e) => {
270                    eprintln!(
271                        "Warning: Could not recalculate block hash: {e}. Using original hash."
272                    );
273                    match &execution_payload {
274                        ExecutionPayload::V1(p) => p.block_hash,
275                        ExecutionPayload::V2(p) => p.payload_inner.block_hash,
276                        ExecutionPayload::V3(p) => p.payload_inner.payload_inner.block_hash,
277                    }
278                }
279            };
280
281            match &mut execution_payload {
282                ExecutionPayload::V1(p) => p.block_hash = new_hash,
283                ExecutionPayload::V2(p) => p.payload_inner.block_hash = new_hash,
284                ExecutionPayload::V3(p) => p.payload_inner.payload_inner.block_hash = new_hash,
285            }
286        }
287
288        if self.dry_run {
289            println!("=== Dry Run ===");
290            println!("Changes that would be applied:");
291            for change in &changes {
292                println!("  - {}", change);
293            }
294            if changes.is_empty() {
295                println!("  (no changes)");
296            }
297            if skip_recalc {
298                println!("  - Block hash recalculation: SKIPPED");
299            } else {
300                println!("  - Block hash recalculation: PERFORMED");
301            }
302            println!("\nResulting payload JSON:");
303            let json = serde_json::to_string_pretty(&execution_payload)?;
304            println!("{}", json);
305            return Ok(());
306        }
307
308        let json_request = if use_v4 {
309            serde_json::to_string(&(
310                execution_payload,
311                blob_versioned_hashes,
312                parent_beacon_block_root,
313                requests_hash.unwrap_or_default(),
314            ))?
315        } else {
316            serde_json::to_string(&(
317                execution_payload,
318                blob_versioned_hashes,
319                parent_beacon_block_root,
320            ))?
321        };
322
323        match self.mode {
324            Mode::Execute => {
325                let mut command = std::process::Command::new("cast");
326                let method = if use_v4 { "engine_newPayloadV4" } else { "engine_newPayloadV3" };
327                command.arg("rpc").arg(method).arg("--raw");
328                if let Some(rpc_url) = self.rpc_url {
329                    command.arg("--rpc-url").arg(rpc_url);
330                }
331                if let Some(secret) = &jwt_secret {
332                    command.arg("--jwt-secret").arg(secret);
333                }
334
335                let mut process = command.stdin(std::process::Stdio::piped()).spawn()?;
336
337                process
338                    .stdin
339                    .take()
340                    .ok_or_eyre("stdin not available")?
341                    .write_all(json_request.as_bytes())?;
342
343                process.wait()?;
344            }
345            Mode::Cast => {
346                let mut cmd = format!(
347                    "cast rpc engine_newPayloadV{} --raw '{}'",
348                    self.new_payload_version, json_request
349                );
350
351                if let Some(rpc_url) = self.rpc_url {
352                    cmd += &format!(" --rpc-url {rpc_url}");
353                }
354                if let Some(secret) = &jwt_secret {
355                    cmd += &format!(" --jwt-secret {secret}");
356                }
357
358                println!("{cmd}");
359            }
360            Mode::Json => {
361                println!("{json_request}");
362            }
363        }
364
365        Ok(())
366    }
367}