reth_rpc/eth/
sim_bundle.rs

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