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