Skip to main content

reth_rpc/eth/
sim_bundle.rs

1//! `Eth` Sim bundle implementation and helpers.
2
3use alloy_consensus::{transaction::TxHashRef, BlockHeader};
4use alloy_evm::{env::BlockEnvironment, overrides::apply_block_overrides};
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 jsonrpsee::core::RpcResult;
12use reth_errors::RethError;
13use reth_evm::{ConfigureEvm, Evm};
14use reth_primitives_traits::Recovered;
15use reth_rpc_api::MevSimApiServer;
16use reth_rpc_eth_api::{
17    helpers::{block::LoadBlock, Call, EthTransactions},
18    FromEthApiError, FromEvmError,
19};
20use reth_rpc_eth_types::EthApiError;
21use reth_storage_api::ProviderTx;
22use reth_tasks::pool::BlockingTaskGuard;
23use reth_transaction_pool::{PoolPooledTx, PoolTransaction, TransactionPool};
24use revm::{
25    context::Block, context_interface::result::ResultAndState, DatabaseCommit, DatabaseRef,
26};
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
37const DEFAULT_SIM_TIMEOUT: Duration = Duration::from_secs(5);
38
39/// Maximum simulation timeout
40const 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 + 'static,
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        // Parse and validate bundle
291        // Also, flatten the bundle here so that its easier to process
292        let flattened_bundle = self.parse_and_flatten_bundle(&request)?;
293
294        let block_id = parent_block.unwrap_or(BlockId::latest());
295        let (parent, _, parent_block_id) =
296            self.eth_api().evm_env_and_recovered_block_at(block_id).await?;
297
298        // The bundle is simulated in the block that builds on the parent, so derive the
299        // environment of the next block, see
300        // <https://github.com/flashbots/builder/blob/main/internal/ethapi/sbundle_api.go>
301        let parent_header = parent.clone_sealed_header();
302        let mut evm_env = {
303            let attributes = self
304                .eth_api()
305                .pending_env_builder()
306                .pending_env_attributes(&parent_header, Some(&block_overrides))?;
307            self.eth_api()
308                .evm_config()
309                .next_evm_env(&parent_header, &attributes)
310                .map_err(RethError::other)
311                .map_err(EthApiError::from_eth_err)?
312        };
313
314        let eth_api = self.inner.eth_api.clone();
315
316        let sim_response = self
317            .inner
318            .eth_api
319            .spawn_with_state_at_block(parent_block_id, move |_, mut db| {
320                // apply overrides before reading the block values they may change
321                apply_block_overrides(block_overrides, &mut db, evm_env.block_env.inner_mut());
322
323                let state_block = parent.number();
324                let simulated_block_number = evm_env.block_env.number().saturating_to::<u64>();
325                let simulated_block_timestamp =
326                    evm_env.block_env.timestamp().saturating_to::<u64>();
327                let coinbase = evm_env.block_env.beneficiary();
328                let basefee = evm_env.block_env.basefee();
329
330                let initial_coinbase_balance = DatabaseRef::basic_ref(&db, coinbase)
331                    .map_err(EthApiError::from_eth_err)?
332                    .map(|acc| acc.balance)
333                    .unwrap_or_default();
334
335                let mut coinbase_balance_before_tx = initial_coinbase_balance;
336                let mut total_gas_used = 0;
337                let mut total_profit = U256::ZERO;
338                let mut refundable_value = U256::ZERO;
339                let mut flat_logs: Vec<Vec<Log>> = Vec::new();
340
341                let mut evm = eth_api.evm_config().evm_with_env(db, evm_env);
342                let mut log_index = 0;
343
344                for (tx_index, item) in flattened_bundle.iter().enumerate() {
345                    // Check inclusion constraints
346                    let block_number = item.inclusion.block_number();
347                    let max_block_number =
348                        item.inclusion.max_block_number().unwrap_or(block_number);
349
350                    if simulated_block_number < block_number ||
351                        simulated_block_number > max_block_number
352                    {
353                        return Err(EthApiError::InvalidParams(
354                            EthSimBundleError::InvalidInclusion.to_string(),
355                        )
356                        .into());
357                    }
358
359                    let ResultAndState { result, state } = evm
360                        .transact(eth_api.evm_config().tx_env(&item.tx))
361                        .map_err(Eth::Error::from_evm_err)?;
362
363                    if !result.is_success() && !item.can_revert {
364                        return Err(EthApiError::InvalidParams(
365                            EthSimBundleError::BundleTransactionFailed.to_string(),
366                        )
367                        .into());
368                    }
369
370                    let gas_used = result.tx_gas_used();
371                    total_gas_used += gas_used;
372
373                    // coinbase is always present in the result state
374                    let coinbase_balance_after_tx =
375                        state.get(&coinbase).map(|acc| acc.info.balance).unwrap_or_default();
376
377                    let coinbase_diff =
378                        coinbase_balance_after_tx.saturating_sub(coinbase_balance_before_tx);
379                    total_profit += coinbase_diff;
380
381                    // Add to refundable value if this tx does not have a refund percent
382                    if item.refund_percent.is_none() {
383                        refundable_value += coinbase_diff;
384                    }
385
386                    // Update coinbase balance before next tx
387                    coinbase_balance_before_tx = coinbase_balance_after_tx;
388
389                    // Keep one log entry per executed transaction so we can rebuild the bundle
390                    // tree in execution order after simulation.
391                    if logs {
392                        let tx_logs: Vec<Log> = result
393                            .into_logs()
394                            .into_iter()
395                            .map(|inner| {
396                                let full_log = Log {
397                                    inner,
398                                    // the simulated block has no hash yet
399                                    block_hash: None,
400                                    block_number: Some(simulated_block_number),
401                                    block_timestamp: Some(simulated_block_timestamp),
402                                    transaction_hash: Some(*item.tx.tx_hash()),
403                                    transaction_index: Some(tx_index as u64),
404                                    log_index: Some(log_index),
405                                    removed: false,
406                                };
407                                log_index += 1;
408                                full_log
409                            })
410                            .collect();
411                        flat_logs.push(tx_logs);
412                    }
413
414                    // Apply state changes
415                    evm.db_mut().commit(state);
416                }
417
418                let body_logs =
419                    if logs { Self::build_bundle_logs(&request, &flat_logs)? } else { vec![] };
420
421                // After processing all transactions, process refunds
422                // Store the original refundable value to calculate all payouts correctly
423                let original_refundable_value = refundable_value;
424                for item in &flattened_bundle {
425                    if let Some(refund_percent) = item.refund_percent {
426                        let refund_configs = item.refund_configs.clone().unwrap_or_else(|| {
427                            vec![RefundConfig { address: item.tx.signer(), percent: 100 }]
428                        });
429
430                        // Calculate payout transaction fee
431                        let payout_tx_fee = U256::from(basefee) *
432                            U256::from(SBUNDLE_PAYOUT_MAX_COST) *
433                            U256::from(refund_configs.len() as u64);
434
435                        // Add gas used for payout transactions
436                        total_gas_used += SBUNDLE_PAYOUT_MAX_COST * refund_configs.len() as u64;
437
438                        // Calculate allocated refundable value (payout value) based on ORIGINAL
439                        // refundable value. This ensures all refund_percent values are
440                        // calculated from the same base.
441                        let payout_value = original_refundable_value * U256::from(refund_percent) /
442                            U256::from(100);
443
444                        if payout_tx_fee > payout_value {
445                            return Err(EthApiError::InvalidParams(
446                                EthSimBundleError::NegativeProfit.to_string(),
447                            )
448                            .into());
449                        }
450
451                        // Subtract payout value from total profit
452                        total_profit = total_profit.checked_sub(payout_value).ok_or(
453                            EthApiError::InvalidParams(
454                                EthSimBundleError::NegativeProfit.to_string(),
455                            ),
456                        )?;
457
458                        // Adjust refundable value
459                        refundable_value = refundable_value.checked_sub(payout_value).ok_or(
460                            EthApiError::InvalidParams(
461                                EthSimBundleError::NegativeProfit.to_string(),
462                            ),
463                        )?;
464                    }
465                }
466
467                // Calculate mev gas price
468                let mev_gas_price = if total_gas_used != 0 {
469                    total_profit / U256::from(total_gas_used)
470                } else {
471                    U256::ZERO
472                };
473
474                Ok(SimBundleResponse {
475                    success: true,
476                    state_block,
477                    error: None,
478                    logs: Some(body_logs),
479                    gas_used: total_gas_used,
480                    mev_gas_price,
481                    profit: total_profit,
482                    refundable_value,
483                    exec_error: None,
484                    revert: None,
485                })
486            })
487            .await?;
488
489        Ok(sim_response)
490    }
491}
492
493#[async_trait::async_trait]
494impl<Eth> MevSimApiServer for EthSimBundle<Eth>
495where
496    Eth: EthTransactions + LoadBlock + Call + 'static,
497{
498    async fn sim_bundle(
499        &self,
500        request: MevSendBundle,
501        overrides: SimBundleOverrides,
502    ) -> RpcResult<SimBundleResponse> {
503        trace!("mev_simBundle called, request: {:?}, overrides: {:?}", request, overrides);
504
505        let override_timeout = overrides.timeout;
506
507        let timeout = override_timeout
508            .map(Duration::from_secs)
509            .map(|d| d.min(MAX_SIM_TIMEOUT))
510            .unwrap_or(DEFAULT_SIM_TIMEOUT);
511
512        let bundle_res =
513            tokio::time::timeout(timeout, Self::sim_bundle_inner(self, request, overrides, true))
514                .await
515                .map_err(|_| {
516                    EthApiError::InvalidParams(EthSimBundleError::BundleTimeout.to_string())
517                })?;
518
519        bundle_res.map_err(Into::into)
520    }
521}
522
523/// Container type for `EthSimBundle` internals
524#[derive(Debug)]
525struct EthSimBundleInner<Eth> {
526    /// Access to commonly used code of the `eth` namespace
527    eth_api: Eth,
528    // restrict the number of concurrent tracing calls.
529    #[expect(dead_code)]
530    blocking_task_guard: BlockingTaskGuard,
531}
532
533impl<Eth> std::fmt::Debug for EthSimBundle<Eth> {
534    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
535        f.debug_struct("EthSimBundle").finish_non_exhaustive()
536    }
537}
538
539impl<Eth> Clone for EthSimBundle<Eth> {
540    fn clone(&self) -> Self {
541        Self { inner: Arc::clone(&self.inner) }
542    }
543}
544
545/// [`EthSimBundle`] specific errors.
546#[derive(Debug, thiserror::Error)]
547pub enum EthSimBundleError {
548    /// Thrown when max depth is reached
549    #[error("max depth reached")]
550    MaxDepth,
551    /// Thrown when a bundle is unmatched
552    #[error("unmatched bundle")]
553    UnmatchedBundle,
554    /// Thrown when a bundle is too large
555    #[error("bundle too large")]
556    BundleTooLarge,
557    /// Thrown when validity is invalid
558    #[error("invalid validity")]
559    InvalidValidity,
560    /// Thrown when inclusion is invalid
561    #[error("invalid inclusion")]
562    InvalidInclusion,
563    /// Thrown when a bundle is invalid
564    #[error("invalid bundle")]
565    InvalidBundle,
566    /// Thrown when a bundle simulation times out
567    #[error("bundle simulation timed out")]
568    BundleTimeout,
569    /// Thrown when a transaction is reverted in a bundle
570    #[error("bundle transaction failed")]
571    BundleTransactionFailed,
572    /// Thrown when a bundle simulation returns negative profit
573    #[error("bundle simulation returned negative profit")]
574    NegativeProfit,
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use alloy_eips::BlockNumberOrTag;
581    use alloy_primitives::Bytes;
582    use alloy_rpc_types_mev::{Inclusion, ProtocolVersion};
583
584    #[tokio::test(flavor = "multi_thread")]
585    async fn sim_bundle_uses_next_block_env_and_overrides() {
586        use crate::EthApiBuilder;
587        use alloy_consensus::{transaction::SignerRecoverable, TxEip1559};
588        use alloy_eips::eip2718::Encodable2718;
589        use alloy_genesis::{Genesis, GenesisAccount};
590        use alloy_primitives::{Address, TxKind, B256};
591        use alloy_rpc_types_eth::BlockOverrides;
592        use reth_chainspec::{ChainSpecBuilder, ChainSpecProvider};
593        use reth_db_common::init::init_genesis;
594        use reth_evm_ethereum::EthEvmConfig;
595        use reth_network_api::noop::NoopNetwork;
596        use reth_provider::{
597            providers::BlockchainProvider, test_utils::create_test_provider_factory_with_chain_spec,
598        };
599        use reth_testing_utils::generators::{self, generate_key, sign_tx_with_key_pair};
600        use reth_transaction_pool::test_utils::testing_pool;
601
602        let contract = Address::repeat_byte(0xaa);
603        let coinbase = Address::repeat_byte(0x11);
604        let overridden_coinbase = Address::repeat_byte(0x22);
605        let mut rng = generators::rng();
606        let tx = sign_tx_with_key_pair(
607            generate_key(&mut rng),
608            reth_ethereum_primitives::Transaction::Eip1559(TxEip1559 {
609                chain_id: 1,
610                nonce: 0,
611                gas_limit: 100_000,
612                max_fee_per_gas: 2_000_000_000,
613                max_priority_fee_per_gas: 1_000_000_000,
614                to: TxKind::Call(contract),
615                ..Default::default()
616            }),
617        );
618        let sender = tx.recover_signer().unwrap();
619
620        // Log NUMBER, TIMESTAMP, BASEFEE and COINBASE as topics, in that order.
621        let code = Bytes::from_static(&[0x41, 0x48, 0x42, 0x43, 0x5f, 0x5f, 0xa4, 0x00]);
622        let genesis = Genesis::default()
623            .with_gas_limit(30_000_000)
624            .with_timestamp(100)
625            .with_base_fee(Some(1_000_000_000))
626            .with_coinbase(coinbase)
627            .extend_accounts([
628                (sender, GenesisAccount::default().with_balance(U256::from(10u128.pow(18)))),
629                (contract, GenesisAccount::default().with_code(Some(code))),
630                (
631                    overridden_coinbase,
632                    GenesisAccount::default().with_balance(U256::from(10u128.pow(18))),
633                ),
634            ]);
635        let chain_spec =
636            Arc::new(ChainSpecBuilder::mainnet().cancun_activated().genesis(genesis).build());
637        let factory = create_test_provider_factory_with_chain_spec(chain_spec);
638        init_genesis(&factory).unwrap();
639        let provider = BlockchainProvider::new(factory).unwrap();
640
641        let eth_api = EthApiBuilder::new(
642            provider.clone(),
643            testing_pool(),
644            NoopNetwork::default(),
645            EthEvmConfig::new(provider.chain_spec()),
646        )
647        .build();
648        let api = EthSimBundle::new(eth_api, BlockingTaskGuard::new(4));
649
650        let bundle = |block| MevSendBundle {
651            protocol_version: ProtocolVersion::V0_1,
652            inclusion: Inclusion::at_block(block),
653            bundle_body: vec![BundleItem::Tx { tx: tx.encoded_2718().into(), can_revert: false }],
654            validity: None,
655            privacy: None,
656        };
657
658        for (overrides, number, timestamp, base_fee, beneficiary) in [
659            (SimBundleOverrides::default(), 1, 112, 875_000_000, coinbase),
660            (
661                SimBundleOverrides {
662                    parent_block: Some(BlockNumberOrTag::Number(0).into()),
663                    block_overrides: BlockOverrides {
664                        number: Some(U256::from(2)),
665                        time: Some(200),
666                        base_fee: Some(U256::from(1_500_000_000)),
667                        coinbase: Some(overridden_coinbase),
668                        ..Default::default()
669                    },
670                    ..Default::default()
671                },
672                2,
673                200,
674                1_500_000_000,
675                overridden_coinbase,
676            ),
677        ] {
678            let response = api.sim_bundle(bundle(number), overrides.clone()).await.unwrap();
679            assert!(response.success);
680            assert_eq!(response.state_block, 0);
681            let tip = (2_000_000_000u64 - base_fee).min(1_000_000_000);
682            assert_eq!(response.profit, U256::from(response.gas_used) * U256::from(tip));
683            assert_eq!(response.refundable_value, response.profit);
684            assert_eq!(response.mev_gas_price, U256::from(tip));
685
686            let logs = response.logs.unwrap();
687            assert_eq!(logs.len(), 1);
688            let tx_logs = logs[0].tx_logs.as_ref().unwrap();
689            assert_eq!(tx_logs.len(), 1);
690            let log = &tx_logs[0];
691            assert_eq!(
692                log.inner.topics(),
693                &[
694                    B256::from(U256::from(number)),
695                    B256::from(U256::from(timestamp)),
696                    B256::from(U256::from(base_fee)),
697                    beneficiary.into_word(),
698                ]
699            );
700            assert_eq!(log.block_hash, None);
701            assert_eq!(log.block_number, Some(number));
702            assert_eq!(log.block_timestamp, Some(timestamp));
703            assert_eq!(log.transaction_hash, Some(*tx.tx_hash()));
704            assert_eq!(log.transaction_index, Some(0));
705            assert_eq!(log.log_index, Some(0));
706
707            // Nonzero inclusion bounds reach the simulation check instead of failing parsing.
708            let err = api.sim_bundle(bundle(number + 1), overrides.clone()).await.unwrap_err();
709            assert!(err.message().contains("invalid inclusion"), "{err}");
710            if number > 1 {
711                let err = api.sim_bundle(bundle(number - 1), overrides).await.unwrap_err();
712                assert!(err.message().contains("invalid inclusion"), "{err}");
713            }
714        }
715    }
716
717    fn create_test_bundle(tx_bytes: Vec<Bytes>) -> MevSendBundle {
718        let body: Vec<BundleItem> =
719            tx_bytes.into_iter().map(|tx| BundleItem::Tx { tx, can_revert: false }).collect();
720        MevSendBundle {
721            bundle_body: body,
722            inclusion: Inclusion { block: 1, max_block: None },
723            validity: None,
724            privacy: None,
725            protocol_version: ProtocolVersion::V0_1,
726        }
727    }
728
729    fn create_nested_bundle(outer_tx: Bytes, inner_txs: Vec<Bytes>) -> MevSendBundle {
730        let inner_bundle = create_test_bundle(inner_txs);
731        MevSendBundle {
732            bundle_body: vec![
733                BundleItem::Tx { tx: outer_tx, can_revert: false },
734                BundleItem::Bundle { bundle: inner_bundle },
735            ],
736            inclusion: Inclusion { block: 1, max_block: None },
737            validity: None,
738            privacy: None,
739            protocol_version: ProtocolVersion::V0_1,
740        }
741    }
742
743    fn create_bundle_with_body(bundle_body: Vec<BundleItem>) -> MevSendBundle {
744        MevSendBundle {
745            bundle_body,
746            inclusion: Inclusion { block: 1, max_block: None },
747            validity: None,
748            privacy: None,
749            protocol_version: ProtocolVersion::V0_1,
750        }
751    }
752
753    fn create_bundle_logs(log_counts: &[usize]) -> Vec<Vec<Log>> {
754        log_counts.iter().map(|count| vec![Log::default(); *count]).collect()
755    }
756
757    fn assert_unmatched_bundle(result: Result<Vec<SimBundleLogs>, EthApiError>) {
758        assert!(matches!(
759            result,
760            Err(EthApiError::InvalidParams(ref message))
761                if message == &EthSimBundleError::UnmatchedBundle.to_string()
762        ));
763    }
764
765    #[test]
766    fn test_build_bundle_logs_single_tx() {
767        let bundle = create_test_bundle(vec![Bytes::from(vec![0x01, 0x02, 0x03])]);
768        let result =
769            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1])).unwrap();
770
771        assert_eq!(result.len(), 1);
772        assert!(result[0].tx_logs.is_some());
773        assert!(result[0].bundle_logs.is_none());
774        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
775    }
776
777    #[test]
778    fn test_build_bundle_logs_empty_bundle() {
779        let bundle = create_test_bundle(vec![]);
780        let result = EthSimBundle::<()>::build_bundle_logs(&bundle, &[]).unwrap();
781
782        assert!(result.is_empty());
783    }
784
785    #[test]
786    fn test_build_bundle_logs_nested_bundle() {
787        let outer_tx = Bytes::from(vec![0x01, 0x02, 0x03]);
788        let inner_tx1 = Bytes::from(vec![0x04, 0x05, 0x06]);
789        let inner_tx2 = Bytes::from(vec![0x07, 0x08, 0x09]);
790        let bundle = create_nested_bundle(outer_tx, vec![inner_tx1, inner_tx2]);
791        let result =
792            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1, 1, 2]))
793                .unwrap();
794
795        assert_eq!(result.len(), 2);
796        assert!(result[0].tx_logs.is_some());
797        assert!(result[0].bundle_logs.is_none());
798        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
799
800        assert!(result[1].tx_logs.is_none());
801        assert!(result[1].bundle_logs.is_some());
802
803        let nested_logs = result[1].bundle_logs.as_ref().unwrap();
804        assert_eq!(nested_logs.len(), 2);
805        assert!(nested_logs[0].tx_logs.is_some());
806        assert_eq!(nested_logs[0].tx_logs.as_ref().unwrap().len(), 1);
807        assert!(nested_logs[1].tx_logs.is_some());
808        assert_eq!(nested_logs[1].tx_logs.as_ref().unwrap().len(), 2);
809    }
810
811    #[test]
812    fn test_build_bundle_logs_duplicate_transactions_same_level() {
813        let duplicate_tx = Bytes::from(vec![0x01, 0x02, 0x03]);
814        let bundle = create_test_bundle(vec![duplicate_tx.clone(), duplicate_tx]);
815        let result =
816            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1, 2])).unwrap();
817
818        assert_eq!(result.len(), 2);
819        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
820        assert_eq!(result[1].tx_logs.as_ref().unwrap().len(), 2);
821    }
822
823    #[test]
824    fn test_build_bundle_logs_duplicate_transactions_across_nested_bundles() {
825        let duplicate_tx = Bytes::from(vec![0x01, 0x02, 0x03]);
826        let bundle = create_nested_bundle(duplicate_tx.clone(), vec![duplicate_tx]);
827        let result =
828            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1, 2])).unwrap();
829
830        assert_eq!(result.len(), 2);
831        assert!(result[1].bundle_logs.is_some());
832        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
833
834        let nested_logs = result[1].bundle_logs.as_ref().unwrap();
835        assert_eq!(nested_logs.len(), 1);
836        assert_eq!(nested_logs[0].tx_logs.as_ref().unwrap().len(), 2);
837    }
838
839    #[test]
840    fn test_build_bundle_logs_root_with_only_nested_bundles() {
841        let first_nested = create_test_bundle(vec![Bytes::from(vec![0x01])]);
842        let second_nested =
843            create_test_bundle(vec![Bytes::from(vec![0x02]), Bytes::from(vec![0x03])]);
844        let bundle = create_bundle_with_body(vec![
845            BundleItem::Bundle { bundle: first_nested },
846            BundleItem::Bundle { bundle: second_nested },
847        ]);
848        let result =
849            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1, 1, 2]))
850                .unwrap();
851
852        assert_eq!(result.len(), 2);
853        assert!(result[0].tx_logs.is_none());
854        assert!(result[1].tx_logs.is_none());
855
856        let first_nested_logs = result[0].bundle_logs.as_ref().unwrap();
857        assert_eq!(first_nested_logs.len(), 1);
858        assert_eq!(first_nested_logs[0].tx_logs.as_ref().unwrap().len(), 1);
859
860        let second_nested_logs = result[1].bundle_logs.as_ref().unwrap();
861        assert_eq!(second_nested_logs.len(), 2);
862        assert_eq!(second_nested_logs[0].tx_logs.as_ref().unwrap().len(), 1);
863        assert_eq!(second_nested_logs[1].tx_logs.as_ref().unwrap().len(), 2);
864    }
865
866    #[test]
867    fn test_build_bundle_logs_deeply_nested_bundle() {
868        let leaf_bundle = create_test_bundle(vec![Bytes::from(vec![0x03])]);
869        let middle_bundle = create_bundle_with_body(vec![
870            BundleItem::Tx { tx: Bytes::from(vec![0x02]), can_revert: false },
871            BundleItem::Bundle { bundle: leaf_bundle },
872        ]);
873        let root_bundle = create_bundle_with_body(vec![
874            BundleItem::Tx { tx: Bytes::from(vec![0x01]), can_revert: false },
875            BundleItem::Bundle { bundle: middle_bundle },
876        ]);
877        let result =
878            EthSimBundle::<()>::build_bundle_logs(&root_bundle, &create_bundle_logs(&[1, 2, 3]))
879                .unwrap();
880
881        assert_eq!(result.len(), 2);
882        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
883
884        let middle_logs = result[1].bundle_logs.as_ref().unwrap();
885        assert_eq!(middle_logs.len(), 2);
886        assert_eq!(middle_logs[0].tx_logs.as_ref().unwrap().len(), 2);
887
888        let leaf_logs = middle_logs[1].bundle_logs.as_ref().unwrap();
889        assert_eq!(leaf_logs.len(), 1);
890        assert_eq!(leaf_logs[0].tx_logs.as_ref().unwrap().len(), 3);
891    }
892
893    #[test]
894    fn test_build_bundle_logs_mismatched_flat_logs() {
895        let bundle = create_test_bundle(vec![Bytes::from(vec![0x01, 0x02, 0x03])]);
896
897        assert_unmatched_bundle(EthSimBundle::<()>::build_bundle_logs(&bundle, &[]));
898        assert_unmatched_bundle(EthSimBundle::<()>::build_bundle_logs(
899            &bundle,
900            &create_bundle_logs(&[1, 2]),
901        ));
902    }
903}