1use 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
31const MAX_NESTED_BUNDLE_DEPTH: usize = 5;
33
34const MAX_BUNDLE_BODY_SIZE: usize = 50;
36
37const DEFAULT_SIM_TIMEOUT: Duration = Duration::from_secs(5);
39
40const MAX_SIM_TIMEOUT: Duration = Duration::from_secs(30);
42
43const SBUNDLE_PAYOUT_MAX_COST: u64 = 30_000;
45
46#[derive(Clone, Debug)]
48pub struct FlattenedBundleItem<T> {
49 pub tx: Recovered<T>,
51 pub can_revert: bool,
53 pub inclusion: Inclusion,
55 pub validity: Option<Validity>,
57 pub privacy: Option<Privacy>,
59 pub refund_percent: Option<u64>,
61 pub refund_configs: Option<Vec<RefundConfig>>,
63}
64
65pub struct EthSimBundle<Eth> {
67 inner: Arc<EthSimBundleInner<Eth>>,
69}
70
71impl<Eth> EthSimBundle<Eth> {
72 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 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 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 let mut stack = Vec::new();
99
100 stack.push((request, 0, 1));
102
103 while let Some((current_bundle, mut idx, depth)) = stack.pop() {
104 if depth > MAX_NESTED_BUNDLE_DEPTH {
106 return Err(EthApiError::InvalidParams(EthSimBundleError::MaxDepth.to_string()));
107 }
108
109 let inclusion = ¤t_bundle.inclusion;
111 let validity = ¤t_bundle.validity;
112 let privacy = ¤t_bundle.privacy;
113
114 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 if current_bundle.bundle_body.len() > MAX_BUNDLE_BODY_SIZE {
126 return Err(EthApiError::InvalidParams(
127 EthSimBundleError::BundleTooLarge.to_string(),
128 ));
129 }
130
131 if let Some(validity) = ¤t_bundle.validity {
133 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 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 = ¤t_bundle.bundle_body;
166
167 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 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 items.push(flattened_item);
198
199 idx += 1;
200 }
201 BundleItem::Bundle { bundle } => {
202 stack.push((current_bundle, idx + 1, depth));
204
205 stack.push((bundle, 0, depth + 1));
207 break;
208 }
209 BundleItem::Hash { hash: _ } => {
210 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 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 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_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 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 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 if item.refund_percent.is_none() {
308 refundable_value += coinbase_diff;
309 }
310
311 coinbase_balance_before_tx = coinbase_balance_after_tx;
313
314 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 evm.db_mut().commit(state);
343 }
344
345 for item in &flattened_bundle {
347 if let Some(refund_percent) = item.refund_percent {
348 let refund_configs = item.refund_configs.clone().unwrap_or_else(|| {
350 vec![RefundConfig { address: item.tx.signer(), percent: 100 }]
351 });
352
353 let payout_tx_fee = U256::from(basefee) *
355 U256::from(SBUNDLE_PAYOUT_MAX_COST) *
356 U256::from(refund_configs.len() as u64);
357
358 total_gas_used += SBUNDLE_PAYOUT_MAX_COST * refund_configs.len() as u64;
360
361 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 total_profit = total_profit.checked_sub(payout_value).ok_or(
374 EthApiError::InvalidParams(
375 EthSimBundleError::NegativeProfit.to_string(),
376 ),
377 )?;
378
379 refundable_value = refundable_value.checked_sub(payout_value).ok_or(
381 EthApiError::InvalidParams(
382 EthSimBundleError::NegativeProfit.to_string(),
383 ),
384 )?;
385 }
386 }
387
388 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#[derive(Debug)]
446struct EthSimBundleInner<Eth> {
447 eth_api: Eth,
449 #[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#[derive(Debug, thiserror::Error)]
468pub enum EthSimBundleError {
469 #[error("max depth reached")]
471 MaxDepth,
472 #[error("unmatched bundle")]
474 UnmatchedBundle,
475 #[error("bundle too large")]
477 BundleTooLarge,
478 #[error("invalid validity")]
480 InvalidValidity,
481 #[error("invalid inclusion")]
483 InvalidInclusion,
484 #[error("invalid bundle")]
486 InvalidBundle,
487 #[error("bundle simulation timed out")]
489 BundleTimeout,
490 #[error("bundle transaction failed")]
492 BundleTransactionFailed,
493 #[error("bundle simulation returned negative profit")]
495 NegativeProfit,
496}