reth_rpc/eth/
sim_bundle.rs

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