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;
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_revm::{database::StateProviderDatabase, State};
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::{utils::recover_raw_transaction, EthApiError};
22use reth_storage_api::ProviderTx;
23use reth_tasks::pool::BlockingTaskGuard;
24use reth_transaction_pool::{PoolPooledTx, PoolTransaction, TransactionPool};
25use revm::{
26    context::Block, context_interface::result::ResultAndState, DatabaseCommit, DatabaseRef,
27};
28use std::{sync::Arc, time::Duration};
29use tracing::trace;
30
31/// Maximum bundle depth
32const MAX_NESTED_BUNDLE_DEPTH: usize = 5;
33
34/// Maximum body size
35const MAX_BUNDLE_BODY_SIZE: usize = 50;
36
37/// Default simulation timeout
38const DEFAULT_SIM_TIMEOUT: Duration = Duration::from_secs(5);
39
40/// Maximum simulation timeout
41const MAX_SIM_TIMEOUT: Duration = Duration::from_secs(30);
42
43/// Maximum payout cost
44const SBUNDLE_PAYOUT_MAX_COST: u64 = 30_000;
45
46/// A flattened representation of a bundle item containing transaction and associated metadata.
47#[derive(Clone, Debug)]
48pub struct FlattenedBundleItem<T> {
49    /// The signed transaction
50    pub tx: Recovered<T>,
51    /// Whether the transaction is allowed to revert
52    pub can_revert: bool,
53    /// Item-level inclusion constraints
54    pub inclusion: Inclusion,
55    /// Optional validity constraints for the bundle item
56    pub validity: Option<Validity>,
57    /// Optional privacy settings for the bundle item
58    pub privacy: Option<Privacy>,
59    /// Optional refund percent for the bundle item
60    pub refund_percent: Option<u64>,
61    /// Optional refund configs for the bundle item
62    pub refund_configs: Option<Vec<RefundConfig>>,
63}
64
65/// `Eth` sim bundle implementation.
66pub struct EthSimBundle<Eth> {
67    /// All nested fields bundled together.
68    inner: Arc<EthSimBundleInner<Eth>>,
69}
70
71impl<Eth> EthSimBundle<Eth> {
72    /// Create a new `EthSimBundle` instance.
73    pub fn new(eth_api: Eth, blocking_task_guard: BlockingTaskGuard) -> Self {
74        Self { inner: Arc::new(EthSimBundleInner { eth_api, blocking_task_guard }) }
75    }
76
77    /// Access the underlying `Eth` API.
78    pub fn eth_api(&self) -> &Eth {
79        &self.inner.eth_api
80    }
81}
82
83impl<Eth> EthSimBundle<Eth>
84where
85    Eth: EthTransactions + LoadBlock + Call + 'static,
86{
87    /// Flattens a potentially nested bundle into a list of individual transactions in a
88    /// `FlattenedBundleItem` with their associated metadata. This handles recursive bundle
89    /// processing up to `MAX_NESTED_BUNDLE_DEPTH` and `MAX_BUNDLE_BODY_SIZE`, preserving
90    /// inclusion, validity and privacy settings from parent bundles.
91    fn parse_and_flatten_bundle(
92        &self,
93        request: &MevSendBundle,
94    ) -> Result<Vec<FlattenedBundleItem<ProviderTx<Eth::Provider>>>, EthApiError> {
95        let mut items = Vec::new();
96
97        // Stack for processing bundles
98        let mut stack = Vec::new();
99
100        // Start with initial bundle, index 0, and depth 1
101        stack.push((request, 0, 1));
102
103        while let Some((current_bundle, mut idx, depth)) = stack.pop() {
104            // Check max depth
105            if depth > MAX_NESTED_BUNDLE_DEPTH {
106                return Err(EthApiError::InvalidParams(EthSimBundleError::MaxDepth.to_string()));
107            }
108
109            // Determine inclusion, validity, and privacy
110            let inclusion = &current_bundle.inclusion;
111            let validity = &current_bundle.validity;
112            let privacy = &current_bundle.privacy;
113
114            // Validate inclusion parameters
115            let block_number = inclusion.block_number();
116            let max_block_number = inclusion.max_block_number().unwrap_or(block_number);
117
118            if max_block_number < block_number || block_number == 0 {
119                return Err(EthApiError::InvalidParams(
120                    EthSimBundleError::InvalidInclusion.to_string(),
121                ));
122            }
123
124            // Validate bundle body size
125            if current_bundle.bundle_body.len() > MAX_BUNDLE_BODY_SIZE {
126                return Err(EthApiError::InvalidParams(
127                    EthSimBundleError::BundleTooLarge.to_string(),
128                ));
129            }
130
131            // Validate validity and refund config
132            if let Some(validity) = &current_bundle.validity {
133                // Validate refund entries
134                if let Some(refunds) = &validity.refund {
135                    let mut total_percent = 0;
136                    for refund in refunds {
137                        if refund.body_idx as usize >= current_bundle.bundle_body.len() {
138                            return Err(EthApiError::InvalidParams(
139                                EthSimBundleError::InvalidValidity.to_string(),
140                            ));
141                        }
142                        if 100 - total_percent < refund.percent {
143                            return Err(EthApiError::InvalidParams(
144                                EthSimBundleError::InvalidValidity.to_string(),
145                            ));
146                        }
147                        total_percent += refund.percent;
148                    }
149                }
150
151                // Validate refund configs
152                if let Some(refund_configs) = &validity.refund_config {
153                    let mut total_percent = 0;
154                    for refund_config in refund_configs {
155                        if 100 - total_percent < refund_config.percent {
156                            return Err(EthApiError::InvalidParams(
157                                EthSimBundleError::InvalidValidity.to_string(),
158                            ));
159                        }
160                        total_percent += refund_config.percent;
161                    }
162                }
163            }
164
165            let body = &current_bundle.bundle_body;
166
167            // Process items in the current bundle
168            while idx < body.len() {
169                match &body[idx] {
170                    BundleItem::Tx { tx, can_revert } => {
171                        let tx = recover_raw_transaction::<PoolPooledTx<Eth::Pool>>(tx)?;
172                        let tx = tx.map(
173                            <Eth::Pool as TransactionPool>::Transaction::pooled_into_consensus,
174                        );
175
176                        let refund_percent =
177                            validity.as_ref().and_then(|v| v.refund.as_ref()).and_then(|refunds| {
178                                refunds.iter().find_map(|refund| {
179                                    (refund.body_idx as usize == idx).then_some(refund.percent)
180                                })
181                            });
182                        let refund_configs =
183                            validity.as_ref().and_then(|v| v.refund_config.clone());
184
185                        // Create FlattenedBundleItem with current inclusion, validity, and privacy
186                        let flattened_item = FlattenedBundleItem {
187                            tx,
188                            can_revert: *can_revert,
189                            inclusion: inclusion.clone(),
190                            validity: validity.clone(),
191                            privacy: privacy.clone(),
192                            refund_percent,
193                            refund_configs,
194                        };
195
196                        // Add to items
197                        items.push(flattened_item);
198
199                        idx += 1;
200                    }
201                    BundleItem::Bundle { bundle } => {
202                        // Push the current bundle and next index onto the stack to resume later
203                        stack.push((current_bundle, idx + 1, depth));
204
205                        // process the nested bundle next
206                        stack.push((bundle, 0, depth + 1));
207                        break;
208                    }
209                    BundleItem::Hash { hash: _ } => {
210                        // Hash-only items are not allowed
211                        return Err(EthApiError::InvalidParams(
212                            EthSimBundleError::InvalidBundle.to_string(),
213                        ));
214                    }
215                }
216            }
217        }
218
219        Ok(items)
220    }
221
222    async fn sim_bundle_inner(
223        &self,
224        request: MevSendBundle,
225        overrides: SimBundleOverrides,
226        logs: bool,
227    ) -> Result<SimBundleResponse, Eth::Error> {
228        let SimBundleOverrides { parent_block, block_overrides, .. } = overrides;
229
230        // Parse and validate bundle
231        // Also, flatten the bundle here so that its easier to process
232        let flattened_bundle = self.parse_and_flatten_bundle(&request)?;
233
234        let block_id = parent_block.unwrap_or(BlockId::Number(BlockNumberOrTag::Latest));
235        let (mut evm_env, current_block_id) = self.eth_api().evm_env_at(block_id).await?;
236        let current_block = self.eth_api().recovered_block(current_block_id).await?;
237        let current_block = current_block.ok_or(EthApiError::HeaderNotFound(block_id))?;
238
239        let eth_api = self.inner.eth_api.clone();
240
241        let sim_response = self
242            .inner
243            .eth_api
244            .spawn_with_state_at_block(current_block_id, move |state| {
245                // Setup environment
246                let current_block_number = current_block.number();
247                let coinbase = evm_env.block_env.beneficiary();
248                let basefee = evm_env.block_env.basefee();
249                let mut db =
250                    State::builder().with_database(StateProviderDatabase::new(state)).build();
251
252                // apply overrides
253                apply_block_overrides(block_overrides, &mut db, evm_env.block_env.inner_mut());
254
255                let initial_coinbase_balance = DatabaseRef::basic_ref(&db, coinbase)
256                    .map_err(EthApiError::from_eth_err)?
257                    .map(|acc| acc.balance)
258                    .unwrap_or_default();
259
260                let mut coinbase_balance_before_tx = initial_coinbase_balance;
261                let mut total_gas_used = 0;
262                let mut total_profit = U256::ZERO;
263                let mut refundable_value = U256::ZERO;
264                let mut body_logs: Vec<SimBundleLogs> = Vec::new();
265
266                let mut evm = eth_api.evm_config().evm_with_env(db, evm_env);
267                let mut log_index = 0;
268
269                for (tx_index, item) in flattened_bundle.iter().enumerate() {
270                    // Check inclusion constraints
271                    let block_number = item.inclusion.block_number();
272                    let max_block_number =
273                        item.inclusion.max_block_number().unwrap_or(block_number);
274
275                    if current_block_number < block_number ||
276                        current_block_number > max_block_number
277                    {
278                        return Err(EthApiError::InvalidParams(
279                            EthSimBundleError::InvalidInclusion.to_string(),
280                        )
281                        .into());
282                    }
283
284                    let ResultAndState { result, state } = evm
285                        .transact(eth_api.evm_config().tx_env(&item.tx))
286                        .map_err(Eth::Error::from_evm_err)?;
287
288                    if !result.is_success() && !item.can_revert {
289                        return Err(EthApiError::InvalidParams(
290                            EthSimBundleError::BundleTransactionFailed.to_string(),
291                        )
292                        .into());
293                    }
294
295                    let gas_used = result.gas_used();
296                    total_gas_used += gas_used;
297
298                    // coinbase is always present in the result state
299                    let coinbase_balance_after_tx =
300                        state.get(&coinbase).map(|acc| acc.info.balance).unwrap_or_default();
301
302                    let coinbase_diff =
303                        coinbase_balance_after_tx.saturating_sub(coinbase_balance_before_tx);
304                    total_profit += coinbase_diff;
305
306                    // Add to refundable value if this tx does not have a refund percent
307                    if item.refund_percent.is_none() {
308                        refundable_value += coinbase_diff;
309                    }
310
311                    // Update coinbase balance before next tx
312                    coinbase_balance_before_tx = coinbase_balance_after_tx;
313
314                    // Collect logs if requested
315                    // TODO: since we are looping over iteratively, we are not collecting bundle
316                    // logs. We should collect bundle logs when we are processing the bundle items.
317                    if logs {
318                        let tx_logs = result
319                            .logs()
320                            .iter()
321                            .map(|log| {
322                                let full_log = alloy_rpc_types_eth::Log {
323                                    inner: log.clone(),
324                                    block_hash: None,
325                                    block_number: None,
326                                    block_timestamp: None,
327                                    transaction_hash: Some(*item.tx.tx_hash()),
328                                    transaction_index: Some(tx_index as u64),
329                                    log_index: Some(log_index),
330                                    removed: false,
331                                };
332                                log_index += 1;
333                                full_log
334                            })
335                            .collect();
336                        let sim_bundle_logs =
337                            SimBundleLogs { tx_logs: Some(tx_logs), bundle_logs: None };
338                        body_logs.push(sim_bundle_logs);
339                    }
340
341                    // Apply state changes
342                    evm.db_mut().commit(state);
343                }
344
345                // After processing all transactions, process refunds
346                for item in &flattened_bundle {
347                    if let Some(refund_percent) = item.refund_percent {
348                        // Get refund configurations
349                        let refund_configs = item.refund_configs.clone().unwrap_or_else(|| {
350                            vec![RefundConfig { address: item.tx.signer(), percent: 100 }]
351                        });
352
353                        // Calculate payout transaction fee
354                        let payout_tx_fee = U256::from(basefee) *
355                            U256::from(SBUNDLE_PAYOUT_MAX_COST) *
356                            U256::from(refund_configs.len() as u64);
357
358                        // Add gas used for payout transactions
359                        total_gas_used += SBUNDLE_PAYOUT_MAX_COST * refund_configs.len() as u64;
360
361                        // Calculate allocated refundable value (payout value)
362                        let payout_value =
363                            refundable_value * U256::from(refund_percent) / U256::from(100);
364
365                        if payout_tx_fee > payout_value {
366                            return Err(EthApiError::InvalidParams(
367                                EthSimBundleError::NegativeProfit.to_string(),
368                            )
369                            .into());
370                        }
371
372                        // Subtract payout value from total profit
373                        total_profit = total_profit.checked_sub(payout_value).ok_or(
374                            EthApiError::InvalidParams(
375                                EthSimBundleError::NegativeProfit.to_string(),
376                            ),
377                        )?;
378
379                        // Adjust refundable value
380                        refundable_value = refundable_value.checked_sub(payout_value).ok_or(
381                            EthApiError::InvalidParams(
382                                EthSimBundleError::NegativeProfit.to_string(),
383                            ),
384                        )?;
385                    }
386                }
387
388                // Calculate mev gas price
389                let mev_gas_price = if total_gas_used != 0 {
390                    total_profit / U256::from(total_gas_used)
391                } else {
392                    U256::ZERO
393                };
394
395                Ok(SimBundleResponse {
396                    success: true,
397                    state_block: current_block_number,
398                    error: None,
399                    logs: Some(body_logs),
400                    gas_used: total_gas_used,
401                    mev_gas_price,
402                    profit: total_profit,
403                    refundable_value,
404                    exec_error: None,
405                    revert: None,
406                })
407            })
408            .await?;
409
410        Ok(sim_response)
411    }
412}
413
414#[async_trait::async_trait]
415impl<Eth> MevSimApiServer for EthSimBundle<Eth>
416where
417    Eth: EthTransactions + LoadBlock + Call + 'static,
418{
419    async fn sim_bundle(
420        &self,
421        request: MevSendBundle,
422        overrides: SimBundleOverrides,
423    ) -> RpcResult<SimBundleResponse> {
424        trace!("mev_simBundle called, request: {:?}, overrides: {:?}", request, overrides);
425
426        let override_timeout = overrides.timeout;
427
428        let timeout = override_timeout
429            .map(Duration::from_secs)
430            .map(|d| d.min(MAX_SIM_TIMEOUT))
431            .unwrap_or(DEFAULT_SIM_TIMEOUT);
432
433        let bundle_res =
434            tokio::time::timeout(timeout, Self::sim_bundle_inner(self, request, overrides, true))
435                .await
436                .map_err(|_| {
437                    EthApiError::InvalidParams(EthSimBundleError::BundleTimeout.to_string())
438                })?;
439
440        bundle_res.map_err(Into::into)
441    }
442}
443
444/// Container type for `EthSimBundle` internals
445#[derive(Debug)]
446struct EthSimBundleInner<Eth> {
447    /// Access to commonly used code of the `eth` namespace
448    eth_api: Eth,
449    // restrict the number of concurrent tracing calls.
450    #[expect(dead_code)]
451    blocking_task_guard: BlockingTaskGuard,
452}
453
454impl<Eth> std::fmt::Debug for EthSimBundle<Eth> {
455    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456        f.debug_struct("EthSimBundle").finish_non_exhaustive()
457    }
458}
459
460impl<Eth> Clone for EthSimBundle<Eth> {
461    fn clone(&self) -> Self {
462        Self { inner: Arc::clone(&self.inner) }
463    }
464}
465
466/// [`EthSimBundle`] specific errors.
467#[derive(Debug, thiserror::Error)]
468pub enum EthSimBundleError {
469    /// Thrown when max depth is reached
470    #[error("max depth reached")]
471    MaxDepth,
472    /// Thrown when a bundle is unmatched
473    #[error("unmatched bundle")]
474    UnmatchedBundle,
475    /// Thrown when a bundle is too large
476    #[error("bundle too large")]
477    BundleTooLarge,
478    /// Thrown when validity is invalid
479    #[error("invalid validity")]
480    InvalidValidity,
481    /// Thrown when inclusion is invalid
482    #[error("invalid inclusion")]
483    InvalidInclusion,
484    /// Thrown when a bundle is invalid
485    #[error("invalid bundle")]
486    InvalidBundle,
487    /// Thrown when a bundle simulation times out
488    #[error("bundle simulation timed out")]
489    BundleTimeout,
490    /// Thrown when a transaction is reverted in a bundle
491    #[error("bundle transaction failed")]
492    BundleTransactionFailed,
493    /// Thrown when a bundle simulation returns negative profit
494    #[error("bundle simulation returned negative profit")]
495    NegativeProfit,
496}