Skip to main content

reth_rpc/eth/
sim_bundle.rs

1//! `Eth` Sim bundle implementation and helpers.
2
3use crate::eth::bundle::{BundleBlockGas, EthBundleError};
4use alloy_consensus::{transaction::TxHashRef, BlockHeader, Transaction as _};
5use alloy_primitives::U256;
6use alloy_rpc_types_eth::{BlockId, Log};
7use alloy_rpc_types_mev::{
8    BundleItem, Inclusion, MevSendBundle, Privacy, RefundConfig, SimBundleLogs, SimBundleOverrides,
9    SimBundleResponse, Validity,
10};
11use evm2::evm::DynDatabase;
12use jsonrpsee::core::RpcResult;
13use reth_errors::RethError;
14use reth_evm::{ConfigureEvm, Evm, EvmEnv};
15use reth_primitives_traits::Recovered;
16use reth_rpc_api::MevSimApiServer;
17use reth_rpc_eth_api::{
18    helpers::{block::LoadBlock, Call, EthTransactions},
19    FromEthApiError, FromEvmError,
20};
21use reth_rpc_eth_types::{
22    cache::db::apply_block_overrides, EthApiError, RpcInvalidTransactionError,
23};
24use reth_storage_api::ProviderTx;
25use reth_tasks::{cancel::is_cancelled, pool::BlockingTaskGuard};
26use reth_transaction_pool::{PoolPooledTx, PoolTransaction, TransactionPool};
27use std::{sync::Arc, time::Duration};
28use tracing::trace;
29
30/// Maximum bundle depth
31const MAX_NESTED_BUNDLE_DEPTH: usize = 5;
32
33/// Maximum body size
34const MAX_BUNDLE_BODY_SIZE: usize = 50;
35
36/// Default simulation timeout
37pub(crate) const DEFAULT_SIM_TIMEOUT: Duration = Duration::from_secs(5);
38
39/// Maximum simulation timeout
40pub(crate) const MAX_SIM_TIMEOUT: Duration = Duration::from_secs(30);
41
42/// Maximum payout cost
43const SBUNDLE_PAYOUT_MAX_COST: u64 = 30_000;
44
45/// A flattened representation of a bundle item containing transaction and associated metadata.
46#[derive(Clone, Debug)]
47pub struct FlattenedBundleItem<T> {
48    /// The signed transaction
49    pub tx: Recovered<T>,
50    /// Whether the transaction is allowed to revert
51    pub can_revert: bool,
52    /// Item-level inclusion constraints
53    pub inclusion: Inclusion,
54    /// Optional validity constraints for the bundle item
55    pub validity: Option<Validity>,
56    /// Optional privacy settings for the bundle item
57    pub privacy: Option<Privacy>,
58    /// Optional refund percent for the bundle item
59    pub refund_percent: Option<u64>,
60    /// Optional refund configs for the bundle item
61    pub refund_configs: Option<Vec<RefundConfig>>,
62}
63
64/// `Eth` sim bundle implementation.
65pub struct EthSimBundle<Eth> {
66    /// All nested fields bundled together.
67    inner: Arc<EthSimBundleInner<Eth>>,
68}
69
70impl<Eth> EthSimBundle<Eth> {
71    /// Create a new `EthSimBundle` instance.
72    pub fn new(eth_api: Eth, blocking_task_guard: BlockingTaskGuard) -> Self {
73        Self { inner: Arc::new(EthSimBundleInner { eth_api, blocking_task_guard }) }
74    }
75
76    /// Access the underlying `Eth` API.
77    pub fn eth_api(&self) -> &Eth {
78        &self.inner.eth_api
79    }
80
81    /// Builds a hierarchical `SimBundleLogs` structure from flattened transaction logs.
82    fn build_bundle_logs(
83        bundle: &MevSendBundle,
84        flat_logs: &[Vec<Log>],
85    ) -> Result<Vec<SimBundleLogs>, EthApiError> {
86        struct BundleFrame<'a> {
87            bundle: &'a MevSendBundle,
88            next_idx: usize,
89            logs: Vec<SimBundleLogs>,
90        }
91
92        let mut stack = vec![BundleFrame { bundle, next_idx: 0, logs: Vec::new() }];
93        let mut flat_log_idx = 0;
94        let mut root_logs = None;
95
96        while let Some(mut frame) = stack.pop() {
97            if frame.next_idx == frame.bundle.bundle_body.len() {
98                if let Some(parent) = stack.last_mut() {
99                    parent
100                        .logs
101                        .push(SimBundleLogs { tx_logs: None, bundle_logs: Some(frame.logs) });
102                } else {
103                    root_logs = Some(frame.logs);
104                }
105
106                continue;
107            }
108
109            match &frame.bundle.bundle_body[frame.next_idx] {
110                BundleItem::Tx { .. } => {
111                    let tx_logs = flat_logs.get(flat_log_idx).cloned().ok_or_else(|| {
112                        EthApiError::InvalidParams(EthSimBundleError::UnmatchedBundle.to_string())
113                    })?;
114
115                    frame.logs.push(SimBundleLogs { tx_logs: Some(tx_logs), bundle_logs: None });
116                    frame.next_idx += 1;
117                    flat_log_idx += 1;
118                    stack.push(frame);
119                }
120                BundleItem::Bundle { bundle } => {
121                    frame.next_idx += 1;
122                    stack.push(frame);
123                    stack.push(BundleFrame { bundle, next_idx: 0, logs: Vec::new() });
124                }
125                BundleItem::Hash { .. } => {
126                    return Err(EthApiError::InvalidParams(
127                        EthSimBundleError::InvalidBundle.to_string(),
128                    ));
129                }
130            }
131        }
132
133        if flat_log_idx != flat_logs.len() {
134            return Err(EthApiError::InvalidParams(EthSimBundleError::UnmatchedBundle.to_string()));
135        }
136
137        root_logs.ok_or_else(|| {
138            EthApiError::InvalidParams(EthSimBundleError::UnmatchedBundle.to_string())
139        })
140    }
141}
142
143impl<Eth> EthSimBundle<Eth>
144where
145    Eth: EthTransactions + LoadBlock + Call,
146{
147    /// Flattens a potentially nested bundle into a list of individual transactions in a
148    /// `FlattenedBundleItem` with their associated metadata. This handles recursive bundle
149    /// processing up to `MAX_NESTED_BUNDLE_DEPTH` and `MAX_BUNDLE_BODY_SIZE`, preserving
150    /// inclusion, validity and privacy settings from parent bundles.
151    fn parse_and_flatten_bundle(
152        &self,
153        request: &MevSendBundle,
154    ) -> Result<Vec<FlattenedBundleItem<ProviderTx<Eth::Provider>>>, EthApiError> {
155        let mut items = Vec::new();
156
157        // Stack for processing bundles
158        let mut stack = Vec::new();
159
160        // Start with initial bundle, index 0, and depth 1
161        stack.push((request, 0, 1));
162
163        while let Some((current_bundle, mut idx, depth)) = stack.pop() {
164            // Check max depth
165            if depth > MAX_NESTED_BUNDLE_DEPTH {
166                return Err(EthApiError::InvalidParams(EthSimBundleError::MaxDepth.to_string()));
167            }
168
169            // Determine inclusion, validity, and privacy
170            let inclusion = &current_bundle.inclusion;
171            let validity = &current_bundle.validity;
172            let privacy = &current_bundle.privacy;
173
174            // Validate inclusion parameters
175            let block_number = inclusion.block_number();
176            let max_block_number = inclusion.max_block_number().unwrap_or(block_number);
177
178            if max_block_number < block_number || block_number == 0 {
179                return Err(EthApiError::InvalidParams(
180                    EthSimBundleError::InvalidInclusion.to_string(),
181                ));
182            }
183
184            // Validate bundle body size
185            if current_bundle.bundle_body.len() > MAX_BUNDLE_BODY_SIZE {
186                return Err(EthApiError::InvalidParams(
187                    EthSimBundleError::BundleTooLarge.to_string(),
188                ));
189            }
190
191            // Validate validity and refund config
192            if let Some(validity) = &current_bundle.validity {
193                // Validate refund entries
194                if let Some(refunds) = &validity.refund {
195                    let mut total_percent = 0;
196                    for refund in refunds {
197                        if refund.body_idx as usize >= current_bundle.bundle_body.len() {
198                            return Err(EthApiError::InvalidParams(
199                                EthSimBundleError::InvalidValidity.to_string(),
200                            ));
201                        }
202                        if 100 - total_percent < refund.percent {
203                            return Err(EthApiError::InvalidParams(
204                                EthSimBundleError::InvalidValidity.to_string(),
205                            ));
206                        }
207                        total_percent += refund.percent;
208                    }
209                }
210
211                // Validate refund configs
212                if let Some(refund_configs) = &validity.refund_config {
213                    let mut total_percent = 0;
214                    for refund_config in refund_configs {
215                        if 100 - total_percent < refund_config.percent {
216                            return Err(EthApiError::InvalidParams(
217                                EthSimBundleError::InvalidValidity.to_string(),
218                            ));
219                        }
220                        total_percent += refund_config.percent;
221                    }
222                }
223            }
224
225            let body = &current_bundle.bundle_body;
226
227            // Process items in the current bundle
228            while idx < body.len() {
229                match &body[idx] {
230                    BundleItem::Tx { tx, can_revert } => {
231                        let recovered_tx = self
232                            .eth_api()
233                            .recover_raw_transaction::<PoolPooledTx<Eth::Pool>>(tx)?;
234                        let tx = recovered_tx.map(
235                            <Eth::Pool as TransactionPool>::Transaction::pooled_into_consensus,
236                        );
237
238                        let refund_percent =
239                            validity.as_ref().and_then(|v| v.refund.as_ref()).and_then(|refunds| {
240                                refunds.iter().find_map(|refund| {
241                                    (refund.body_idx as usize == idx).then_some(refund.percent)
242                                })
243                            });
244                        let refund_configs =
245                            validity.as_ref().and_then(|v| v.refund_config.clone());
246
247                        // Create FlattenedBundleItem with current inclusion, validity, and privacy
248                        let flattened_item = FlattenedBundleItem {
249                            tx,
250                            can_revert: *can_revert,
251                            inclusion: inclusion.clone(),
252                            validity: validity.clone(),
253                            privacy: privacy.clone(),
254                            refund_percent,
255                            refund_configs,
256                        };
257
258                        items.push(flattened_item);
259                        idx += 1;
260                    }
261                    BundleItem::Bundle { bundle } => {
262                        // Push the current bundle and next index onto the stack to resume later
263                        stack.push((current_bundle, idx + 1, depth));
264
265                        // process the nested bundle next
266                        stack.push((bundle, 0, depth + 1));
267                        break;
268                    }
269                    BundleItem::Hash { hash: _ } => {
270                        // Hash-only items are not allowed
271                        return Err(EthApiError::InvalidParams(
272                            EthSimBundleError::InvalidBundle.to_string(),
273                        ));
274                    }
275                }
276            }
277        }
278
279        Ok(items)
280    }
281
282    async fn sim_bundle_inner(
283        &self,
284        request: MevSendBundle,
285        overrides: SimBundleOverrides,
286        logs: bool,
287    ) -> Result<SimBundleResponse, Eth::Error> {
288        let SimBundleOverrides { parent_block, block_overrides, .. } = overrides;
289
290        // A call gas limit of 0 disables the cap.
291        let call_gas_limit = self.eth_api().call_gas_limit();
292        if let Some(gas_limit) = block_overrides.gas_limit &&
293            call_gas_limit != 0 &&
294            gas_limit > call_gas_limit
295        {
296            return Err(
297                EthApiError::InvalidTransaction(RpcInvalidTransactionError::GasTooHigh).into()
298            )
299        }
300
301        // Parse and validate bundle
302        // Also, flatten the bundle here so that its easier to process
303        let flattened_bundle = self.parse_and_flatten_bundle(&request)?;
304
305        let block_id = parent_block.unwrap_or(BlockId::latest());
306        let (parent, _, parent_block_id) =
307            self.eth_api().evm_env_and_recovered_block_at(block_id).await?;
308
309        // The bundle is simulated in the block that builds on the parent, so derive the
310        // environment of the next block, see
311        // <https://github.com/flashbots/builder/blob/main/internal/ethapi/sbundle_api.go>
312        let parent_header = parent.clone_sealed_header();
313        let mut evm_env = {
314            let attributes = self
315                .eth_api()
316                .pending_env_builder()
317                .pending_env_attributes(&parent_header, Some(&block_overrides))?;
318            self.eth_api()
319                .evm_config()
320                .next_evm_env(&parent_header, &attributes)
321                .map_err(RethError::other)
322                .map_err(EthApiError::from_eth_err)?
323        };
324
325        let permit = self
326            .eth_api()
327            .acquire_owned_blocking_io()
328            .await
329            .map_err(|_| EthApiError::InternalEthError)?;
330
331        let eth_api = self.inner.eth_api.clone();
332
333        let sim_response = self
334            .inner
335            .eth_api
336            .spawn_with_state_at_block(parent_block_id, move |_, mut db| {
337                let _permit = permit;
338                // apply overrides before reading the block values they may change
339                apply_block_overrides(block_overrides, &mut db, &mut evm_env);
340
341                let state_block = parent.number();
342                let simulated_block_number = evm_env.block_env().number.saturating_to::<u64>();
343                let simulated_block_timestamp =
344                    evm_env.block_env().timestamp.saturating_to::<u64>();
345                let coinbase = evm_env.block_env().beneficiary;
346                let basefee = evm_env.block_base_fee();
347
348                let initial_coinbase_balance = db
349                    .get_account(&coinbase)
350                    .map_err(Eth::Error::from_eth_err)?
351                    .map(|acc| acc.balance)
352                    .unwrap_or_default();
353
354                let mut block_gas_limit = evm_env.block_env().gas_limit.saturating_to::<u64>();
355                if call_gas_limit != 0 {
356                    block_gas_limit = block_gas_limit.min(call_gas_limit);
357                }
358                let mut block_gas = BundleBlockGas::new(block_gas_limit, evm_env.version());
359                let mut evm = eth_api.evm_config().evm_with_env(&mut db, evm_env);
360                let mut coinbase_balance_before_tx = initial_coinbase_balance;
361                let mut total_profit = U256::ZERO;
362                let mut refundable_value = U256::ZERO;
363                let mut flat_logs: Vec<Vec<Log>> = Vec::new();
364
365                let mut log_index = 0;
366
367                for (tx_index, item) in flattened_bundle.iter().enumerate() {
368                    if is_cancelled() {
369                        return Err(EthApiError::InternalEthError.into())
370                    }
371
372                    // Check inclusion constraints
373                    let block_number = item.inclusion.block_number();
374                    let max_block_number =
375                        item.inclusion.max_block_number().unwrap_or(block_number);
376
377                    if simulated_block_number < block_number ||
378                        simulated_block_number > max_block_number
379                    {
380                        return Err(EthApiError::InvalidParams(
381                            EthSimBundleError::InvalidInclusion.to_string(),
382                        )
383                        .into());
384                    }
385
386                    if !block_gas.admits(item.tx.gas_limit()) {
387                        return Err(EthApiError::InvalidParams(
388                            EthBundleError::BundleGasLimitExceeded(block_gas_limit).to_string(),
389                        )
390                        .into())
391                    }
392
393                    let tx_env = eth_api.evm_config().tx_env(item.tx.clone());
394                    let result = evm.transact_commit(&tx_env).map_err(Eth::Error::from_evm_err)?;
395
396                    if !result.status && !item.can_revert {
397                        return Err(EthApiError::InvalidParams(
398                            EthSimBundleError::BundleTransactionFailed.to_string(),
399                        )
400                        .into());
401                    }
402
403                    block_gas.record(&result);
404
405                    let coinbase_balance_after_tx = evm
406                        .account_info(&coinbase)
407                        .map_err(Eth::Error::from_evm_err)?
408                        .map(|acc| acc.balance)
409                        .unwrap_or_default();
410
411                    let coinbase_diff =
412                        coinbase_balance_after_tx.saturating_sub(coinbase_balance_before_tx);
413                    total_profit += coinbase_diff;
414
415                    // Add to refundable value if this tx does not have a refund percent
416                    if item.refund_percent.is_none() {
417                        refundable_value += coinbase_diff;
418                    }
419
420                    // Update coinbase balance before next tx
421                    coinbase_balance_before_tx = coinbase_balance_after_tx;
422
423                    // Keep one log entry per executed transaction so we can rebuild the bundle
424                    // tree in execution order after simulation.
425                    if logs {
426                        let tx_logs: Vec<Log> = result
427                            .logs
428                            .into_iter()
429                            .map(|inner| {
430                                let full_log = Log {
431                                    inner,
432                                    // the simulated block has no hash yet
433                                    block_hash: None,
434                                    block_number: Some(simulated_block_number),
435                                    block_timestamp: Some(simulated_block_timestamp),
436                                    transaction_hash: Some(*item.tx.tx_hash()),
437                                    transaction_index: Some(tx_index as u64),
438                                    log_index: Some(log_index),
439                                    removed: false,
440                                };
441                                log_index += 1;
442                                full_log
443                            })
444                            .collect();
445                        flat_logs.push(tx_logs);
446                    }
447                }
448
449                let body_logs =
450                    if logs { Self::build_bundle_logs(&request, &flat_logs)? } else { vec![] };
451
452                let mut total_gas_used = block_gas.gas_used();
453
454                // After processing all transactions, process refunds
455                for item in &flattened_bundle {
456                    if let Some(refund_percent) = item.refund_percent {
457                        let refund_configs = item.refund_configs.clone().unwrap_or_else(|| {
458                            vec![RefundConfig { address: item.tx.signer(), percent: 100 }]
459                        });
460
461                        // Calculate payout transaction fee
462                        let payout_tx_fee = U256::from(basefee) *
463                            U256::from(SBUNDLE_PAYOUT_MAX_COST) *
464                            U256::from(refund_configs.len() as u64);
465
466                        // Add gas used for payout transactions
467                        total_gas_used += SBUNDLE_PAYOUT_MAX_COST * refund_configs.len() as u64;
468
469                        // Calculate allocated refundable value (payout value)
470                        let payout_value =
471                            refundable_value * U256::from(refund_percent) / U256::from(100);
472
473                        if payout_tx_fee > payout_value {
474                            return Err(EthApiError::InvalidParams(
475                                EthSimBundleError::NegativeProfit.to_string(),
476                            )
477                            .into());
478                        }
479
480                        // Subtract payout value from total profit
481                        total_profit = total_profit.checked_sub(payout_value).ok_or(
482                            EthApiError::InvalidParams(
483                                EthSimBundleError::NegativeProfit.to_string(),
484                            ),
485                        )?;
486                    }
487                }
488
489                // Calculate mev gas price
490                let mev_gas_price = if total_gas_used != 0 {
491                    total_profit / U256::from(total_gas_used)
492                } else {
493                    U256::ZERO
494                };
495
496                Ok(SimBundleResponse {
497                    success: true,
498                    state_block,
499                    error: None,
500                    logs: Some(body_logs),
501                    gas_used: total_gas_used,
502                    mev_gas_price,
503                    profit: total_profit,
504                    refundable_value,
505                    exec_error: None,
506                    revert: None,
507                })
508            })
509            .await?;
510
511        Ok(sim_response)
512    }
513}
514
515impl<Eth> MevSimApiServer for EthSimBundle<Eth>
516where
517    Eth: EthTransactions + LoadBlock + Call,
518{
519    async fn sim_bundle(
520        &self,
521        request: MevSendBundle,
522        overrides: SimBundleOverrides,
523    ) -> RpcResult<SimBundleResponse> {
524        trace!("mev_simBundle called, request: {:?}, overrides: {:?}", request, overrides);
525
526        let override_timeout = overrides.timeout;
527
528        let timeout = override_timeout
529            .map(Duration::from_secs)
530            .map(|d| d.min(MAX_SIM_TIMEOUT))
531            .unwrap_or(DEFAULT_SIM_TIMEOUT);
532
533        let bundle_res =
534            tokio::time::timeout(timeout, Self::sim_bundle_inner(self, request, overrides, true))
535                .await
536                .map_err(|_| {
537                    EthApiError::InvalidParams(EthSimBundleError::BundleTimeout.to_string())
538                })?;
539
540        bundle_res.map_err(Into::into)
541    }
542}
543
544/// Container type for `EthSimBundle` internals
545#[derive(Debug)]
546struct EthSimBundleInner<Eth> {
547    /// Access to commonly used code of the `eth` namespace
548    eth_api: Eth,
549    // restrict the number of concurrent tracing calls.
550    #[expect(dead_code)]
551    blocking_task_guard: BlockingTaskGuard,
552}
553
554impl<Eth> std::fmt::Debug for EthSimBundle<Eth> {
555    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
556        f.debug_struct("EthSimBundle").finish_non_exhaustive()
557    }
558}
559
560impl<Eth> Clone for EthSimBundle<Eth> {
561    fn clone(&self) -> Self {
562        Self { inner: Arc::clone(&self.inner) }
563    }
564}
565
566/// [`EthSimBundle`] specific errors.
567#[derive(Debug, thiserror::Error)]
568pub enum EthSimBundleError {
569    /// Thrown when max depth is reached
570    #[error("max depth reached")]
571    MaxDepth,
572    /// Thrown when a bundle is unmatched
573    #[error("unmatched bundle")]
574    UnmatchedBundle,
575    /// Thrown when a bundle is too large
576    #[error("bundle too large")]
577    BundleTooLarge,
578    /// Thrown when validity is invalid
579    #[error("invalid validity")]
580    InvalidValidity,
581    /// Thrown when inclusion is invalid
582    #[error("invalid inclusion")]
583    InvalidInclusion,
584    /// Thrown when a bundle is invalid
585    #[error("invalid bundle")]
586    InvalidBundle,
587    /// Thrown when a bundle simulation times out
588    #[error("bundle simulation timed out")]
589    BundleTimeout,
590    /// Thrown when a transaction is reverted in a bundle
591    #[error("bundle transaction failed")]
592    BundleTransactionFailed,
593    /// Thrown when a bundle simulation returns negative profit
594    #[error("bundle simulation returned negative profit")]
595    NegativeProfit,
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use crate::EthApiBuilder;
602    use alloy_consensus::{transaction::SignerRecoverable, TxEip1559};
603    use alloy_eips::{eip1559::INITIAL_BASE_FEE, eip2718::Encodable2718};
604    use alloy_genesis::{Genesis, GenesisAccount};
605    use alloy_primitives::{Address, Bytes, TxKind, B256};
606    use alloy_rpc_types_eth::BlockOverrides;
607    use alloy_rpc_types_mev::{Inclusion, ProtocolVersion};
608    use jsonrpsee::types::ErrorObjectOwned;
609    use reth_chainspec::{ChainSpecBuilder, ChainSpecProvider};
610    use reth_db_common::init::init_genesis;
611    use reth_evm_ethereum::EthEvmConfig;
612    use reth_network_api::noop::NoopNetwork;
613    use reth_provider::{
614        providers::BlockchainProvider, test_utils::create_test_provider_factory_with_chain_spec,
615    };
616    use reth_testing_utils::{
617        generators,
618        generators::{generate_key, sign_tx_with_key_pair},
619    };
620    use reth_transaction_pool::test_utils::testing_pool;
621
622    #[tokio::test(flavor = "multi_thread")]
623    async fn sim_bundle_uses_next_block_env_and_overrides() {
624        let contract = Address::repeat_byte(0xaa);
625        let coinbase = Address::repeat_byte(0x11);
626        let overridden_coinbase = Address::repeat_byte(0x22);
627        let mut rng = generators::rng();
628        let tx = sign_tx_with_key_pair(
629            generate_key(&mut rng),
630            reth_ethereum_primitives::Transaction::Eip1559(TxEip1559 {
631                chain_id: 1,
632                nonce: 0,
633                gas_limit: 100_000,
634                max_fee_per_gas: 2_000_000_000,
635                max_priority_fee_per_gas: 1_000_000_000,
636                to: TxKind::Call(contract),
637                ..Default::default()
638            }),
639        );
640        let sender = tx.recover_signer().unwrap();
641
642        // Log NUMBER, TIMESTAMP, BASEFEE and COINBASE as topics, in that order.
643        let code = Bytes::from_static(&[0x41, 0x48, 0x42, 0x43, 0x5f, 0x5f, 0xa4, 0x00]);
644        let genesis = Genesis::default()
645            .with_gas_limit(30_000_000)
646            .with_timestamp(100)
647            .with_base_fee(Some(1_000_000_000))
648            .with_coinbase(coinbase)
649            .extend_accounts([
650                (sender, GenesisAccount::default().with_balance(U256::from(10u128.pow(18)))),
651                (contract, GenesisAccount::default().with_code(Some(code))),
652                (
653                    overridden_coinbase,
654                    GenesisAccount::default().with_balance(U256::from(10u128.pow(18))),
655                ),
656            ]);
657        let chain_spec =
658            Arc::new(ChainSpecBuilder::mainnet().cancun_activated().genesis(genesis).build());
659        let factory = create_test_provider_factory_with_chain_spec(chain_spec);
660        init_genesis(&factory).unwrap();
661        let provider = BlockchainProvider::new(factory).unwrap();
662
663        let eth_api = EthApiBuilder::new(
664            provider.clone(),
665            testing_pool(),
666            NoopNetwork::default(),
667            EthEvmConfig::new(provider.chain_spec()),
668        )
669        .build();
670        let api = EthSimBundle::new(eth_api, BlockingTaskGuard::new(4));
671
672        let bundle = |block| MevSendBundle {
673            protocol_version: ProtocolVersion::V0_1,
674            inclusion: Inclusion::at_block(block),
675            bundle_body: vec![BundleItem::Tx { tx: tx.encoded_2718().into(), can_revert: false }],
676            validity: None,
677            privacy: None,
678        };
679
680        for (overrides, number, timestamp, base_fee, beneficiary) in [
681            (SimBundleOverrides::default(), 1, 112, 875_000_000, coinbase),
682            (
683                SimBundleOverrides {
684                    parent_block: Some(BlockId::number(0)),
685                    block_overrides: BlockOverrides {
686                        number: Some(U256::from(2)),
687                        time: Some(200),
688                        base_fee: Some(U256::from(1_500_000_000)),
689                        coinbase: Some(overridden_coinbase),
690                        ..Default::default()
691                    },
692                    ..Default::default()
693                },
694                2,
695                200,
696                1_500_000_000,
697                overridden_coinbase,
698            ),
699        ] {
700            let response = api.sim_bundle(bundle(number), overrides.clone()).await.unwrap();
701            assert!(response.success);
702            assert_eq!(response.state_block, 0);
703            let tip = (2_000_000_000u64 - base_fee).min(1_000_000_000);
704            assert_eq!(response.profit, U256::from(response.gas_used) * U256::from(tip));
705            assert_eq!(response.refundable_value, response.profit);
706            assert_eq!(response.mev_gas_price, U256::from(tip));
707
708            let logs = response.logs.unwrap();
709            assert_eq!(logs.len(), 1);
710            let tx_logs = logs[0].tx_logs.as_ref().unwrap();
711            assert_eq!(tx_logs.len(), 1);
712            let log = &tx_logs[0];
713            assert_eq!(
714                log.inner.topics(),
715                &[
716                    B256::from(U256::from(number)),
717                    B256::from(U256::from(timestamp)),
718                    B256::from(U256::from(base_fee)),
719                    beneficiary.into_word(),
720                ]
721            );
722            assert_eq!(log.block_hash, None);
723            assert_eq!(log.block_number, Some(number));
724            assert_eq!(log.block_timestamp, Some(timestamp));
725            assert_eq!(log.transaction_hash, Some(*tx.tx_hash()));
726            assert_eq!(log.transaction_index, Some(0));
727            assert_eq!(log.log_index, Some(0));
728
729            // Nonzero inclusion bounds reach the simulation check instead of failing parsing.
730            let err = api.sim_bundle(bundle(number + 1), overrides.clone()).await.unwrap_err();
731            assert!(err.message().contains("invalid inclusion"), "{err}");
732            if number > 1 {
733                let err = api.sim_bundle(bundle(number - 1), overrides).await.unwrap_err();
734                assert!(err.message().contains("invalid inclusion"), "{err}");
735            }
736        }
737    }
738
739    fn create_test_bundle(tx_bytes: Vec<Bytes>) -> MevSendBundle {
740        let body: Vec<BundleItem> =
741            tx_bytes.into_iter().map(|tx| BundleItem::Tx { tx, can_revert: false }).collect();
742        MevSendBundle {
743            bundle_body: body,
744            inclusion: Inclusion { block: 1, max_block: None },
745            validity: None,
746            privacy: None,
747            protocol_version: ProtocolVersion::V0_1,
748        }
749    }
750
751    fn create_nested_bundle(outer_tx: Bytes, inner_txs: Vec<Bytes>) -> MevSendBundle {
752        let inner_bundle = create_test_bundle(inner_txs);
753        MevSendBundle {
754            bundle_body: vec![
755                BundleItem::Tx { tx: outer_tx, can_revert: false },
756                BundleItem::Bundle { bundle: inner_bundle },
757            ],
758            inclusion: Inclusion { block: 1, max_block: None },
759            validity: None,
760            privacy: None,
761            protocol_version: ProtocolVersion::V0_1,
762        }
763    }
764
765    fn create_bundle_with_body(bundle_body: Vec<BundleItem>) -> MevSendBundle {
766        MevSendBundle {
767            bundle_body,
768            inclusion: Inclusion { block: 1, max_block: None },
769            validity: None,
770            privacy: None,
771            protocol_version: ProtocolVersion::V0_1,
772        }
773    }
774
775    fn create_bundle_logs(log_counts: &[usize]) -> Vec<Vec<Log>> {
776        log_counts.iter().map(|count| vec![Log::default(); *count]).collect()
777    }
778
779    fn assert_unmatched_bundle(result: Result<Vec<SimBundleLogs>, EthApiError>) {
780        assert!(matches!(
781            result,
782            Err(EthApiError::InvalidParams(ref message))
783                if message == &EthSimBundleError::UnmatchedBundle.to_string()
784        ));
785    }
786
787    #[test]
788    fn test_build_bundle_logs_single_tx() {
789        let bundle = create_test_bundle(vec![Bytes::from(vec![0x01, 0x02, 0x03])]);
790        let result =
791            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1])).unwrap();
792
793        assert_eq!(result.len(), 1);
794        assert!(result[0].tx_logs.is_some());
795        assert!(result[0].bundle_logs.is_none());
796        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
797    }
798
799    #[test]
800    fn test_build_bundle_logs_empty_bundle() {
801        let bundle = create_test_bundle(vec![]);
802        let result = EthSimBundle::<()>::build_bundle_logs(&bundle, &[]).unwrap();
803
804        assert!(result.is_empty());
805    }
806
807    #[test]
808    fn test_build_bundle_logs_nested_bundle() {
809        let outer_tx = Bytes::from(vec![0x01, 0x02, 0x03]);
810        let inner_tx1 = Bytes::from(vec![0x04, 0x05, 0x06]);
811        let inner_tx2 = Bytes::from(vec![0x07, 0x08, 0x09]);
812        let bundle = create_nested_bundle(outer_tx, vec![inner_tx1, inner_tx2]);
813        let result =
814            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1, 1, 2]))
815                .unwrap();
816
817        assert_eq!(result.len(), 2);
818        assert!(result[0].tx_logs.is_some());
819        assert!(result[0].bundle_logs.is_none());
820        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
821
822        assert!(result[1].tx_logs.is_none());
823        assert!(result[1].bundle_logs.is_some());
824
825        let nested_logs = result[1].bundle_logs.as_ref().unwrap();
826        assert_eq!(nested_logs.len(), 2);
827        assert!(nested_logs[0].tx_logs.is_some());
828        assert_eq!(nested_logs[0].tx_logs.as_ref().unwrap().len(), 1);
829        assert!(nested_logs[1].tx_logs.is_some());
830        assert_eq!(nested_logs[1].tx_logs.as_ref().unwrap().len(), 2);
831    }
832
833    #[test]
834    fn test_build_bundle_logs_duplicate_transactions_same_level() {
835        let duplicate_tx = Bytes::from(vec![0x01, 0x02, 0x03]);
836        let bundle = create_test_bundle(vec![duplicate_tx.clone(), duplicate_tx]);
837        let result =
838            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1, 2])).unwrap();
839
840        assert_eq!(result.len(), 2);
841        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
842        assert_eq!(result[1].tx_logs.as_ref().unwrap().len(), 2);
843    }
844
845    #[test]
846    fn test_build_bundle_logs_duplicate_transactions_across_nested_bundles() {
847        let duplicate_tx = Bytes::from(vec![0x01, 0x02, 0x03]);
848        let bundle = create_nested_bundle(duplicate_tx.clone(), vec![duplicate_tx]);
849        let result =
850            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1, 2])).unwrap();
851
852        assert_eq!(result.len(), 2);
853        assert!(result[1].bundle_logs.is_some());
854        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
855
856        let nested_logs = result[1].bundle_logs.as_ref().unwrap();
857        assert_eq!(nested_logs.len(), 1);
858        assert_eq!(nested_logs[0].tx_logs.as_ref().unwrap().len(), 2);
859    }
860
861    #[test]
862    fn test_build_bundle_logs_root_with_only_nested_bundles() {
863        let first_nested = create_test_bundle(vec![Bytes::from(vec![0x01])]);
864        let second_nested =
865            create_test_bundle(vec![Bytes::from(vec![0x02]), Bytes::from(vec![0x03])]);
866        let bundle = create_bundle_with_body(vec![
867            BundleItem::Bundle { bundle: first_nested },
868            BundleItem::Bundle { bundle: second_nested },
869        ]);
870        let result =
871            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1, 1, 2]))
872                .unwrap();
873
874        assert_eq!(result.len(), 2);
875        assert!(result[0].tx_logs.is_none());
876        assert!(result[1].tx_logs.is_none());
877
878        let first_nested_logs = result[0].bundle_logs.as_ref().unwrap();
879        assert_eq!(first_nested_logs.len(), 1);
880        assert_eq!(first_nested_logs[0].tx_logs.as_ref().unwrap().len(), 1);
881
882        let second_nested_logs = result[1].bundle_logs.as_ref().unwrap();
883        assert_eq!(second_nested_logs.len(), 2);
884        assert_eq!(second_nested_logs[0].tx_logs.as_ref().unwrap().len(), 1);
885        assert_eq!(second_nested_logs[1].tx_logs.as_ref().unwrap().len(), 2);
886    }
887
888    #[test]
889    fn test_build_bundle_logs_deeply_nested_bundle() {
890        let leaf_bundle = create_test_bundle(vec![Bytes::from(vec![0x03])]);
891        let middle_bundle = create_bundle_with_body(vec![
892            BundleItem::Tx { tx: Bytes::from(vec![0x02]), can_revert: false },
893            BundleItem::Bundle { bundle: leaf_bundle },
894        ]);
895        let root_bundle = create_bundle_with_body(vec![
896            BundleItem::Tx { tx: Bytes::from(vec![0x01]), can_revert: false },
897            BundleItem::Bundle { bundle: middle_bundle },
898        ]);
899        let result =
900            EthSimBundle::<()>::build_bundle_logs(&root_bundle, &create_bundle_logs(&[1, 2, 3]))
901                .unwrap();
902
903        assert_eq!(result.len(), 2);
904        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
905
906        let middle_logs = result[1].bundle_logs.as_ref().unwrap();
907        assert_eq!(middle_logs.len(), 2);
908        assert_eq!(middle_logs[0].tx_logs.as_ref().unwrap().len(), 2);
909
910        let leaf_logs = middle_logs[1].bundle_logs.as_ref().unwrap();
911        assert_eq!(leaf_logs.len(), 1);
912        assert_eq!(leaf_logs[0].tx_logs.as_ref().unwrap().len(), 3);
913    }
914
915    #[test]
916    fn test_build_bundle_logs_mismatched_flat_logs() {
917        let bundle = create_test_bundle(vec![Bytes::from(vec![0x01, 0x02, 0x03])]);
918
919        assert_unmatched_bundle(EthSimBundle::<()>::build_bundle_logs(&bundle, &[]));
920        assert_unmatched_bundle(EthSimBundle::<()>::build_bundle_logs(
921            &bundle,
922            &create_bundle_logs(&[1, 2]),
923        ));
924    }
925
926    /// The bundle's gas budget is capped at the RPC call gas limit, and a limit of 0 disables the
927    /// cap.
928    #[tokio::test(flavor = "multi_thread")]
929    async fn sim_bundle_caps_gas_at_call_gas_limit() {
930        let mut rng = generators::rng();
931        let key = generate_key(&mut rng);
932        let txs: Vec<_> = (0..2)
933            .map(|nonce| {
934                sign_tx_with_key_pair(
935                    key,
936                    reth_ethereum_primitives::Transaction::Eip1559(TxEip1559 {
937                        chain_id: 1,
938                        nonce,
939                        gas_limit: 21_000,
940                        max_fee_per_gas: INITIAL_BASE_FEE as u128,
941                        to: TxKind::Call(Address::random()),
942                        ..Default::default()
943                    }),
944                )
945            })
946            .collect();
947        let sender = txs[0].recover_signer().unwrap();
948
949        let genesis = Genesis::default()
950            .with_gas_limit(30_000_000)
951            .with_base_fee(Some(INITIAL_BASE_FEE as u128))
952            .extend_accounts([(
953                sender,
954                GenesisAccount::default().with_balance(U256::from(10u128.pow(18))),
955            )]);
956        let chain_spec =
957            Arc::new(ChainSpecBuilder::mainnet().cancun_activated().genesis(genesis).build());
958        let factory = create_test_provider_factory_with_chain_spec(chain_spec);
959        init_genesis(&factory).unwrap();
960        let provider = BlockchainProvider::new(factory).unwrap();
961        let api = |gas_cap: u64| {
962            let eth_api = EthApiBuilder::new(
963                provider.clone(),
964                testing_pool(),
965                NoopNetwork::default(),
966                EthEvmConfig::new(provider.chain_spec()),
967            )
968            .gas_cap(gas_cap.into())
969            .build();
970            EthSimBundle::new(eth_api, BlockingTaskGuard::new(4))
971        };
972        let bundle = create_test_bundle(txs.iter().map(|tx| tx.encoded_2718().into()).collect());
973        let overrides = SimBundleOverrides {
974            block_overrides: BlockOverrides { gas_limit: Some(21_001), ..Default::default() },
975            ..Default::default()
976        };
977
978        let capped = api(21_000);
979        assert_eq!(
980            capped.sim_bundle(bundle.clone(), overrides).await.unwrap_err(),
981            ErrorObjectOwned::from(EthApiError::InvalidTransaction(
982                RpcInvalidTransactionError::GasTooHigh
983            ))
984        );
985        assert_eq!(
986            capped.sim_bundle(bundle.clone(), SimBundleOverrides::default()).await.unwrap_err(),
987            ErrorObjectOwned::from(EthApiError::InvalidParams(
988                EthBundleError::BundleGasLimitExceeded(21_000).to_string()
989            ))
990        );
991        let response = api(0).sim_bundle(bundle, SimBundleOverrides::default()).await.unwrap();
992        assert_eq!(response.gas_used, 42_000);
993    }
994}