1use 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
30const MAX_NESTED_BUNDLE_DEPTH: usize = 5;
32
33const MAX_BUNDLE_BODY_SIZE: usize = 50;
35
36const DEFAULT_SIM_TIMEOUT: Duration = Duration::from_secs(5);
38
39const MAX_SIM_TIMEOUT: Duration = Duration::from_secs(30);
41
42const SBUNDLE_PAYOUT_MAX_COST: u64 = 30_000;
44
45#[derive(Clone, Debug)]
47pub struct FlattenedBundleItem<T> {
48 pub tx: Recovered<T>,
50 pub can_revert: bool,
52 pub inclusion: Inclusion,
54 pub validity: Option<Validity>,
56 pub privacy: Option<Privacy>,
58 pub refund_percent: Option<u64>,
60 pub refund_configs: Option<Vec<RefundConfig>>,
62}
63
64pub struct EthSimBundle<Eth> {
66 inner: Arc<EthSimBundleInner<Eth>>,
68}
69
70impl<Eth> EthSimBundle<Eth> {
71 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 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 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 let mut stack = Vec::new();
98
99 stack.push((request, 0, 1));
101
102 while let Some((current_bundle, mut idx, depth)) = stack.pop() {
103 if depth > MAX_NESTED_BUNDLE_DEPTH {
105 return Err(EthApiError::InvalidParams(EthSimBundleError::MaxDepth.to_string()));
106 }
107
108 let inclusion = ¤t_bundle.inclusion;
110 let validity = ¤t_bundle.validity;
111 let privacy = ¤t_bundle.privacy;
112
113 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 if current_bundle.bundle_body.len() > MAX_BUNDLE_BODY_SIZE {
125 return Err(EthApiError::InvalidParams(
126 EthSimBundleError::BundleTooLarge.to_string(),
127 ));
128 }
129
130 if let Some(validity) = ¤t_bundle.validity {
132 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 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 = ¤t_bundle.bundle_body;
165
166 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 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 items.push(flattened_item);
197
198 idx += 1;
199 }
200 BundleItem::Bundle { bundle } => {
201 stack.push((current_bundle, idx + 1, depth));
203
204 stack.push((bundle, 0, depth + 1));
206 break;
207 }
208 BundleItem::Hash { hash: _ } => {
209 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 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 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_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 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 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 if item.refund_percent.is_none() {
306 refundable_value += coinbase_diff;
307 }
308
309 coinbase_balance_before_tx = coinbase_balance_after_tx;
311
312 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 evm.db_mut().commit(state);
341 }
342
343 for item in &flattened_bundle {
345 if let Some(refund_percent) = item.refund_percent {
346 let refund_configs = item.refund_configs.clone().unwrap_or_else(|| {
348 vec![RefundConfig { address: item.tx.signer(), percent: 100 }]
349 });
350
351 let payout_tx_fee = U256::from(basefee) *
353 U256::from(SBUNDLE_PAYOUT_MAX_COST) *
354 U256::from(refund_configs.len() as u64);
355
356 total_gas_used += SBUNDLE_PAYOUT_MAX_COST * refund_configs.len() as u64;
358
359 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 total_profit = total_profit.checked_sub(payout_value).ok_or(
372 EthApiError::InvalidParams(
373 EthSimBundleError::NegativeProfit.to_string(),
374 ),
375 )?;
376
377 refundable_value = refundable_value.checked_sub(payout_value).ok_or(
379 EthApiError::InvalidParams(
380 EthSimBundleError::NegativeProfit.to_string(),
381 ),
382 )?;
383 }
384 }
385
386 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#[derive(Debug)]
444struct EthSimBundleInner<Eth> {
445 eth_api: Eth,
447 #[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#[derive(Debug, thiserror::Error)]
466pub enum EthSimBundleError {
467 #[error("max depth reached")]
469 MaxDepth,
470 #[error("unmatched bundle")]
472 UnmatchedBundle,
473 #[error("bundle too large")]
475 BundleTooLarge,
476 #[error("invalid validity")]
478 InvalidValidity,
479 #[error("invalid inclusion")]
481 InvalidInclusion,
482 #[error("invalid bundle")]
484 InvalidBundle,
485 #[error("bundle simulation timed out")]
487 BundleTimeout,
488 #[error("bundle transaction failed")]
490 BundleTransactionFailed,
491 #[error("bundle simulation returned negative profit")]
493 NegativeProfit,
494}