1use 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
29const MAX_NESTED_BUNDLE_DEPTH: usize = 5;
31
32const MAX_BUNDLE_BODY_SIZE: usize = 50;
34
35const DEFAULT_SIM_TIMEOUT: Duration = Duration::from_secs(5);
37
38const MAX_SIM_TIMEOUT: Duration = Duration::from_secs(30);
40
41const SBUNDLE_PAYOUT_MAX_COST: u64 = 30_000;
43
44#[derive(Clone, Debug)]
46pub struct FlattenedBundleItem<T> {
47 pub tx: Recovered<T>,
49 pub can_revert: bool,
51 pub inclusion: Inclusion,
53 pub validity: Option<Validity>,
55 pub privacy: Option<Privacy>,
57 pub refund_percent: Option<u64>,
59 pub refund_configs: Option<Vec<RefundConfig>>,
61}
62
63pub struct EthSimBundle<Eth> {
65 inner: Arc<EthSimBundleInner<Eth>>,
67}
68
69impl<Eth> EthSimBundle<Eth> {
70 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 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 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 let mut stack = Vec::new();
97
98 stack.push((request, 0, 1));
100
101 while let Some((current_bundle, mut idx, depth)) = stack.pop() {
102 if depth > MAX_NESTED_BUNDLE_DEPTH {
104 return Err(EthApiError::InvalidParams(EthSimBundleError::MaxDepth.to_string()));
105 }
106
107 let inclusion = ¤t_bundle.inclusion;
109 let validity = ¤t_bundle.validity;
110 let privacy = ¤t_bundle.privacy;
111
112 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 if current_bundle.bundle_body.len() > MAX_BUNDLE_BODY_SIZE {
124 return Err(EthApiError::InvalidParams(
125 EthSimBundleError::BundleTooLarge.to_string(),
126 ));
127 }
128
129 if let Some(validity) = ¤t_bundle.validity {
131 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 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 = ¤t_bundle.bundle_body;
164
165 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 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 items.push(flattened_item);
196
197 idx += 1;
198 }
199 BundleItem::Bundle { bundle } => {
200 stack.push((current_bundle, idx + 1, depth));
202
203 stack.push((bundle, 0, depth + 1));
205 break;
206 }
207 BundleItem::Hash { hash: _ } => {
208 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 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 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_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 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 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 if item.refund_percent.is_none() {
305 refundable_value += coinbase_diff;
306 }
307
308 coinbase_balance_before_tx = coinbase_balance_after_tx;
310
311 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 evm.db_mut().commit(state);
340 }
341
342 for item in &flattened_bundle {
344 if let Some(refund_percent) = item.refund_percent {
345 let refund_configs = item.refund_configs.clone().unwrap_or_else(|| {
347 vec![RefundConfig { address: item.tx.signer(), percent: 100 }]
348 });
349
350 let payout_tx_fee = U256::from(basefee) *
352 U256::from(SBUNDLE_PAYOUT_MAX_COST) *
353 U256::from(refund_configs.len() as u64);
354
355 total_gas_used += SBUNDLE_PAYOUT_MAX_COST * refund_configs.len() as u64;
357
358 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 total_profit = total_profit.checked_sub(payout_value).ok_or(
371 EthApiError::InvalidParams(
372 EthSimBundleError::NegativeProfit.to_string(),
373 ),
374 )?;
375
376 refundable_value = refundable_value.checked_sub(payout_value).ok_or(
378 EthApiError::InvalidParams(
379 EthSimBundleError::NegativeProfit.to_string(),
380 ),
381 )?;
382 }
383 }
384
385 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#[derive(Debug)]
443struct EthSimBundleInner<Eth> {
444 eth_api: Eth,
446 #[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#[derive(Debug, thiserror::Error)]
465pub enum EthSimBundleError {
466 #[error("max depth reached")]
468 MaxDepth,
469 #[error("unmatched bundle")]
471 UnmatchedBundle,
472 #[error("bundle too large")]
474 BundleTooLarge,
475 #[error("invalid validity")]
477 InvalidValidity,
478 #[error("invalid inclusion")]
480 InvalidInclusion,
481 #[error("invalid bundle")]
483 InvalidBundle,
484 #[error("bundle simulation timed out")]
486 BundleTimeout,
487 #[error("bundle transaction failed")]
489 BundleTransactionFailed,
490 #[error("bundle simulation returned negative profit")]
492 NegativeProfit,
493}