use std::{
collections::{hash_map::Entry, HashMap},
sync::Arc,
};
use alloy_primitives::TxHash;
use tracing::trace;
mod types;
use brontes_database::libmdbx::LibmdbxReader;
use brontes_metrics::inspectors::OutlierMetrics;
use brontes_types::{
db::dex::PriceAt,
mev::{Bundle, BundleData, MevType, Sandwich},
normalized_actions::{
accounting::ActionAccounting, Action, NormalizedSwap, NormalizedTransfer,
},
tree::{collect_address_set_for_accounting, BlockTree, GasDetails},
ActionIter, BlockData, FastHashMap, FastHashSet, IntoZipTree, MultiBlockData, ToFloatNearest,
TreeBase, TreeCollector, TreeIter, TreeSearchBuilder, TxInfo, UnzipPadded,
};
use itertools::Itertools;
use malachite::{num::basic::traits::Zero, Rational};
use reth_primitives::{Address, B256};
use types::{PossibleSandwich, PossibleSandwichWithTxInfo};
use super::MAX_PROFIT;
use crate::{shared_utils::SharedInspectorUtils, Inspector, Metadata, MIN_PROFIT};
type GroupedVictims<'a> = HashMap<Address, Vec<&'a (Vec<NormalizedSwap>, Vec<NormalizedTransfer>)>>;
type VictimSetActions = Option<Vec<Vec<(Vec<NormalizedSwap>, Vec<NormalizedTransfer>)>>>;
const MAX_PRICE_DIFF: Rational = Rational::const_from_unsigneds(995, 1000);
const MAX_NON_SWAP_FRONTRUN: Rational = Rational::const_from_unsigned(5000);
pub struct SandwichInspector<'db, DB: LibmdbxReader> {
utils: SharedInspectorUtils<'db, DB>,
}
impl<'db, DB: LibmdbxReader> SandwichInspector<'db, DB> {
pub fn new(quote: Address, db: &'db DB, metrics: Option<OutlierMetrics>) -> Self {
Self { utils: SharedInspectorUtils::new(quote, db, metrics) }
}
}
impl<DB: LibmdbxReader> Inspector for SandwichInspector<'_, DB> {
type Result = Vec<Bundle>;
fn get_id(&self) -> &str {
"Sandwich"
}
fn get_quote_token(&self) -> Address {
self.utils.quote
}
fn inspect_block(&self, data: MultiBlockData) -> Self::Result {
let BlockData { metadata, tree } = data.get_most_recent_block();
self.utils
.get_metrics()
.map(|m| {
m.run_inspector(MevType::Sandwich, || {
self.inspect_block_inner(tree.clone(), metadata.clone())
})
})
.unwrap_or_else(|| self.inspect_block_inner(tree.clone(), metadata.clone()))
}
}
impl<DB: LibmdbxReader> SandwichInspector<'_, DB> {
fn inspect_block_inner(
&self,
tree: Arc<BlockTree<Action>>,
metadata: Arc<Metadata>,
) -> Vec<Bundle> {
tracing::trace!("starting sandwich");
let search_args = TreeSearchBuilder::default().with_actions([
Action::is_swap,
Action::is_transfer,
Action::is_eth_transfer,
Action::is_nested_action,
]);
self.get_possible_sandwich(tree.clone())
.into_iter()
.filter_map(|ps| {
self.collect_baseline_sandwich_data(
tree.clone(),
search_args.clone(),
ps,
metadata.clone(),
)
})
.flatten()
.collect::<Vec<_>>()
}
fn collect_baseline_sandwich_data(
&self,
tree: Arc<BlockTree<Action>>,
search_args: TreeSearchBuilder<Action>,
ps: PossibleSandwichWithTxInfo,
metadata: Arc<Metadata>,
) -> Option<Vec<Bundle>> {
let PossibleSandwichWithTxInfo {
inner:
PossibleSandwich {
possible_frontruns,
possible_backrun,
mev_executor_contract,
victims,
..
},
victims_info,
possible_frontruns_info,
possible_backrun_info,
} = ps;
if victims.iter().flatten().count() == 0 {
return None
};
let victim_swaps_transfers: Vec<_> = self.get_victim_swap_transfer(
victims,
tree.clone(),
search_args.clone(),
mev_executor_contract,
)?;
let searcher_actions: Vec<Vec<Action>> = tree
.clone()
.collect_txes(
possible_frontruns
.iter()
.copied()
.chain(std::iter::once(possible_backrun))
.collect::<Vec<_>>()
.as_slice(),
search_args.clone(),
)
.map(|actions| {
self.utils
.flatten_nested_actions_default(actions.into_iter())
.collect_vec()
})
.collect::<Vec<_>>();
let black_list: FastHashSet<Address> =
collect_address_set_for_accounting(&possible_frontruns_info);
self.calculate_sandwich(
tree.clone(),
metadata.clone(),
possible_frontruns_info,
possible_backrun_info,
searcher_actions,
victims_info,
victim_swaps_transfers,
black_list,
0,
)
}
fn calculate_sandwich(
&self,
tree: Arc<BlockTree<Action>>,
metadata: Arc<Metadata>,
possible_front_runs_info: Vec<TxInfo>,
backrun_info: TxInfo,
mut searcher_actions: Vec<Vec<Action>>,
victim_info: Vec<Vec<TxInfo>>,
victim_actions: Vec<Vec<(Vec<NormalizedSwap>, Vec<NormalizedTransfer>)>>,
black_list: FastHashSet<Address>,
recusive: u8,
) -> Option<Vec<Bundle>> {
if !(possible_front_runs_info
.iter()
.chain(vec![&backrun_info])
.all(|f| f.mev_contract.is_some())
|| possible_front_runs_info
.iter()
.chain(vec![&backrun_info])
.map(|f| f.eoa)
.unique()
.count()
== 1)
{
tracing::debug!(target: "brontes_inspect::sandwich", "all sandwiches don't have same eoa and aren't all verified contracts");
return None
}
let mut mev_addresses: FastHashSet<Address> =
collect_address_set_for_accounting(&possible_front_runs_info);
let backrun_addresses: FastHashSet<Address> =
collect_address_set_for_accounting(std::slice::from_ref(&backrun_info));
mev_addresses.extend(backrun_addresses);
let possible_searcher_swaps = searcher_actions
.iter()
.map(|action| {
let (mut swaps, transfers): (Vec<_>, Vec<_>) = action
.iter()
.cloned()
.split_actions((Action::try_swaps_merged, Action::try_transfer));
swaps.extend(
self.utils
.try_create_swaps(&transfers, mev_addresses.clone()),
);
swaps
})
.collect::<Vec<_>>();
if !possible_searcher_swaps
.iter()
.all(|searcher_tx_swaps| !searcher_tx_swaps.is_empty())
{
return None
}
let back_run_actions = searcher_actions.pop()?;
if !Self::has_pool_overlap(
&searcher_actions,
&back_run_actions,
&victim_actions,
&victim_info,
&black_list,
) {
return self.recursive_possible_sandwiches(
tree.clone(),
metadata.clone(),
&possible_front_runs_info,
backrun_info,
&back_run_actions,
&searcher_actions,
&victim_info,
&victim_actions,
black_list,
recusive,
)
}
let victim_swaps = victim_actions.into_iter().flatten().collect::<Vec<_>>();
let back_run_swaps = back_run_actions
.clone()
.into_iter()
.collect_action_vec(Action::try_swaps_merged);
let front_run_swaps = searcher_actions
.clone()
.into_iter()
.map(|action| {
action
.into_iter()
.collect_action_vec(Action::try_swaps_merged)
})
.collect::<Vec<_>>();
let (frontrun_tx_hash, frontrun_gas_details): (Vec<_>, Vec<_>) = possible_front_runs_info
.clone()
.into_iter()
.map(|info| info.split_to_storage_info())
.unzip();
let (victim_swaps_tx_hashes, victim_swaps_gas_details): (Vec<_>, Vec<_>) = victim_info
.clone()
.into_iter()
.map(|info| {
info.into_iter()
.map(|info| info.split_to_storage_info())
.unzip::<B256, GasDetails, Vec<B256>, Vec<GasDetails>>()
})
.unzip();
let gas_used = frontrun_gas_details
.iter()
.chain([backrun_info.gas_details].iter())
.map(|g| g.gas_paid())
.sum::<u128>();
let gas_used = metadata.get_gas_price_usd(gas_used, self.utils.quote);
let searcher_deltas = searcher_actions
.into_iter()
.flatten()
.chain(back_run_actions)
.filter(|f| f.is_transfer() || f.is_eth_transfer())
.chain(
possible_front_runs_info
.iter()
.chain(vec![backrun_info.clone()].iter())
.flat_map(|info| info.get_total_eth_value())
.cloned()
.map(Action::from),
)
.account_for_actions();
let mut has_dex_price = true;
for (swaps, info) in front_run_swaps.iter().zip(&possible_front_runs_info) {
has_dex_price &= self.utils.valid_pricing(
metadata.clone(),
swaps,
searcher_deltas
.values()
.flat_map(|k| {
k.iter()
.filter(|(_, v)| *v != &Rational::ZERO)
.map(|(k, _)| k)
})
.unique(),
info.tx_index as usize,
MAX_PRICE_DIFF,
MevType::Sandwich,
);
}
has_dex_price &= self.utils.valid_pricing(
metadata.clone(),
&back_run_swaps,
searcher_deltas
.values()
.flat_map(|k| {
k.iter()
.filter(|(_, v)| *v != &Rational::ZERO)
.map(|(k, _)| k)
})
.unique(),
backrun_info.tx_index as usize,
MAX_PRICE_DIFF,
MevType::Sandwich,
);
let mut mev_addresses: FastHashSet<Address> =
collect_address_set_for_accounting(&possible_front_runs_info);
let backrun_addresses: FastHashSet<Address> =
collect_address_set_for_accounting(std::slice::from_ref(&backrun_info));
mev_addresses.extend(backrun_addresses);
let rev = if let Some(rev) = self.utils.get_deltas_usd(
backrun_info.tx_index,
PriceAt::After,
&mev_addresses,
&searcher_deltas,
metadata.clone(),
true,
) {
Some(rev)
} else {
has_dex_price = false;
Some(Rational::ZERO)
};
let mut profit_usd = rev
.map(|rev| rev - &gas_used)
.filter(|_| has_dex_price)
.unwrap_or_default();
if profit_usd >= MAX_PROFIT || profit_usd <= MIN_PROFIT {
has_dex_price = false;
profit_usd = Rational::ZERO;
}
if front_run_swaps.iter().flatten().count() == 0 && profit_usd > MAX_NON_SWAP_FRONTRUN {
tracing::warn!("frontrun has no swaps");
profit_usd = Rational::ZERO;
has_dex_price = false;
}
let gas_details: Vec<_> = possible_front_runs_info
.iter()
.chain(std::iter::once(&backrun_info))
.map(|info| info.gas_details)
.collect();
let mut bundle_hashes = Vec::new();
for (index, frontrun_hash) in frontrun_tx_hash.iter().enumerate() {
bundle_hashes.push(*frontrun_hash);
if let Some(victim_hashes) = victim_swaps_tx_hashes.get(index) {
bundle_hashes.extend_from_slice(victim_hashes);
}
}
bundle_hashes.push(backrun_info.tx_hash);
let header = self.utils.build_bundle_header(
vec![searcher_deltas],
bundle_hashes,
&backrun_info,
profit_usd.to_float(),
&gas_details,
metadata.clone(),
MevType::Sandwich,
!has_dex_price,
|this, token, amount| {
this.get_token_value_dex(
backrun_info.tx_index as usize,
PriceAt::Average,
token,
&amount,
&metadata,
)
},
);
let victim_swaps = victim_swaps.into_iter().map(|(s, _)| s).collect_vec();
let sandwich = Sandwich {
block_number: metadata.block_num,
frontrun_tx_hash,
frontrun_gas_details,
frontrun_swaps: front_run_swaps,
victim_swaps_tx_hashes,
victim_swaps_gas_details: victim_swaps_gas_details.into_iter().flatten().collect(),
victim_swaps,
backrun_tx_hash: backrun_info.tx_hash,
backrun_swaps: back_run_swaps,
backrun_gas_details: backrun_info.gas_details,
};
tracing::debug!("{:#?}\n{:#?}", header, sandwich);
Some(vec![Bundle { header, data: BundleData::Sandwich(sandwich) }])
}
fn recursive_possible_sandwiches(
&self,
tree: Arc<BlockTree<Action>>,
metadata: Arc<Metadata>,
possible_front_runs_info: &[TxInfo],
backrun_info: TxInfo,
back_run_actions: &[Action],
searcher_actions: &[Vec<Action>],
victim_info: &[Vec<TxInfo>],
victim_actions: &[Vec<(Vec<NormalizedSwap>, Vec<NormalizedTransfer>)>],
black_list: FastHashSet<Address>,
mut recursive: u8,
) -> Option<Vec<Bundle>> {
let mut res = vec![];
if recursive >= 6 {
return None
}
if possible_front_runs_info.len() > 1 {
recursive += 1;
if victim_info.is_empty() || victim_actions.is_empty() {
return None
}
let back_shrink = {
let mut victim_info = victim_info.to_vec();
let mut victim_actions = victim_actions.to_vec();
let mut possible_front_runs_info = possible_front_runs_info.to_vec();
victim_info.pop()?;
victim_actions.pop()?;
let back_run_info = possible_front_runs_info.pop()?;
if victim_actions
.iter()
.flatten()
.filter_map(
|(s, t)| if s.is_empty() && t.is_empty() { None } else { Some(true) },
)
.count()
== 0
{
return None
}
self.calculate_sandwich(
tree.clone(),
metadata.clone(),
possible_front_runs_info,
back_run_info,
searcher_actions.to_vec(),
victim_info,
victim_actions,
black_list.clone(),
recursive,
)
};
let front_shrink = {
let mut victim_info = victim_info.to_vec();
let mut victim_actions = victim_actions.to_vec();
let mut possible_front_runs_info = possible_front_runs_info.to_vec();
let mut searcher_actions = searcher_actions.to_vec();
searcher_actions.push(back_run_actions.to_vec());
victim_info.remove(0);
victim_actions.remove(0);
possible_front_runs_info.remove(0);
searcher_actions.remove(0);
if victim_actions
.iter()
.flatten()
.filter_map(
|(s, t)| if s.is_empty() && t.is_empty() { None } else { Some(true) },
)
.count()
== 0
{
return None
}
self.calculate_sandwich(
tree.clone(),
metadata.clone(),
possible_front_runs_info,
backrun_info,
searcher_actions,
victim_info,
victim_actions,
black_list,
recursive,
)
};
if let Some(front) = front_shrink {
res.extend(front);
}
if let Some(back) = back_shrink {
res.extend(back);
}
return Some(res)
}
None
}
fn has_pool_overlap(
front_run_swaps: &[Vec<Action>],
back_run_swaps: &[Action],
victim_actions: &[Vec<(Vec<NormalizedSwap>, Vec<NormalizedTransfer>)>],
victim_info: &[Vec<TxInfo>],
black_list: &FastHashSet<Address>,
) -> bool {
let f_swap_len = front_run_swaps.len();
for (i, (chunk_victim_actions, chunk_victim_info)) in
victim_actions.iter().zip(victim_info).enumerate()
{
let chunk_front_run_swaps = &front_run_swaps[0..=i];
let chunk_back_run_swaps = if f_swap_len > i + 1 {
let mut res = vec![];
res.extend(front_run_swaps[i + 1..].iter().flatten().cloned());
res.extend(back_run_swaps.to_vec().clone());
res
} else {
back_run_swaps.to_vec()
};
let (front_run_pools, front_run_tokens) =
Self::collect_frontrun_data(chunk_front_run_swaps, black_list);
let (back_run_pools, back_run_tokens) =
Self::collect_backrun_data(chunk_back_run_swaps, black_list);
if front_run_pools.intersection(&back_run_pools).count() == 0 {
tracing::trace!(target: "brontes_inspect::sandwich", "no pool intersection for frontrun / backrun");
}
let grouped_victims = itertools::Itertools::into_group_map(
chunk_victim_info
.iter()
.zip(chunk_victim_actions)
.map(|(info, actions)| (info.eoa, actions)),
);
if !Self::verify_sandwich_victims(
grouped_victims,
front_run_pools,
front_run_tokens,
back_run_pools,
back_run_tokens,
black_list,
) {
return false
}
}
true
}
fn verify_sandwich_victims(
grouped_victims: GroupedVictims<'_>,
front_run_pools: FastHashSet<Address>,
front_run_tokens: FastHashSet<(Address, Address, bool)>,
back_run_pools: FastHashSet<Address>,
back_run_tokens: FastHashSet<(Address, Address, bool)>,
black_list: &FastHashSet<Address>,
) -> bool {
trace!(
target: "brontes_inspect::sandwich",
"\nGrouped victims: {:#?}\n\
Front-run tokens: {:#?}\n\
Back-run tokens: {:#?}\n\
Front-run pools: {:#?}\n\
Back-run pools: {:#?}",
grouped_victims,
front_run_tokens,
back_run_tokens,
front_run_pools,
back_run_pools
);
let amount = grouped_victims.len();
if amount == 0 {
trace!(target: "brontes_inspect::sandwich", "no grouped victims");
return false
}
let mut has_sandwich = false;
let was_victims: usize = grouped_victims
.into_values()
.map(|v| {
let (front_run_pools_overlap, front_run_token_overlaps) =
Self::check_for_overlap(&v, &front_run_tokens, &front_run_pools, true);
let (back_run_pools_overlap, back_run_token_overlaps) =
Self::check_for_overlap(&v, &back_run_tokens, &back_run_pools, false);
let pools_overlap = front_run_pools_overlap
.intersection(&back_run_pools_overlap)
.count()
!= 0;
let token_overlap = front_run_token_overlaps
.intersection(&back_run_token_overlaps)
.count()
!= 0;
trace!(
target: "brontes_inspect::sandwich",
pools_overlap,
token_overlap,
front_run_pools_overlap_count = front_run_pools_overlap.len(),
back_run_pools_overlap_count = back_run_pools_overlap.len(),
front_run_token_overlaps_count = front_run_token_overlaps.len(),
back_run_token_overlaps_count = back_run_token_overlaps.len(),
"Overlap analysis for potential sandwich"
);
let generated_pool_overlap = Self::generate_possible_pools_from_transfers(
v.into_iter().flat_map(|(_, t)| t),
black_list,
)
.any(|pool| {
has_sandwich |= front_run_pools
.intersection(&back_run_pools)
.contains(&pool);
front_run_pools.contains(&pool) || back_run_pools.contains(&pool)
});
has_sandwich |= pools_overlap || token_overlap;
pools_overlap || token_overlap || generated_pool_overlap
})
.map(|was_victim| was_victim as usize)
.sum();
let victim_pct = (was_victims as f64) / (amount as f64);
trace!(lt_50pct_victims=%victim_pct, has_sandwich=has_sandwich);
victim_pct >= 0.25 && has_sandwich
}
fn check_for_overlap(
victim_actions: &[&(Vec<NormalizedSwap>, Vec<NormalizedTransfer>)],
tokens: &FastHashSet<(Address, Address, bool)>,
pools: &FastHashSet<Address>,
is_frontrun: bool,
) -> (FastHashSet<Address>, FastHashSet<(Address, Address)>) {
let mut matched_pools = FastHashSet::default();
let mut matched_tokens = FastHashSet::default();
victim_actions
.iter()
.cloned()
.filter(|(swap, transfer)| !(swap.is_empty() && transfer.is_empty()))
.for_each(|(swaps, transfers)| {
matched_pools.extend(
swaps
.iter()
.filter(|s| pools.contains(&s.pool))
.map(|p| p.pool),
);
matched_tokens.extend(transfers.iter().filter_map(|t| {
if tokens.contains(&(t.token.address, t.to, is_frontrun)) {
return Some((t.token.address, t.to))
}
if tokens.contains(&(t.token.address, t.from, !is_frontrun)) {
return Some((t.token.address, t.from))
}
None
}))
});
(matched_pools, matched_tokens)
}
fn collect_frontrun_data(
front_run: &[Vec<Action>],
black_list: &FastHashSet<Address>,
) -> (FastHashSet<Address>, FastHashSet<(Address, Address, bool)>) {
let front_run: Vec<(Vec<NormalizedSwap>, Vec<NormalizedTransfer>)> = front_run
.iter()
.map(|action| {
action
.clone()
.into_iter()
.split_actions((Action::try_swaps_merged, Action::try_transfer))
})
.collect_vec();
let (front_pools, front_tokens): (Vec<_>, Vec<_>) = front_run
.into_iter()
.map(|(swaps, transfers)| {
let front_run_pools =
Self::generate_possible_pools_from_transfers(transfers.iter(), black_list)
.chain(swaps.iter().map(|s| s.pool))
.collect::<Vec<_>>();
let front_run_tokens = Self::generate_tokens(swaps.iter(), transfers.iter());
(front_run_pools, front_run_tokens)
})
.unzip();
let front_run_pools = front_pools
.into_iter()
.flatten()
.collect::<FastHashSet<_>>();
let front_run_tokens = front_tokens
.into_iter()
.flatten()
.collect::<FastHashSet<_>>();
(front_run_pools, front_run_tokens)
}
fn collect_backrun_data(
details: Vec<Action>,
black_list: &FastHashSet<Address>,
) -> (FastHashSet<Address>, FastHashSet<(Address, Address, bool)>) {
let (back_swap, back_transfer): (Vec<NormalizedSwap>, Vec<NormalizedTransfer>) = details
.into_iter()
.split_actions((Action::try_swaps_merged, Action::try_transfer));
let back_run_pools =
Self::generate_possible_pools_from_transfers(back_transfer.iter(), black_list)
.chain(back_swap.iter().map(|s| s.pool))
.collect::<FastHashSet<_>>();
let back_run_tokens = Self::generate_tokens(back_swap.iter(), back_transfer.iter());
(back_run_pools, back_run_tokens)
}
fn generate_tokens<'a>(
swaps: impl Iterator<Item = &'a NormalizedSwap>,
transfers: impl Iterator<Item = &'a NormalizedTransfer>,
) -> FastHashSet<(Address, Address, bool)> {
swaps
.flat_map(|s| {
[(s.token_in.address, s.pool, true), (s.token_out.address, s.pool, false)]
})
.chain(
transfers.flat_map(|t| {
[(t.token.address, t.to, true), (t.token.address, t.from, false)]
}),
)
.collect::<FastHashSet<_>>()
}
fn generate_possible_pools_from_transfers<'a>(
transfers: impl Iterator<Item = &'a NormalizedTransfer>,
black_list: &'a FastHashSet<Address>,
) -> impl Iterator<Item = Address> + 'a {
itertools::Itertools::into_group_map(
transfers.flat_map(|t| [(t.to, t.clone()), (t.from, t.clone())]),
)
.into_iter()
.filter(|(address, v)| {
if v.len() != 2 || black_list.contains(address) {
return false
}
let first = v.first().unwrap();
let second = v.get(1).unwrap();
first.token.address != second.token.address && first.to != second.to
})
.map(|(k, _)| k)
}
fn get_possible_sandwich(
&self,
tree: Arc<BlockTree<Action>>,
) -> Vec<PossibleSandwichWithTxInfo> {
if tree.tx_roots.len() < 3 {
return vec![]
}
let tree_clone_for_senders = tree.clone();
let tree_clone_for_contracts = tree.clone();
let result_senders = get_possible_sandwich_duplicate_senders(tree_clone_for_senders);
let result_contracts = get_possible_sandwich_duplicate_contracts(tree_clone_for_contracts);
let set = Itertools::unique(result_senders.into_iter().chain(result_contracts))
.flat_map(Self::partition_into_gaps)
.collect::<Vec<_>>();
let tx_set = set
.iter()
.filter_map(|ps| {
let mut set = ps.possible_frontruns.clone();
set.push(ps.possible_backrun);
if ps.victims.len() > 10 {
return None
}
set.extend(ps.victims.iter().flatten().copied());
Some(set)
})
.flatten()
.unique()
.collect::<Vec<_>>();
let tx_info_map = tree
.get_tx_info_batch(&tx_set, self.utils.db)
.into_iter()
.flatten()
.map(|info| (info.tx_hash, info))
.collect::<FastHashMap<_, _>>();
set.into_iter()
.filter(|sando| {
sando.victims.len() <= 10 && sando.victims.iter().flatten().count() <= 30
})
.filter_map(|ps| PossibleSandwichWithTxInfo::from_ps(ps, &tx_info_map))
.collect_vec()
}
fn partition_into_gaps(ps: PossibleSandwich) -> Vec<PossibleSandwich> {
let PossibleSandwich {
eoa,
possible_frontruns,
possible_backrun,
mev_executor_contract,
victims,
} = ps;
let mut results = vec![];
let mut victim_sets = vec![];
let mut last_partition = 0;
victims.into_iter().enumerate().for_each(|(i, group_set)| {
if group_set.is_empty() {
results.push(PossibleSandwich {
eoa,
mev_executor_contract,
victims: std::mem::take(&mut victim_sets),
possible_frontruns: possible_frontruns[last_partition..i].to_vec(),
possible_backrun: possible_frontruns
.get(i)
.copied()
.unwrap_or(possible_backrun),
});
last_partition = i + 1;
} else {
victim_sets.push(group_set);
}
});
if results.is_empty() {
results.push(PossibleSandwich {
eoa,
mev_executor_contract,
victims: victim_sets,
possible_frontruns,
possible_backrun,
});
} else if !victim_sets.is_empty() {
results.push(PossibleSandwich {
eoa,
mev_executor_contract,
victims: victim_sets,
possible_frontruns: possible_frontruns[last_partition..].to_vec(),
possible_backrun,
});
}
results
}
fn get_victim_swap_transfer(
&self,
victims: Vec<Vec<TxHash>>,
tree: Arc<BlockTree<Action>>,
search_args: TreeSearchBuilder<Action>,
mev_executor_contract: Address,
) -> VictimSetActions {
victims
.into_iter()
.map(|victim| {
(
tree.clone()
.collect_txes(&victim, search_args.clone())
.t_map(|actions| {
self.utils
.flatten_nested_actions_default(actions.into_iter())
}),
victim,
)
})
.try_fold(vec![], |mut acc, (victim_set, hashes)| {
let tree = victim_set.tree();
let actions = victim_set
.map(|s| {
s.into_iter().split_actions::<(Vec<_>, Vec<_>), _>((
Action::try_swaps_merged,
Action::try_transfer,
))
})
.into_zip_tree(tree)
.tree_zip_with(hashes.into_iter())
.t_full_filter_map(|(tree, rest)| {
let (swap, hashes): (Vec<_>, Vec<_>) = UnzipPadded::unzip_padded(rest);
if !hashes
.iter()
.map(|v| {
let tree = &(*tree.clone());
let d = tree.get_root(*v).unwrap().get_root_action();
d.is_revert() || mev_executor_contract == d.get_to_address()
})
.any(|d| d)
{
Some(swap)
} else {
None
}
})?;
if actions.is_empty() {
None
} else {
acc.push(actions);
Some(acc)
}
})
}
}
fn get_possible_sandwich_duplicate_senders(tree: Arc<BlockTree<Action>>) -> Vec<PossibleSandwich> {
let mut duplicate_senders: FastHashMap<Address, B256> = FastHashMap::default();
let mut possible_victims: FastHashMap<B256, Vec<B256>> = FastHashMap::default();
let mut possible_sandwiches: FastHashMap<Address, PossibleSandwich> = FastHashMap::default();
for root in tree.tx_roots.iter() {
if root.get_root_action().is_revert() {
continue
}
match duplicate_senders.entry(root.head.address) {
Entry::Vacant(v) => {
v.insert(root.tx_hash);
}
Entry::Occupied(mut o) => {
let prev_tx_hash = o.insert(root.tx_hash);
if let Some(frontrun_victims) = possible_victims.remove(&prev_tx_hash) {
match possible_sandwiches.entry(root.head.address) {
Entry::Vacant(e) => {
e.insert(PossibleSandwich {
eoa: root.head.address,
possible_frontruns: vec![prev_tx_hash],
possible_backrun: root.tx_hash,
mev_executor_contract: root.get_to_address(),
victims: vec![frontrun_victims],
});
}
Entry::Occupied(mut o) => {
let sandwich = o.get_mut();
sandwich.possible_frontruns.push(prev_tx_hash);
sandwich.possible_backrun = root.tx_hash;
sandwich.victims.push(frontrun_victims);
}
}
}
o.insert(root.tx_hash);
}
}
for (_, v) in possible_victims.iter_mut() {
v.push(root.tx_hash);
}
possible_victims.insert(root.tx_hash, vec![]);
}
possible_sandwiches.into_values().collect()
}
fn get_possible_sandwich_duplicate_contracts(
tree: Arc<BlockTree<Action>>,
) -> Vec<PossibleSandwich> {
let mut duplicate_mev_contracts: FastHashMap<Address, (B256, Address)> = FastHashMap::default();
let mut possible_victims: FastHashMap<B256, Vec<B256>> = FastHashMap::default();
let mut possible_sandwiches: FastHashMap<Address, PossibleSandwich> = FastHashMap::default();
for root in tree.tx_roots.iter() {
if root.get_root_action().is_revert() {
continue
}
match duplicate_mev_contracts.entry(root.get_to_address()) {
Entry::Vacant(duplicate_mev_contract) => {
duplicate_mev_contract.insert((root.tx_hash, root.head.address));
}
Entry::Occupied(mut duplicate_mev_contract) => {
let (prev_tx_hash, frontrun_eoa) = duplicate_mev_contract.get_mut();
if let Some(frontrun_victims) = possible_victims.remove(prev_tx_hash) {
match possible_sandwiches.entry(root.get_to_address()) {
Entry::Vacant(e) => {
e.insert(PossibleSandwich {
eoa: *frontrun_eoa,
possible_frontruns: vec![*prev_tx_hash],
possible_backrun: root.tx_hash,
mev_executor_contract: root.get_to_address(),
victims: vec![frontrun_victims],
});
}
Entry::Occupied(mut o) => {
let sandwich = o.get_mut();
sandwich.possible_frontruns.push(*prev_tx_hash);
sandwich.possible_backrun = root.tx_hash;
sandwich.victims.push(frontrun_victims);
}
}
}
*prev_tx_hash = root.tx_hash;
}
}
for (_, v) in possible_victims.iter_mut() {
v.push(root.tx_hash);
}
possible_victims.insert(root.tx_hash, vec![]);
}
possible_sandwiches.into_values().collect()
}
#[cfg(test)]
mod tests {
use alloy_primitives::hex;
use brontes_types::constants::{DAI_ADDRESS, USDT_ADDRESS, WETH_ADDRESS};
use super::*;
use crate::{
test_utils::{InspectorTestUtils, InspectorTxRunConfig, USDC_ADDRESS},
Inspectors,
};
#[brontes_macros::test]
async fn test_sandwich_different_eoa() {
let inspector_util = InspectorTestUtils::new(USDC_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_mev_tx_hashes(vec![
hex!("ff79c471b191c0021cfb62408cb1d7418d09334665a02106191f6ed16a47e36c").into(),
hex!("19122ffe65a714f0551edbb16a24551031056df16ccaab39db87a73ac657b722").into(),
hex!("67771f2e3b0ea51c11c5af156d679ccef6933db9a4d4d6cd7605b4eee27f9ac8").into(),
])
.with_dex_prices()
.needs_token(Address::new(hex!("28cf5263108c1c40cf30e0fe390bd9ccf929bf82")))
.with_gas_paid_usd(16.64)
.with_expected_profit_usd(15.648);
inspector_util.run_inspector(config, None).await.unwrap();
}
#[brontes_macros::test]
async fn test_sandwich_part_of_jit_sandwich_simple() {
let inspector_util = InspectorTestUtils::new(USDC_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_block(18500018)
.with_dex_prices()
.needs_token(hex!("8642a849d0dcb7a15a974794668adcfbe4794b56").into())
.with_gas_paid_usd(40.26)
.with_expected_profit_usd(1.18);
inspector_util.run_inspector(config, None).await.unwrap();
}
#[brontes_macros::test]
async fn test_loan_sandwich() {
let inspector_util = InspectorTestUtils::new(USDT_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_mev_tx_hashes(vec![
hex!("db9c9f7ecfd33d4856bcd36d7af1228d29be90bfc7301fe7eadb0ddb23c68e3a").into(),
hex!("e4b3824c6cc238a1cf402f626c339f66a8cde9834b0dd84864ce82d7472cb763").into(),
hex!("152487feea8f726e8e09f2304bc32b0b2937a0386362231542f4e7189d4ac3b8").into(),
])
.with_dex_prices()
.needs_tokens(vec![
hex!("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").into(),
hex!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").into(),
])
.with_gas_paid_usd(2734.3)
.with_expected_profit_usd(195.27);
inspector_util.run_inspector(config, None).await.unwrap();
}
#[brontes_macros::test]
async fn test_sandwich_part_of_jit_sandwich_default() {
let inspector_util = InspectorTestUtils::new(USDC_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_mev_tx_hashes(vec![
hex!("22ea36d516f59cc90ccc01042e20f8fba196f32b067a7e5f1510099140ae5e0a").into(),
hex!("72eb3269ac013cf663dde9aa11cc3295e0dfb50c7edfcf074c5c57b43611439c").into(),
hex!("3b4138bac9dc9fa4e39d8d14c6ecd7ec0144fe26b120ea799317aa15fa35ddcd").into(),
hex!("99785f7b76a9347f13591db3574506e9f718060229db2826b4925929ebaea77e").into(),
hex!("31dedbae6a8e44ec25f660b3cd0e04524c6476a0431ab610bb4096f82271831b").into(),
])
.needs_tokens(vec![
hex!("b17548c7b510427baac4e267bea62e800b247173").into(),
hex!("ed4e879087ebd0e8a77d66870012b5e0dffd0fa4").into(),
hex!("50D1c9771902476076eCFc8B2A83Ad6b9355a4c9").into(),
])
.with_gas_paid_usd(90.875025)
.with_expected_profit_usd(13.6);
inspector_util.run_inspector(config, None).await.unwrap();
}
#[brontes_macros::test]
async fn test_big_mac_sandwich() {
let inspector_util = InspectorTestUtils::new(USDC_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_mev_tx_hashes(vec![
hex!("2a187ed5ba38cc3b857726df51ce99ee6e29c9bcaa02be1a328f99c3783b3303").into(),
hex!("7325392f41338440f045cb1dba75b6099f01f8b00983e33cc926eb27aacd7e2d").into(),
hex!("bcb8115fb54b7d6b0a0b0faf6e65fae02066705bd4afde70c780d4251a771428").into(),
hex!("0b428553bc2ccc8047b0da46e6c1c1e8a338d9a461850fcd67ddb233f6984677").into(),
hex!("fb2ef488bf7b6ad09accb126330837198b0857d2ea0052795af520d470eb5e1d").into(),
])
.needs_tokens(vec![
WETH_ADDRESS,
hex!("dac17f958d2ee523a2206206994597c13d831ec7").into(),
])
.with_gas_paid_usd(21.9)
.with_expected_profit_usd(0.015);
inspector_util
.run_inspector(
config,
Some(Box::new(|bundle: &Bundle| {
let BundleData::Sandwich(ref sando) = bundle.data else {
panic!("given bundle wasn't a sandwich");
};
assert!(sando.frontrun_tx_hash.len() == 2, "didn't find the big mac");
})),
)
.await
.unwrap();
}
#[brontes_macros::test]
async fn test_related_victim_tx_sandwich() {
let inspector_util = InspectorTestUtils::new(USDC_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_mev_tx_hashes(vec![
hex!("561dc89f55be726eb4a6e42b811b514391d6f5619ac54a2b3546f4a3ce747e98").into(),
hex!("efc9bcea246c70f4e915cb26a62019325d73871dbb31849cbf7541a5bc069f1c").into(),
hex!("17a8ebe7b7d153d123b27714570bc5a7d1ead669cd90f9e13654a46542ed4367").into(),
hex!("bf18530786a7ddf9da5316e57f0f041de09e149a42a121edd532f5ce3bb1cc4b").into(),
hex!("a51d90663cbf127440972163d3943d18e3a79dae9a77e065b0980f8d192b65e7").into(),
hex!("3b0a069a010d5ebb00be9d4cc86d4dce90687d41eacfd05f1916d12b061e24f2").into(),
])
.needs_tokens(vec![
WETH_ADDRESS,
hex!("628a3b2e302c7e896acc432d2d0dd22b6cb9bc88").into(),
hex!("d9016a907dc0ecfa3ca425ab20b6b785b42f2373").into(),
hex!("8390a1da07e376ef7add4be859ba74fb83aa02d5").into(),
hex!("51cb253744189f11241becb29bedd3f1b5384fdb").into(),
USDC_ADDRESS,
])
.with_gas_paid_usd(61.0)
.with_expected_profit_usd(1.18);
inspector_util
.run_inspector(
config,
Some(Box::new(|bundle: &Bundle| {
let BundleData::Sandwich(ref sando) = bundle.data else {
panic!("expected a sandwich");
};
assert_eq!(sando.victim_swaps_tx_hashes.iter().flatten().count(), 4);
})),
)
.await
.unwrap();
}
#[brontes_macros::test]
async fn test_low_profit_sandwich1() {
let inspector_util = InspectorTestUtils::new(USDC_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_mev_tx_hashes(vec![
hex!("73003ef0efa2d7fea8b54418d58c529fe02dfa7f074c792f608c52028671c0ee").into(),
hex!("9a52628d5f1b4129ee85768cf96477824c158ebce48b4331ab4f89de28a39ef1").into(),
hex!("a46bfbd85fbcaf8450879d73f27436bf942078e5762af68bc10757745b5e1c9a").into(),
])
.needs_tokens(vec![
WETH_ADDRESS,
hex!("8390a1da07e376ef7add4be859ba74fb83aa02d5").into(),
])
.with_gas_paid_usd(16.57)
.with_expected_profit_usd(0.001);
inspector_util.run_inspector(config, None).await.unwrap();
}
#[brontes_macros::test]
async fn test_low_profit_sandwich2() {
let inspector_util = InspectorTestUtils::new(USDC_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_mev_tx_hashes(vec![
hex!("3c1592d19a18c7237d6e42ca1541bc82bce4789600f288d933c7476cdd20f375").into(),
hex!("b53dfdce0e49609f58df3a229bd431ba8f9d2d201ba4a0ccd40ae11024b8c333").into(),
hex!("9955b95cc97a07fab9b42fdb675560256a35feaa8ce98292b594c88d218ebb9d").into(),
hex!("287d48d4841cb8cc34771d2df2f00e42ee31711910358d372b4b546cad44679c").into(),
])
.needs_tokens(vec![
WETH_ADDRESS,
hex!("4309e88d1d511f3764ee0f154cee98d783b61f09").into(),
hex!("6bc40d4099f9057b23af309c08d935b890d7adc0").into(),
])
.with_gas_paid_usd(30.0)
.with_expected_profit_usd(0.03);
inspector_util.run_inspector(config, None).await.unwrap();
}
#[brontes_macros::test]
async fn test_sandwich_not_classified() {
let inspector_util = InspectorTestUtils::new(USDT_ADDRESS, 5.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_mev_tx_hashes(vec![
hex!("8d67edc3404d17caa0ab07835d160d67b6b3414b01737c4693f95db5462238eb").into(),
hex!("eda2a0759b04a5b92886b0146df4ca018236d3ea479ee4309b36ba82dfab2cd6").into(),
hex!("4cb2e73cb144fb6926055473c925bb3a094255460d3d438f31aa2b4a10a489f3").into(),
])
.needs_tokens(vec![
hex!("4ddc2d193948926d02f9b1fe9e1daa0718270ed5").into(),
WETH_ADDRESS,
DAI_ADDRESS,
USDT_ADDRESS,
USDC_ADDRESS,
])
.with_gas_paid_usd(700.36)
.with_expected_profit_usd(112.2);
inspector_util.run_inspector(config, None).await.unwrap();
}
#[brontes_macros::test]
async fn test_dodo_balancer_flashloan() {
let inspector_util = InspectorTestUtils::new(USDT_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_mev_tx_hashes(vec![
hex!("5047cf41c74ea639a25fdb1940effe4be284ed2ae9b563a2800c94e9a8b43135").into(),
hex!("027141d059be231b0a0be8f5030edb70a70b5a75a64a72671b7cd04e2523e65e").into(),
hex!("b102f59420b7ee268a269f33d6728d84d344b17758fa78da18e1ce60cd05e5ae").into(),
])
.needs_tokens(vec![WETH_ADDRESS, DAI_ADDRESS, USDT_ADDRESS, USDC_ADDRESS])
.with_gas_paid_usd(106.9)
.with_expected_profit_usd(2.6);
inspector_util.run_inspector(config, None).await.unwrap();
}
#[brontes_macros::test]
async fn test_jared_looks_atomic_arb() {
let inspector_util = InspectorTestUtils::new(USDT_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_mev_tx_hashes(vec![
hex!("eaa48d2f9d13f4d9985e1c59546f000ef5a0710532f5f461deb39d2c08b4931e").into(),
hex!("ccd2236c2036efffbb9b492a5867a11b535963c5f7387b174b6e6105e7689ffe").into(),
hex!("1a9b39a84ba847541706626c40fab246892311f8b0b7db226fdb9155858093d2").into(),
])
.needs_tokens(vec![WETH_ADDRESS, DAI_ADDRESS, USDT_ADDRESS, USDC_ADDRESS])
.with_gas_paid_usd(164.35)
.with_expected_profit_usd(0.8);
inspector_util.run_inspector(config, None).await.unwrap();
}
#[brontes_macros::test]
async fn test_zero_x_dydx() {
let inspector_util = InspectorTestUtils::new(USDT_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_mev_tx_hashes(vec![
hex!("b1d88d24517c0bcbcbd566150edaacf702eac451ae85dad5008e4733d3a6eca7").into(),
hex!("b1aa6baba57e9e2c32f6f4a5599eb2a581eb875dedc8a0d21a02f537d6145c30").into(),
hex!("eaf680c0815ee63870d519570d96032ac93bed8931746cb73221101c88fa0a6b").into(),
])
.with_gas_paid_usd(493.0)
.with_expected_profit_usd(68.6);
inspector_util.run_inspector(config, None).await.unwrap();
}
#[brontes_macros::test]
async fn test_zero_x_jared() {
let inspector_util = InspectorTestUtils::new(USDT_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_mev_tx_hashes(vec![
hex!("545134ca5295797387748eaf35af7c9c00e044c5ff270ffe500c3aa896a9cecb").into(),
hex!("02409672760f2289e98d4b9b91ee4c77881da1bf8c7e5210581ef32ca08df5a8").into(),
hex!("77a5183272815e5f220f3febf51615823061bd74a43eb88c9ea54a79b2879677").into(),
])
.with_gas_paid_usd(32.2)
.with_expected_profit_usd(0.16);
inspector_util.run_inspector(config, None).await.unwrap();
}
#[brontes_macros::test]
async fn sandwich_part_of_jit_multi_sandwich() {
let inspector_util = InspectorTestUtils::new(USDT_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_block(18674873)
.with_gas_paid_usd(273.9)
.with_expected_profit_usd(18.1);
inspector_util.run_inspector(config, None).await.unwrap();
}
#[brontes_macros::test]
async fn weird_aavev2_sandwich() {
let inspector_util = InspectorTestUtils::new(USDT_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_mev_tx_hashes(vec![
hex!("ac0aa4de358348c21c489d2327510ec572c31b6189df1b187b1b443717847955").into(),
hex!("337680b1aa08d90a013049eb87bd39375ca8ab074eeac8a09b23852eba147cc6").into(),
hex!("ca1537a5f7b75634ce5bb58336d3fdd59c5d23a8f643a724abfe97d0b6a7c2ad").into(),
])
.with_block(16659292)
.with_gas_paid_usd(90.0)
.with_expected_profit_usd(67.3);
inspector_util.run_inspector(config, None).await.unwrap();
}
#[brontes_macros::test]
async fn sandwich_paraswap_victim() {
let inspector_util = InspectorTestUtils::new(USDT_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_block(19668569)
.with_gas_paid_usd(273.17)
.with_expected_profit_usd(415.59);
inspector_util.run_inspector(config, None).await.unwrap();
}
#[brontes_macros::test]
async fn ensure_just_jit() {
let inspector_util = InspectorTestUtils::new(USDT_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_block(19000056);
inspector_util.assert_no_mev(config).await.unwrap();
}
#[brontes_macros::test]
async fn beaver_double_cex_dex_false_positive() {
let inspector_util = InspectorTestUtils::new(USDT_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_mev_tx_hashes(vec![
hex!("abcc6968cd2a072b20f5e2d25d80d7ad6957efa999079c511a278dd6eb9095d6").into(),
hex!("a79536b1257d96b03f53ff9e0017176704535a19353beb006179f4f9f9ef69aa").into(),
hex!("435470d1f5e2494525f556d03303e6a1e3622777b6b718cbb77abf9d6bd0ebdb").into(),
]);
inspector_util.assert_no_mev(config).await.unwrap();
}
#[brontes_macros::test]
async fn sandwich_missed_on_frontend() {
let inspector_util = InspectorTestUtils::new(USDT_ADDRESS, 1.0).await;
let config = InspectorTxRunConfig::new(Inspectors::Sandwich)
.with_dex_prices()
.with_mev_tx_hashes(vec![
hex!("ee725fc69a985c74dea1a3ffaff9ba7a0e1de6f137cd092bb70514da72dee37d").into(),
hex!("b953c6f835946a1f86256d0cab4f3b553932b0d8159f16b558501d57c44ca595").into(),
hex!("c2f32ffde8efca0032262be0da9973d31821cc9830b50f6d121da823f2314d4f").into(),
hex!("bd63a22a0d3c4420ed3896210ba1f885ce4ef6ba34307feea8da2f439355ebe9").into(),
hex!("9ce374bad9cce46ea66d121662c0ec3df7915e39f196550c97d6327b61f992ed").into(),
hex!("fdf29e171f20338790f11532916d036a20639aa54d4ddaade9110c3648cb3ba2").into(),
hex!("6001e701e5c8ea7fde68f5ad8e924b9a98be9cf2cb7d5da6e7c19f0494a3b95f").into(),
hex!("4ac28bf53a251da80c95eee1a992c6d5c3292b4f8011be2f1987d32e42c69b29").into(),
])
.with_gas_paid_usd(212.91)
.with_expected_profit_usd(-0.16);
inspector_util.run_inspector(config, None).await.unwrap();
}
}