Skip to main content

reth_bench/bench/send_invalid_payload/
mod.rs

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