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_eips::BlockNumberOrTag;
5use alloy_evm::{env::BlockEnvironment, overrides::apply_block_overrides};
6use alloy_primitives::U256;
7use alloy_rpc_types_eth::{BlockId, Log};
8use alloy_rpc_types_mev::{
9    BundleItem, Inclusion, MevSendBundle, Privacy, RefundConfig, SimBundleLogs, SimBundleOverrides,
10    SimBundleResponse, Validity,
11};
12use jsonrpsee::core::RpcResult;
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::{utils::recover_raw_transaction, 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 = recover_raw_transaction::<PoolPooledTx<Eth::Pool>>(tx)?;
232                        let tx = recovered_tx.map(
233                            <Eth::Pool as TransactionPool>::Transaction::pooled_into_consensus,
234                        );
235
236                        let refund_percent =
237                            validity.as_ref().and_then(|v| v.refund.as_ref()).and_then(|refunds| {
238                                refunds.iter().find_map(|refund| {
239                                    (refund.body_idx as usize == idx).then_some(refund.percent)
240                                })
241                            });
242                        let refund_configs =
243                            validity.as_ref().and_then(|v| v.refund_config.clone());
244
245                        // Create FlattenedBundleItem with current inclusion, validity, and privacy
246                        let flattened_item = FlattenedBundleItem {
247                            tx,
248                            can_revert: *can_revert,
249                            inclusion: inclusion.clone(),
250                            validity: validity.clone(),
251                            privacy: privacy.clone(),
252                            refund_percent,
253                            refund_configs,
254                        };
255
256                        items.push(flattened_item);
257                        idx += 1;
258                    }
259                    BundleItem::Bundle { bundle } => {
260                        // Push the current bundle and next index onto the stack to resume later
261                        stack.push((current_bundle, idx + 1, depth));
262
263                        // process the nested bundle next
264                        stack.push((bundle, 0, depth + 1));
265                        break;
266                    }
267                    BundleItem::Hash { hash: _ } => {
268                        // Hash-only items are not allowed
269                        return Err(EthApiError::InvalidParams(
270                            EthSimBundleError::InvalidBundle.to_string(),
271                        ));
272                    }
273                }
274            }
275        }
276
277        Ok(items)
278    }
279
280    async fn sim_bundle_inner(
281        &self,
282        request: MevSendBundle,
283        overrides: SimBundleOverrides,
284        logs: bool,
285    ) -> Result<SimBundleResponse, Eth::Error> {
286        let SimBundleOverrides { parent_block, block_overrides, .. } = overrides;
287
288        // Parse and validate bundle
289        // Also, flatten the bundle here so that its easier to process
290        let flattened_bundle = self.parse_and_flatten_bundle(&request)?;
291
292        let block_id = parent_block.unwrap_or(BlockId::Number(BlockNumberOrTag::Latest));
293        let (mut evm_env, current_block_id) = self.eth_api().evm_env_at(block_id).await?;
294        let current_block = self.eth_api().recovered_block(current_block_id).await?;
295        let current_block = current_block.ok_or(EthApiError::HeaderNotFound(block_id))?;
296
297        let eth_api = self.inner.eth_api.clone();
298
299        let sim_response = self
300            .inner
301            .eth_api
302            .spawn_with_state_at_block(current_block_id, move |_, mut db| {
303                // Setup environment
304                let current_block_number = current_block.number();
305                let coinbase = evm_env.block_env.beneficiary();
306                let basefee = evm_env.block_env.basefee();
307
308                // apply overrides
309                apply_block_overrides(block_overrides, &mut db, evm_env.block_env.inner_mut());
310
311                let initial_coinbase_balance = DatabaseRef::basic_ref(&db, coinbase)
312                    .map_err(EthApiError::from_eth_err)?
313                    .map(|acc| acc.balance)
314                    .unwrap_or_default();
315
316                let mut coinbase_balance_before_tx = initial_coinbase_balance;
317                let mut total_gas_used = 0;
318                let mut total_profit = U256::ZERO;
319                let mut refundable_value = U256::ZERO;
320                let mut flat_logs: Vec<Vec<Log>> = Vec::new();
321
322                let mut evm = eth_api.evm_config().evm_with_env(db, evm_env);
323                let mut log_index = 0;
324
325                for (tx_index, item) in flattened_bundle.iter().enumerate() {
326                    // Check inclusion constraints
327                    let block_number = item.inclusion.block_number();
328                    let max_block_number =
329                        item.inclusion.max_block_number().unwrap_or(block_number);
330
331                    if current_block_number < block_number ||
332                        current_block_number > max_block_number
333                    {
334                        return Err(EthApiError::InvalidParams(
335                            EthSimBundleError::InvalidInclusion.to_string(),
336                        )
337                        .into());
338                    }
339
340                    let ResultAndState { result, state } = evm
341                        .transact(eth_api.evm_config().tx_env(&item.tx))
342                        .map_err(Eth::Error::from_evm_err)?;
343
344                    if !result.is_success() && !item.can_revert {
345                        return Err(EthApiError::InvalidParams(
346                            EthSimBundleError::BundleTransactionFailed.to_string(),
347                        )
348                        .into());
349                    }
350
351                    let gas_used = result.gas_used();
352                    total_gas_used += gas_used;
353
354                    // coinbase is always present in the result state
355                    let coinbase_balance_after_tx =
356                        state.get(&coinbase).map(|acc| acc.info.balance).unwrap_or_default();
357
358                    let coinbase_diff =
359                        coinbase_balance_after_tx.saturating_sub(coinbase_balance_before_tx);
360                    total_profit += coinbase_diff;
361
362                    // Add to refundable value if this tx does not have a refund percent
363                    if item.refund_percent.is_none() {
364                        refundable_value += coinbase_diff;
365                    }
366
367                    // Update coinbase balance before next tx
368                    coinbase_balance_before_tx = coinbase_balance_after_tx;
369
370                    // Keep one log entry per executed transaction so we can rebuild the bundle
371                    // tree in execution order after simulation.
372                    if logs {
373                        let tx_logs: Vec<Log> = result
374                            .into_logs()
375                            .into_iter()
376                            .map(|inner| {
377                                let full_log = Log {
378                                    inner,
379                                    block_hash: Some(current_block.hash()),
380                                    block_number: Some(current_block.number()),
381                                    block_timestamp: Some(current_block.timestamp()),
382                                    transaction_hash: Some(*item.tx.tx_hash()),
383                                    transaction_index: Some(tx_index as u64),
384                                    log_index: Some(log_index),
385                                    removed: false,
386                                };
387                                log_index += 1;
388                                full_log
389                            })
390                            .collect();
391                        flat_logs.push(tx_logs);
392                    }
393
394                    // Apply state changes
395                    evm.db_mut().commit(state);
396                }
397
398                let body_logs =
399                    if logs { Self::build_bundle_logs(&request, &flat_logs)? } else { vec![] };
400
401                // After processing all transactions, process refunds
402                // Store the original refundable value to calculate all payouts correctly
403                let original_refundable_value = refundable_value;
404                for item in &flattened_bundle {
405                    if let Some(refund_percent) = item.refund_percent {
406                        let refund_configs = item.refund_configs.clone().unwrap_or_else(|| {
407                            vec![RefundConfig { address: item.tx.signer(), percent: 100 }]
408                        });
409
410                        // Calculate payout transaction fee
411                        let payout_tx_fee = U256::from(basefee) *
412                            U256::from(SBUNDLE_PAYOUT_MAX_COST) *
413                            U256::from(refund_configs.len() as u64);
414
415                        // Add gas used for payout transactions
416                        total_gas_used += SBUNDLE_PAYOUT_MAX_COST * refund_configs.len() as u64;
417
418                        // Calculate allocated refundable value (payout value) based on ORIGINAL
419                        // refundable value. This ensures all refund_percent values are
420                        // calculated from the same base.
421                        let payout_value = original_refundable_value * U256::from(refund_percent) /
422                            U256::from(100);
423
424                        if payout_tx_fee > payout_value {
425                            return Err(EthApiError::InvalidParams(
426                                EthSimBundleError::NegativeProfit.to_string(),
427                            )
428                            .into());
429                        }
430
431                        // Subtract payout value from total profit
432                        total_profit = total_profit.checked_sub(payout_value).ok_or(
433                            EthApiError::InvalidParams(
434                                EthSimBundleError::NegativeProfit.to_string(),
435                            ),
436                        )?;
437
438                        // Adjust refundable value
439                        refundable_value = refundable_value.checked_sub(payout_value).ok_or(
440                            EthApiError::InvalidParams(
441                                EthSimBundleError::NegativeProfit.to_string(),
442                            ),
443                        )?;
444                    }
445                }
446
447                // Calculate mev gas price
448                let mev_gas_price = if total_gas_used != 0 {
449                    total_profit / U256::from(total_gas_used)
450                } else {
451                    U256::ZERO
452                };
453
454                Ok(SimBundleResponse {
455                    success: true,
456                    state_block: current_block_number,
457                    error: None,
458                    logs: Some(body_logs),
459                    gas_used: total_gas_used,
460                    mev_gas_price,
461                    profit: total_profit,
462                    refundable_value,
463                    exec_error: None,
464                    revert: None,
465                })
466            })
467            .await?;
468
469        Ok(sim_response)
470    }
471}
472
473#[async_trait::async_trait]
474impl<Eth> MevSimApiServer for EthSimBundle<Eth>
475where
476    Eth: EthTransactions + LoadBlock + Call + 'static,
477{
478    async fn sim_bundle(
479        &self,
480        request: MevSendBundle,
481        overrides: SimBundleOverrides,
482    ) -> RpcResult<SimBundleResponse> {
483        trace!("mev_simBundle called, request: {:?}, overrides: {:?}", request, overrides);
484
485        let override_timeout = overrides.timeout;
486
487        let timeout = override_timeout
488            .map(Duration::from_secs)
489            .map(|d| d.min(MAX_SIM_TIMEOUT))
490            .unwrap_or(DEFAULT_SIM_TIMEOUT);
491
492        let bundle_res =
493            tokio::time::timeout(timeout, Self::sim_bundle_inner(self, request, overrides, true))
494                .await
495                .map_err(|_| {
496                    EthApiError::InvalidParams(EthSimBundleError::BundleTimeout.to_string())
497                })?;
498
499        bundle_res.map_err(Into::into)
500    }
501}
502
503/// Container type for `EthSimBundle` internals
504#[derive(Debug)]
505struct EthSimBundleInner<Eth> {
506    /// Access to commonly used code of the `eth` namespace
507    eth_api: Eth,
508    // restrict the number of concurrent tracing calls.
509    #[expect(dead_code)]
510    blocking_task_guard: BlockingTaskGuard,
511}
512
513impl<Eth> std::fmt::Debug for EthSimBundle<Eth> {
514    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
515        f.debug_struct("EthSimBundle").finish_non_exhaustive()
516    }
517}
518
519impl<Eth> Clone for EthSimBundle<Eth> {
520    fn clone(&self) -> Self {
521        Self { inner: Arc::clone(&self.inner) }
522    }
523}
524
525/// [`EthSimBundle`] specific errors.
526#[derive(Debug, thiserror::Error)]
527pub enum EthSimBundleError {
528    /// Thrown when max depth is reached
529    #[error("max depth reached")]
530    MaxDepth,
531    /// Thrown when a bundle is unmatched
532    #[error("unmatched bundle")]
533    UnmatchedBundle,
534    /// Thrown when a bundle is too large
535    #[error("bundle too large")]
536    BundleTooLarge,
537    /// Thrown when validity is invalid
538    #[error("invalid validity")]
539    InvalidValidity,
540    /// Thrown when inclusion is invalid
541    #[error("invalid inclusion")]
542    InvalidInclusion,
543    /// Thrown when a bundle is invalid
544    #[error("invalid bundle")]
545    InvalidBundle,
546    /// Thrown when a bundle simulation times out
547    #[error("bundle simulation timed out")]
548    BundleTimeout,
549    /// Thrown when a transaction is reverted in a bundle
550    #[error("bundle transaction failed")]
551    BundleTransactionFailed,
552    /// Thrown when a bundle simulation returns negative profit
553    #[error("bundle simulation returned negative profit")]
554    NegativeProfit,
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use alloy_primitives::Bytes;
561    use alloy_rpc_types_mev::{Inclusion, ProtocolVersion};
562
563    fn create_test_bundle(tx_bytes: Vec<Bytes>) -> MevSendBundle {
564        let body: Vec<BundleItem> =
565            tx_bytes.into_iter().map(|tx| BundleItem::Tx { tx, can_revert: false }).collect();
566        MevSendBundle {
567            bundle_body: body,
568            inclusion: Inclusion { block: 1, max_block: None },
569            validity: None,
570            privacy: None,
571            protocol_version: ProtocolVersion::V0_1,
572        }
573    }
574
575    fn create_nested_bundle(outer_tx: Bytes, inner_txs: Vec<Bytes>) -> MevSendBundle {
576        let inner_bundle = create_test_bundle(inner_txs);
577        MevSendBundle {
578            bundle_body: vec![
579                BundleItem::Tx { tx: outer_tx, can_revert: false },
580                BundleItem::Bundle { bundle: inner_bundle },
581            ],
582            inclusion: Inclusion { block: 1, max_block: None },
583            validity: None,
584            privacy: None,
585            protocol_version: ProtocolVersion::V0_1,
586        }
587    }
588
589    fn create_bundle_with_body(bundle_body: Vec<BundleItem>) -> MevSendBundle {
590        MevSendBundle {
591            bundle_body,
592            inclusion: Inclusion { block: 1, max_block: None },
593            validity: None,
594            privacy: None,
595            protocol_version: ProtocolVersion::V0_1,
596        }
597    }
598
599    fn create_bundle_logs(log_counts: &[usize]) -> Vec<Vec<Log>> {
600        log_counts.iter().map(|count| vec![Log::default(); *count]).collect()
601    }
602
603    fn assert_unmatched_bundle(result: Result<Vec<SimBundleLogs>, EthApiError>) {
604        assert!(matches!(
605            result,
606            Err(EthApiError::InvalidParams(ref message))
607                if message == &EthSimBundleError::UnmatchedBundle.to_string()
608        ));
609    }
610
611    #[test]
612    fn test_build_bundle_logs_single_tx() {
613        let bundle = create_test_bundle(vec![Bytes::from(vec![0x01, 0x02, 0x03])]);
614        let result =
615            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1])).unwrap();
616
617        assert_eq!(result.len(), 1);
618        assert!(result[0].tx_logs.is_some());
619        assert!(result[0].bundle_logs.is_none());
620        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
621    }
622
623    #[test]
624    fn test_build_bundle_logs_empty_bundle() {
625        let bundle = create_test_bundle(vec![]);
626        let result = EthSimBundle::<()>::build_bundle_logs(&bundle, &[]).unwrap();
627
628        assert!(result.is_empty());
629    }
630
631    #[test]
632    fn test_build_bundle_logs_nested_bundle() {
633        let outer_tx = Bytes::from(vec![0x01, 0x02, 0x03]);
634        let inner_tx1 = Bytes::from(vec![0x04, 0x05, 0x06]);
635        let inner_tx2 = Bytes::from(vec![0x07, 0x08, 0x09]);
636        let bundle = create_nested_bundle(outer_tx, vec![inner_tx1, inner_tx2]);
637        let result =
638            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1, 1, 2]))
639                .unwrap();
640
641        assert_eq!(result.len(), 2);
642        assert!(result[0].tx_logs.is_some());
643        assert!(result[0].bundle_logs.is_none());
644        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
645
646        assert!(result[1].tx_logs.is_none());
647        assert!(result[1].bundle_logs.is_some());
648
649        let nested_logs = result[1].bundle_logs.as_ref().unwrap();
650        assert_eq!(nested_logs.len(), 2);
651        assert!(nested_logs[0].tx_logs.is_some());
652        assert_eq!(nested_logs[0].tx_logs.as_ref().unwrap().len(), 1);
653        assert!(nested_logs[1].tx_logs.is_some());
654        assert_eq!(nested_logs[1].tx_logs.as_ref().unwrap().len(), 2);
655    }
656
657    #[test]
658    fn test_build_bundle_logs_duplicate_transactions_same_level() {
659        let duplicate_tx = Bytes::from(vec![0x01, 0x02, 0x03]);
660        let bundle = create_test_bundle(vec![duplicate_tx.clone(), duplicate_tx]);
661        let result =
662            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1, 2])).unwrap();
663
664        assert_eq!(result.len(), 2);
665        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
666        assert_eq!(result[1].tx_logs.as_ref().unwrap().len(), 2);
667    }
668
669    #[test]
670    fn test_build_bundle_logs_duplicate_transactions_across_nested_bundles() {
671        let duplicate_tx = Bytes::from(vec![0x01, 0x02, 0x03]);
672        let bundle = create_nested_bundle(duplicate_tx.clone(), vec![duplicate_tx]);
673        let result =
674            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1, 2])).unwrap();
675
676        assert_eq!(result.len(), 2);
677        assert!(result[1].bundle_logs.is_some());
678        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
679
680        let nested_logs = result[1].bundle_logs.as_ref().unwrap();
681        assert_eq!(nested_logs.len(), 1);
682        assert_eq!(nested_logs[0].tx_logs.as_ref().unwrap().len(), 2);
683    }
684
685    #[test]
686    fn test_build_bundle_logs_root_with_only_nested_bundles() {
687        let first_nested = create_test_bundle(vec![Bytes::from(vec![0x01])]);
688        let second_nested =
689            create_test_bundle(vec![Bytes::from(vec![0x02]), Bytes::from(vec![0x03])]);
690        let bundle = create_bundle_with_body(vec![
691            BundleItem::Bundle { bundle: first_nested },
692            BundleItem::Bundle { bundle: second_nested },
693        ]);
694        let result =
695            EthSimBundle::<()>::build_bundle_logs(&bundle, &create_bundle_logs(&[1, 1, 2]))
696                .unwrap();
697
698        assert_eq!(result.len(), 2);
699        assert!(result[0].tx_logs.is_none());
700        assert!(result[1].tx_logs.is_none());
701
702        let first_nested_logs = result[0].bundle_logs.as_ref().unwrap();
703        assert_eq!(first_nested_logs.len(), 1);
704        assert_eq!(first_nested_logs[0].tx_logs.as_ref().unwrap().len(), 1);
705
706        let second_nested_logs = result[1].bundle_logs.as_ref().unwrap();
707        assert_eq!(second_nested_logs.len(), 2);
708        assert_eq!(second_nested_logs[0].tx_logs.as_ref().unwrap().len(), 1);
709        assert_eq!(second_nested_logs[1].tx_logs.as_ref().unwrap().len(), 2);
710    }
711
712    #[test]
713    fn test_build_bundle_logs_deeply_nested_bundle() {
714        let leaf_bundle = create_test_bundle(vec![Bytes::from(vec![0x03])]);
715        let middle_bundle = create_bundle_with_body(vec![
716            BundleItem::Tx { tx: Bytes::from(vec![0x02]), can_revert: false },
717            BundleItem::Bundle { bundle: leaf_bundle },
718        ]);
719        let root_bundle = create_bundle_with_body(vec![
720            BundleItem::Tx { tx: Bytes::from(vec![0x01]), can_revert: false },
721            BundleItem::Bundle { bundle: middle_bundle },
722        ]);
723        let result =
724            EthSimBundle::<()>::build_bundle_logs(&root_bundle, &create_bundle_logs(&[1, 2, 3]))
725                .unwrap();
726
727        assert_eq!(result.len(), 2);
728        assert_eq!(result[0].tx_logs.as_ref().unwrap().len(), 1);
729
730        let middle_logs = result[1].bundle_logs.as_ref().unwrap();
731        assert_eq!(middle_logs.len(), 2);
732        assert_eq!(middle_logs[0].tx_logs.as_ref().unwrap().len(), 2);
733
734        let leaf_logs = middle_logs[1].bundle_logs.as_ref().unwrap();
735        assert_eq!(leaf_logs.len(), 1);
736        assert_eq!(leaf_logs[0].tx_logs.as_ref().unwrap().len(), 3);
737    }
738
739    #[test]
740    fn test_build_bundle_logs_mismatched_flat_logs() {
741        let bundle = create_test_bundle(vec![Bytes::from(vec![0x01, 0x02, 0x03])]);
742
743        assert_unmatched_bundle(EthSimBundle::<()>::build_bundle_logs(&bundle, &[]));
744        assert_unmatched_bundle(EthSimBundle::<()>::build_bundle_logs(
745            &bundle,
746            &create_bundle_logs(&[1, 2]),
747        ));
748    }
749}