100 hours of building a sandwich bot

A to Z: Build your own sandwich bot the right way

Solid Quant
23 min readJan 28, 2024
Fotor AI: “Chihuahua eating sandwich” after 16 iterations

Today, we’re going to build a sandwich bot together. 🥪

And yes, I know how that may sound. And I understand if you’re thinking:

Here we go again. There’s already plenty of resources on the web to learn this from. Why another boring sando bot tutorial from someone like you?

Good question. 🤔

It’s actually because there aren’t that many good resources on the web to be honest. And the ones that are there, are either from a couple of months/years ago or are limited in their use case, so might as well update the code a little.

It took me more than a 100 hours to nail down this setup. Whether you’re just dipping your toes into the ecosystem or going in deep, consider this your guide.

If you haven’t tried building an MEV bot from scratch, now’s your chance. Quick heads up, this project won’t start raking in profits from day one. If it did, it won’t be shared openly like this. But it’ll be pretty close to the real deal, and the moolah will start flowing in as we add more features over time. We’ll be fine-tuning the code for maximum performance in the next month (and then diving into sniping and Solana afterwards).

Here’s a sneak peak of the final result:

I’m sharing the link to my Github repository for people who like to dive into code right away:

This series will be split into two parts:

  1. (Part #1): Identification
  2. (Part #2): Execution

and in today’s article we’ll mainly focus on the identification process and in the next one dive deeper into how we can handle the execution part.

Table of contents:

  1. Sandwich bot 101
  2. Sandwich smart contract using Solidity/Yul
  3. Sandwich simulation engine using REVM

Introduction

Most of us are probably familiar with the Subway bot written in JS by libevm:

I still remember my first dive into MEV, and it all started with this old-school repository — a true classic in the game. Even though the project goes way back, there’s a ton to pick up from it. For any MEV beginners stepping into the scene, make sure you dissect every line of code.

However, limitations exist of course. Not because the project isn’t awesome, but the MEV market has done some serious evolving in the past three years.

We’re here to dig into those changes and see what it really takes to cash in on the MEV scene these days. If you’re as hyped as I am, let’s keep this journey going. 🙌

👾 Jump into our Discord crew, where thousands of folks chat about MEV stuff every day. Doing solo research can get kind of lonely, so drop in and say what’s up 🏄🏄. Check out how others are tackling this space:

In every MEV strategy, there are two steps you have to take: the identification step and the execution step. In today’s article, we’ll focus on identifying sandwich opportunities. And in the next one, we’ll send real orders to block builders and try to compete with other sandwich bots.

If you aren’t too familiar with block builders yet, take a look at my previous article:

Sandwich bot 101

We are going to setup our project first.

✋✋ Take note that running your production code using a full node is going to give you the best performance. MEV strategies are generally very network intensive, so it’s best to remove any latencies involved there, and the easiest way of doing that is by running a full node. I’m personally running Geth + Lighthouse.

I’m also using Rust 🦀 for the whole project, and if you don’t know Rust yet, don’t worry, because the concepts here will still make sense even if you don’t. But it is recommended that you learn Rust before diving deeper.

Project Setup

First, create a new Rust project on your local machine by doing:

cargo new sandooo

this will create a skeleton Rust template in a new directory named sandooo.

Open this directory using a IDE of your choice and copy and paste this into Cargo.toml file (sandooo/Cargo.toml):

[package]
name = "sandooo"
version = "0.1.0"
edition = "2021"

[dependencies]
dotenv = "0.15.0"
anyhow = "1.0.70"
itertools = "0.11.0"
serde = "1.0.188"
serde_json = "1.0.107"
bounded-vec-deque = "0.1.1"

# Telegram
teloxide = { version = "0.12", features = ["macros"] }

futures = "0.3.5"
futures-util = "*"
tokio = { version = "1.29.0", features = ["full"] }
tokio-stream = { version = "0.1", features = ['sync'] }
tokio-tungstenite = "*"
async-trait = "0.1.74"

ethers-core = "2.0"
ethers-providers = "2.0"
ethers-contract = "2.0"
ethers = { version = "2.0", features = ["abigen", "ws", "ipc"] }

ethers-flashbots = { git = "https://github.com/onbjerg/ethers-flashbots" }

eth-encode-packed = "0.1.0"
rlp = { version = "0.5", features = ["derive"] }

foundry-evm-mini = { git = "https://github.com/solidquant/foundry-evm-mini.git" }

revm = { version = "3", default-features = false, features = [
"std",
"serde",
"memory_limit",
"optional_eip3607",
"optional_block_gas_limit",
"optional_no_base_fee",
] }

csv = "1.2.2"
colored = "2.0.0"
log = "0.4.17"
fern = { version = "0.6.2", features = ["colored"] }
chrono = "0.4.23"
indicatif = "0.17.5"

[patch.crates-io]
revm = { git = "https://github.com/bluealloy/revm/", rev = "80c909d6f242886cb26e6103a01d1a4bf9468426" }

[profile.release]
codegen-units = 1
lto = "fat"

Once that’s complete, create a new directory within the src directory and name it common. And create a new file named: constants.rs (sandooo/src/common/constants.rs):

pub static PROJECT_NAME: &str = "sandooo";

// Function that will load the environment variable as a String value
pub fn get_env(key: &str) -> String {
std::env::var(key).unwrap_or(String::from(""))
}

#[derive(Debug, Clone)]
pub struct Env {
pub https_url: String,
pub wss_url: String,
pub bot_address: String,
pub private_key: String,
pub identity_key: String,
pub telegram_token: String,
pub telegram_chat_id: String,
pub use_alert: bool,
pub debug: bool,
}

// Creating a new Env struct will automatically load the environment variables
impl Env {
pub fn new() -> Self {
Env {
https_url: get_env("HTTPS_URL"),
wss_url: get_env("WSS_URL"),
bot_address: get_env("BOT_ADDRESS"),
private_key: get_env("PRIVATE_KEY"),
identity_key: get_env("IDENTITY_KEY"),
telegram_token: get_env("TELEGRAM_TOKEN"),
telegram_chat_id: get_env("TELEGRAM_CHAT_ID"),
use_alert: get_env("USE_ALERT").parse::<bool>().unwrap(),
debug: get_env("DEBUG").parse::<bool>().unwrap(),
}
}
}

pub static COINBASE: &str = "0xDAFEA492D9c6733ae3d56b7Ed1ADB60692c98Bc5"; // Flashbots Builder

pub static WETH: &str = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
pub static WETH_BALANCE_SLOT: i32 = 3;
pub static WETH_DECIMALS: u8 = 18;

This is so we can load our environment variables on startup. Naturally, the next step would be to create a .env file in the root directory, which is the sandooo directory (sandooo/.env):

HTTPS_URL=http://localhost:8545
WSS_URL=ws://localhost:8546
BOT_ADDRESS="..."
PRIVATE_KEY="..."
IDENTITY_KEY="..."
TELEGRAM_TOKEN="..."
TELEGRAM_CHAT_ID="..."
USE_ALERT=false
DEBUG=true

RUST_BACKTRACE=1
  • PRIVATE_KEY: this is your actual private key if you intend to run your sandwich bot using a real wallet
  • IDENTITY_KEY: this can be set as any private key of your choice. Builders that receive identity keys will use it to prioritize certain bundles based on searcher reputation. You can learn more about searcher reputation from here: https://docs.flashbots.net/flashbots-auction/advanced/reputation
  • TELEGRAM_TOKEN / TELEGRAM_CHAT_ID: you can leave these fields as empty if you don’t want to use Telegram alerts.
  • DEBUG: we’ll use this flag later to support both development / production modes. If DEBUG is set to true, we’ll only run simulations and not send any real bundles.

We also want to prettify our console logs, so we add a utils.rs file in our src/common directory (sandooo/src/common/utils.rs):

use anyhow::Result;
use ethers::core::rand::thread_rng;
use ethers::prelude::*;
use ethers::{
self,
types::{
transaction::eip2930::{AccessList, AccessListItem},
U256,
},
};
use fern::colors::{Color, ColoredLevelConfig};
use foundry_evm_mini::evm::utils::{b160_to_h160, h160_to_b160, ru256_to_u256, u256_to_ru256};
use log::LevelFilter;
use rand::Rng;
use revm::primitives::{B160, U256 as rU256};
use std::str::FromStr;
use std::sync::Arc;

use crate::common::constants::{PROJECT_NAME, WETH};

// Function to format our console logs
pub fn setup_logger() -> Result<()> {
let colors = ColoredLevelConfig {
trace: Color::Cyan,
debug: Color::Magenta,
info: Color::Green,
warn: Color::Red,
error: Color::BrightRed,
..ColoredLevelConfig::new()
};

fern::Dispatch::new()
.format(move |out, message, record| {
out.finish(format_args!(
"{}[{}] {}",
chrono::Local::now().format("[%H:%M:%S]"),
colors.color(record.level()),
message
))
})
.chain(std::io::stdout())
.level(log::LevelFilter::Error)
.level_for(PROJECT_NAME, LevelFilter::Info)
.apply()?;

Ok(())
}

// Calculates the next block base fee given the previous block's gas usage / limits
// Refer to: https://www.blocknative.com/blog/eip-1559-fees
pub fn calculate_next_block_base_fee(
gas_used: U256,
gas_limit: U256,
base_fee_per_gas: U256,
) -> U256 {
let gas_used = gas_used;

let mut target_gas_used = gas_limit / 2;
target_gas_used = if target_gas_used == U256::zero() {
U256::one()
} else {
target_gas_used
};

let new_base_fee = {
if gas_used > target_gas_used {
base_fee_per_gas
+ ((base_fee_per_gas * (gas_used - target_gas_used)) / target_gas_used)
/ U256::from(8u64)
} else {
base_fee_per_gas
- ((base_fee_per_gas * (target_gas_used - gas_used)) / target_gas_used)
/ U256::from(8u64)
}
};

let seed = rand::thread_rng().gen_range(0..9);
new_base_fee + seed
}

pub fn access_list_to_ethers(access_list: Vec<(B160, Vec<rU256>)>) -> AccessList {
AccessList::from(
access_list
.into_iter()
.map(|(address, slots)| AccessListItem {
address: b160_to_h160(address),
storage_keys: slots
.into_iter()
.map(|y| H256::from_uint(&ru256_to_u256(y)))
.collect(),
})
.collect::<Vec<AccessListItem>>(),
)
}

pub fn access_list_to_revm(access_list: AccessList) -> Vec<(B160, Vec<rU256>)> {
access_list
.0
.into_iter()
.map(|x| {
(
h160_to_b160(x.address),
x.storage_keys
.into_iter()
.map(|y| u256_to_ru256(y.0.into()))
.collect(),
)
})
.collect()
}

abigen!(
IERC20,
r#"[
function balanceOf(address) external view returns (uint256)
]"#,
);

// Utility functions

pub async fn get_token_balance(
provider: Arc<Provider<Ws>>,
owner: H160,
token: H160,
) -> Result<U256> {
let contract = IERC20::new(token, provider);
let token_balance = contract.balance_of(owner).call().await?;
Ok(token_balance)
}

pub fn create_new_wallet() -> (LocalWallet, H160) {
let wallet = LocalWallet::new(&mut thread_rng());
let address = wallet.address();
(wallet, address)
}

pub fn to_h160(str_address: &'static str) -> H160 {
H160::from_str(str_address).unwrap()
}

pub fn is_weth(token_address: H160) -> bool {
token_address == to_h160(WETH)
}

The setup_logger function will take care of formatting our logs, and we’ve added a couple of extra functions to use throughout our project. We’ll see how they’re used when they come up.

We’re almost there. We just have to take care of importing our new files and functions so that they can be used within our project.

For this:

  1. Create sandooo/src/lib.rs:
pub mod common;

2. Create sandooo/src/common/mod.rs:

pub mod constants;
pub mod utils;

The project setup is complete. Now we can move onto the more interesting aspects of a sandwich bot.

Mempool streams

Say for instance someone sends a Uniswap order transaction to the public mempool. Any transaction that’s directly sent to the blockchain will typically go to the public mempool.

And anyone can get access to this data by making a websocket connection to a node provider. To achieve this, we’ll create a new file in sandooo/src/common/streams.rs:

use ethers::{
providers::{Middleware, Provider, Ws},
types::*,
};
use std::sync::Arc;
use tokio::sync::broadcast::Sender;
use tokio_stream::StreamExt;

use crate::common::utils::calculate_next_block_base_fee;

#[derive(Default, Debug, Clone)]
pub struct NewBlock {
pub block_number: U64,
pub base_fee: U256,
pub next_base_fee: U256,
}

#[derive(Debug, Clone)]
pub struct NewPendingTx {
pub added_block: Option<U64>,
pub tx: Transaction,
}

impl Default for NewPendingTx {
fn default() -> Self {
Self {
added_block: None,
tx: Transaction::default(),
}
}
}

#[derive(Debug, Clone)]
pub enum Event {
Block(NewBlock),
PendingTx(NewPendingTx),
}

// A websocket connection made to get newly created blocks
pub async fn stream_new_blocks(provider: Arc<Provider<Ws>>, event_sender: Sender<Event>) {
let stream = provider.subscribe_blocks().await.unwrap();
let mut stream = stream.filter_map(|block| match block.number {
Some(number) => Some(NewBlock {
block_number: number,
base_fee: block.base_fee_per_gas.unwrap_or_default(),
next_base_fee: U256::from(calculate_next_block_base_fee(
block.gas_used,
block.gas_limit,
block.base_fee_per_gas.unwrap_or_default(),
)),
}),
None => None,
});

while let Some(block) = stream.next().await {
match event_sender.send(Event::Block(block)) {
Ok(_) => {}
Err(_) => {}
}
}
}

// A websocket connection made to get new pending transactions
pub async fn stream_pending_transactions(provider: Arc<Provider<Ws>>, event_sender: Sender<Event>) {
let stream = provider.subscribe_pending_txs().await.unwrap();
let mut stream = stream.transactions_unordered(256).fuse();

while let Some(result) = stream.next().await {
match result {
Ok(tx) => match event_sender.send(Event::PendingTx(NewPendingTx {
added_block: None,
tx,
})) {
Ok(_) => {}
Err(_) => {}
},
Err(_) => {}
};
}
}

And update the sandooo/src/common/mod.rs:

pub mod constants;
pub mod streams;
pub mod utils;

so that we can use the functions in streams.rs.

Having the two functions ready will now allow us to get new blocks and pending transactions in real-time. However, we still haven’t defined a event handler that will handle both the Block and the PendingTx Events.

For this, we’ll create a new directory within the src directory, we’ll call it: sandooo/src/sandwich. Create two new files in this directory:

  • sandooo/src/sandwich/strategy.rs:
use bounded_vec_deque::BoundedVecDeque;
use ethers::signers::{LocalWallet, Signer};
use ethers::{
providers::{Middleware, Provider, Ws},
types::{BlockNumber, H160, H256, U256, U64},
};
use log::{info, warn};
use std::{collections::HashMap, str::FromStr, sync::Arc};
use tokio::sync::broadcast::Sender;

// we'll update this part later, for now just import the necessary components
use crate::common::constants::{Env, WETH};
use crate::common::streams::{Event, NewBlock};
use crate::common::utils::{calculate_next_block_base_fee, to_h160};

pub async fn run_sandwich_strategy(provider: Arc<Provider<Ws>>, event_sender: Sender<Event>) {
let mut event_receiver = event_sender.subscribe();

loop {
match event_receiver.recv().await {
Ok(event) => match event {
Event::Block(block) => {
info!("{:?}", block);
}
Event::PendingTx(mut pending_tx) => {
info!("{:?}", pending_tx);
}
},
_ => {}
}
}
}
  • sandooo/src/sandwich/mod.rs:
pub mod strategy;
  • sandooo/src/lib.rs:
pub mod common;
pub mod sandwich;

Hopefully you understand now that each time we add a new directory in the src directory, we update that in our sandooo/src/lib.rs, and these directories should have a mod.rs file in it. And each time we add a new file in that directory, we have to add it in the mod.rs file. Please don’t forget to do this from now on, because I won’t be describing this process from now on, and assume that it is always done.

Head on over to main.rs and update the code:

use anyhow::Result;
use ethers::providers::{Provider, Ws};
use log::info;
use std::sync::Arc;
use tokio::sync::broadcast::{self, Sender};
use tokio::task::JoinSet;

use sandooo::common::constants::Env;
use sandooo::common::streams::{stream_new_blocks, stream_pending_transactions, Event};
use sandooo::common::utils::setup_logger;
use sandooo::sandwich::strategy::run_sandwich_strategy;

#[tokio::main]
async fn main() -> Result<()> {
dotenv::dotenv().ok();
setup_logger().unwrap();

info!("Starting Sandooo");

let env = Env::new();

let ws = Ws::connect(env.wss_url.clone()).await.unwrap();
let provider = Arc::new(Provider::new(ws));

let (event_sender, _): (Sender<Event>, _) = broadcast::channel(512);

let mut set = JoinSet::new();

set.spawn(stream_new_blocks(provider.clone(), event_sender.clone()));
set.spawn(stream_pending_transactions(
provider.clone(),
event_sender.clone(),
));

set.spawn(run_sandwich_strategy(
provider.clone(),
event_sender.clone(),
));

while let Some(res) = set.join_next().await {
info!("{:?}", res);
}

Ok(())
}

The main function is our entry point for the whole system, and it will run three async functions using Tokio’s JoinSet.

Running the current Rust program by doing:

cargo run

will give you a flood of pending transactions on your terminal:

Okay, that’s too much, it hurts my eyes just by looking at it. Sorry I did that to you. But we at least know that the code works now. So we just need a way to figure out which one of these pending transactions are worth looking into and if they are sandwichable. We’ll try to figure out each one at a time.

🔎 Which pending transactions are worth looking into?

The first answer that we can think of may be to decode the input data in these pending transactions. And if they are direct calls to Uniswap pools or Uniswap routers, we should be able to figure out what token the transaction intends to buy or sell, and by how much quantity using that data alone.

However, this method isn’t very scalable. Yes, we can expect to capture transactions like the below:

which are transactions that directly interact with the Uniswap Universal Router.

But, we can’t capture transactions that are a lot more complicated yet may be sandwichable. These may be transactions from aggregators like 1inch and 0x, or smart contracts externally calling swaps in Uniswap in general.

Plus, if you want to add more DEXs, you’ll need to figure out a way to decode all the transactions by reading through their function specifications.

That’s why we need a more scalable method.

❓ Did you know that it’s possible to figure out what the transaction is going to do the blockchain state before it’s even confirmed in the next block?

We can accomplish this by tracing the transaction call. A trace call will try running the transaction on the block state that the caller specifies and return values such as: gas used, call stack, logs returned, etc.

We’ll try using the eth_traceCall method on Geth to figure out what Uniswap V2 pools the pending transactions are touching — by “touching” we mean which pools’ states are changing as a result of the call.

Let’s create another file in the sandwich directory: sandooo/src/sandwich/simulation.rs:

use anyhow::Result;
use eth_encode_packed::ethabi::ethereum_types::{H160 as eH160, U256 as eU256};
use eth_encode_packed::{SolidityDataType, TakeLastXBytes};
use ethers::abi::ParamType;
use ethers::prelude::*;
use ethers::providers::{Provider, Ws};
use ethers::types::{transaction::eip2930::AccessList, Bytes, H160, H256, I256, U256, U64};
use log::info;
use revm::primitives::{Bytecode, U256 as rU256};
use std::{collections::HashMap, default::Default, str::FromStr, sync::Arc};

use crate::common::constants::{WETH, WETH_BALANCE_SLOT};
use crate::common::streams::{NewBlock, NewPendingTx};
use crate::common::utils::{create_new_wallet, is_weth, to_h160};

#[derive(Debug, Clone, Default)]
pub struct PendingTxInfo {
pub pending_tx: NewPendingTx,
pub touched_pairs: Vec<SwapInfo>,
}

#[derive(Debug, Clone)]
pub enum SwapDirection {
Buy,
Sell,
}

#[derive(Debug, Clone)]
pub struct SwapInfo {
pub tx_hash: H256,
pub target_pair: H160,
pub main_currency: H160,
pub target_token: H160,
pub version: u8,
pub token0_is_main: bool,
pub direction: SwapDirection,
}

pub static V2_SWAP_EVENT_ID: &str = "0xd78ad95f";

pub async fn debug_trace_call(
provider: &Arc<Provider<Ws>>,
new_block: &NewBlock,
pending_tx: &NewPendingTx,
) -> Result<Option<CallFrame>> {
let mut opts = GethDebugTracingCallOptions::default();
let mut call_config = CallConfig::default();
call_config.with_log = Some(true); // 👈 make sure we are getting logs

opts.tracing_options.tracer = Some(GethDebugTracerType::BuiltInTracer(
GethDebugBuiltInTracerType::CallTracer,
));
opts.tracing_options.tracer_config = Some(GethDebugTracerConfig::BuiltInTracer(
GethDebugBuiltInTracerConfig::CallTracer(call_config),
));

let block_number = new_block.block_number;
let mut tx = pending_tx.tx.clone();
let nonce = provider
.get_transaction_count(tx.from, Some(block_number.into()))
.await
.unwrap_or_default();
tx.nonce = nonce;

let trace = provider
.debug_trace_call(&tx, Some(block_number.into()), opts)
.await;

match trace {
Ok(trace) => match trace {
GethTrace::Known(call_tracer) => match call_tracer {
GethTraceFrame::CallTracer(frame) => Ok(Some(frame)),
_ => Ok(None),
},
_ => Ok(None),
},
_ => Ok(None),
}

The debug_trace_call function is going to return the call frame returned after tracing the pending transactions. We can try running this after we tweak the strategy function a little (sandooo/src/sandwich/strategy.rs):

// ... imports

pub async fn run_sandwich_strategy(provider: Arc<Provider<Ws>>, event_sender: Sender<Event>) {
let block = provider
.get_block(BlockNumber::Latest)
.await
.unwrap()
.unwrap();
let mut new_block = NewBlock {
block_number: block.number.unwrap(),
base_fee: block.base_fee_per_gas.unwrap(),
next_base_fee: calculate_next_block_base_fee(
block.gas_used,
block.gas_limit,
block.base_fee_per_gas.unwrap(),
),
};

let mut event_receiver = event_sender.subscribe();

loop {
match event_receiver.recv().await {
Ok(event) => match event {
Event::Block(block) => {
new_block = block;
info!("[Block #{:?}]", new_block.block_number);
}
Event::PendingTx(mut pending_tx) => {
let frame = debug_trace_call(&provider, &new_block, &pending_tx).await;
match frame {
Ok(frame) => info!("{:?}", frame),
Err(e) => info!("{e:?}"),
}
}
},
_ => {}
}
}
}

Running:

cargo run

will give you a call frame that looks like this:

The part we are interested in is the logs. However, the call stack we receive as a result of tracing is recursive, so a call frame can have multiple other calls, which is a list of other call frames. And each call frame can contain logs.

To extract logs out of the call frames recursively, we use another helper function that we’ll define in sandooo/src/sandwich/simulation.rs:

pub fn extract_logs(call_frame: &CallFrame, logs: &mut Vec<CallLogFrame>) {
if let Some(ref logs_vec) = call_frame.logs {
logs.extend(logs_vec.iter().cloned());
}

if let Some(ref calls_vec) = call_frame.calls {
for call in calls_vec {
extract_logs(call, logs);
}
}
}

with this new function we can easily flatten the logs into a single vector. You can try updating the strategy.rs function again:

loop {
match event_receiver.recv().await {
Ok(event) => match event {
Event::Block(block) => {
new_block = block;
info!("[Block #{:?}]", new_block.block_number);
}
// just update this part 👇
Event::PendingTx(mut pending_tx) => {
let frame = debug_trace_call(&provider, &new_block, &pending_tx).await;
match frame {
Ok(frame) => match frame {
Some(frame) => {
let mut logs = Vec::new();
extract_logs(&frame, &mut logs);
info!("{:?}", logs);
}
_ => {}
},
Err(e) => info!("{e:?}"),
}
}
},
_ => {}
}
}

Try running this and you’ll now see that all the logs are flattened into a single vector:

Some traces will of course have no logs.

The next step is to filter out these logs and figure out which pending transactions are attempting to swap on Uniswap V2 DEXs. (We’ll focus on Uniswap V2 for now and add V3 in the last part of this series.)

We can do this by filtering out the Swap logs that look like the following:

By heading over to Etherscan, you can tell that Swap events have a 4 bytes selector of 0xd78ad95f, which can be seen from topic0:

So we’ll add another function to sandooo/src/sandwich/simulation.rs:

pub async fn extract_swap_info(
provider: &Arc<Provider<Ws>>,
new_block: &NewBlock,
pending_tx: &NewPendingTx,
pools_map: &HashMap<H160, Pool>,
) -> Result<Vec<SwapInfo>> {
let tx_hash = pending_tx.tx.hash;
let mut swap_info_vec = Vec::new();

let frame = debug_trace_call(provider, new_block, pending_tx).await?;
if frame.is_none() {
return Ok(swap_info_vec);
}
let frame = frame.unwrap();

let mut logs = Vec::new();
extract_logs(&frame, &mut logs);

for log in &logs {
match &log.topics {
Some(topics) => {
if topics.len() > 1 {
let selector = &format!("{:?}", topics[0])[0..10];
let is_v2_swap = selector == V2_SWAP_EVENT_ID;
if is_v2_swap {
let pair_address = log.address.unwrap();

// filter out the pools we have in memory only
let pool = pools_map.get(&pair_address);
if pool.is_none() {
continue;
}
let pool = pool.unwrap();

let token0 = pool.token0;
let token1 = pool.token1;

let token0_is_weth = is_weth(token0);
let token1_is_weth = is_weth(token1);

// filter WETH pairs only
if !token0_is_weth && !token1_is_weth {
continue;
}

let (main_currency, target_token, token0_is_main) = if token0_is_weth {
(token0, token1, true)
} else {
(token1, token0, false)
};

let (in0, _, _, out1) = match ethers::abi::decode(
&[
ParamType::Uint(256),
ParamType::Uint(256),
ParamType::Uint(256),
ParamType::Uint(256),
],
log.data.as_ref().unwrap(),
) {
Ok(input) => {
let uints: Vec<U256> = input
.into_iter()
.map(|i| i.to_owned().into_uint().unwrap())
.collect();
(uints[0], uints[1], uints[2], uints[3])
}
_ => {
let zero = U256::zero();
(zero, zero, zero, zero)
}
};

let zero_for_one = (in0 > U256::zero()) && (out1 > U256::zero());

let direction = if token0_is_main {
if zero_for_one {
SwapDirection::Buy
} else {
SwapDirection::Sell
}
} else {
if zero_for_one {
SwapDirection::Sell
} else {
SwapDirection::Buy
}
};

let swap_info = SwapInfo {
tx_hash,
target_pair: pair_address,
main_currency,
target_token,
version: 2,
token0_is_main,
direction,
};
swap_info_vec.push(swap_info);
}
}
}
_ => {}
}
}

Ok(swap_info_vec)
}

We are filtering out the logs in two steps:

  1. First, by filtering out the pools that we keep in our pools_map only. We haven’t added this yet, but we’ll in the next section.
  2. Second, by filtering out WETH pair pools only.

Once we’ve done that, we decode the log data by doing:

ethers::abi::decode(
&[
ParamType::Uint(256),
ParamType::Uint(256),
ParamType::Uint(256),
ParamType::Uint(256),
],
log.data.as_ref().unwrap(),
)

and extract out the amount0In, amount1In, amount0Out, amount1Out values.

Another interesting thing we can figure out using this data is whether the transaction was meant to buy or sell the target tokens. We’ll define target token as the token that is paired with WETH token (the main currency).

Let’s now add a way to update Uniswap V2 pools and its associated ERC-20 tokens on program startup so that the tracing + log extraction will work.

Add two new files to sandooo/src/common:

  1. pools.rs

2. tokens.rs

3. bytecode.rs

After you’ve done that, let’s update the strategy.rs file again in sandooo/src/sandwich/strategy.rs:

pub async fn run_sandwich_strategy(provider: Arc<Provider<Ws>>, event_sender: Sender<Event>) {
let env = Env::new();

// load_all_pools:
// this will load all Uniswap V2 pools that was deployed after the block #10000000
let (pools, prev_pool_id) = load_all_pools(env.wss_url.clone(), 10000000, 50000)
.await
.unwrap();

// load_all_tokens:
// this will get all the token information including: name, symbol, symbol, totalSupply
let block_number = provider.get_block_number().await.unwrap();
let tokens_map = load_all_tokens(&provider, block_number, &pools, prev_pool_id)
.await
.unwrap();
info!("Tokens map count: {:?}", tokens_map.len());

// filter pools that don't have both token0 / token1 info
let pools_vec: Vec<Pool> = pools
.into_iter()
.filter(|p| {
let token0_exists = tokens_map.contains_key(&p.token0);
let token1_exists = tokens_map.contains_key(&p.token1);
token0_exists && token1_exists
})
.collect();
info!("Filtered pools by tokens count: {:?}", pools_vec.len());

let pools_map: HashMap<H160, Pool> = pools_vec
.clone()
.into_iter()
.map(|p| (p.address, p))
.collect();

let block = provider
.get_block(BlockNumber::Latest)
.await
.unwrap()
.unwrap();
let mut new_block = NewBlock {
block_number: block.number.unwrap(),
base_fee: block.base_fee_per_gas.unwrap(),
next_base_fee: calculate_next_block_base_fee(
block.gas_used,
block.gas_limit,
block.base_fee_per_gas.unwrap(),
),
};

let mut event_receiver = event_sender.subscribe();

loop {
match event_receiver.recv().await {
Ok(event) => match event {
Event::Block(block) => {
new_block = block;
info!("[Block #{:?}]", new_block.block_number);
}
Event::PendingTx(mut pending_tx) => {
let swap_info =
extract_swap_info(&provider, &new_block, &pending_tx, &pools_map).await;
info!("{:?}", swap_info);
}
},
_ => {}
}
}
}

👏👏 👏 Now create a new directory from the root directory: sandooo/cache. This part is important, because pools.rs and tokens.rs will create a file cache of all the existing Uniswap V2 pools and tokens in a cached csv file for fast loading when we restart the system.

When you start your system after creating the cache directory, you’ll see that the program will start loading up pools using the RPC node endpoint:

After letting the program run for a while (it took me something like 30 minutes using a full node), it’ll start printing the swap info we extracted from geth traces.

As you can see, we are getting the target_pair, main_currency, target_token, and the correct swap direction of the pending transactions.

We are finally ready to move onto the more interesting part of our analysis: understanding the profit & cost structure of sandwich bundles.

🥪 Which one of these transactions are sandwichable?

To answer this question we have to understand how the profit & cost analysis of sandwich bundles are performed.

We’ll consider the most basic type of sandwich bundles that look like the below:

  • Front-run transaction: WETH → Target Token (BUY)
  • Victim transaction: WETH → Target Token (BUY)
  • Back-run transaction: Target Token → WETH (SELL)

Understanding the simple form of sandwich strategies is crucial before delving into more complex variations. We’ll eventually tackle those advanced strategies when we integrate V3 into our bot. But for now, let’s grasp the basics.

The concept of a sandwich is very basic: you buy before someone, and sell right after that person so you are guaranteed a profit. If more people buy a certain token, the price will go up, and that’s how the profit part of sandwich strategies work.

Since we’re now able to monitor all the buy, sell transactions from Uniswap V2 pools with the system we’ve setup in the previous section, we can try grouping them into bundles of front-run, victim, and back-run transactions and figure out the following:

  1. the maximum amount of tokens we can buy before the victim, making sure that the victim’s transaction doesn’t revert (transactions can revert due to slippage tolerance levels that the user sets when using Uniswap V2 Router contract)
  2. the maximum amount of profits we can expect to earn if all three transactions go through without reverting

through our simulation engine.

It’s better to just build some bundles in real-time and simulate them to see if we can really profit than to theorize how the calculations work. so let’s get to it right away.

Sandwich smart contract using Solidity/Yul

Trading on-chain differs from off-chain trading on CEXs like Binance, Bybit, etc. I won’t weigh in on what’s more challenging because that’s a subjective matter. Some argue that building strategies on CEXs is tougher due to the rapid price movements, while others contend that on-chain trading is more complex due to the extended block building times introducing new challenges. Both perspectives hold merit, and achieving profitability on either platform is no easy feat.

However, one thing is certain: the execution aspect of MEV is considerably more intricate than CEX trading.

If you aim to be profitable in MEV, it’s crucial to have a solid understanding of developing a safe and efficient smart contract.

Readers unfamiliar with Yul can refer to my previous article on the topic:

We require a contract to simulate our trades (and in the actual trading as well of course), helping us determine potential earnings and associated gas costs. Given that everyone will have a distinct contract tailored to their strategies, expect to encounter various profit and cost analyses.

The contract is provided here:

It’s a very simple contract that is written in Yul using Foundry.

We’ll just take a quick look at the fallback function:

fallback() external payable {
// We check that the msg.sender is the owner who deployed the contract
require(msg.sender == owner, "NOT_OWNER");

assembly {
let ptr := mload(0x40)
let end := calldatasize()

// the first 8 bytes (64 bits, uint64) of the calldata is the block_number
// we want to make sure that our transactions are valid only on
// the block that we've specified
let block_number := shr(192, calldataload(0))
if iszero(eq(block_number, number())) {
revert(0, 0)
}

// we can pass in multiple swap instructions
// which we'll use later when we group multiple sandwiches together
for {
let offset := 8
} lt(offset, end) {

} {
let zeroForOne := shr(248, calldataload(offset)) // 1 byte
let pair := shr(96, calldataload(add(offset, 1))) // 20 bytes
let tokenIn := shr(96, calldataload(add(offset, 21))) // 20 bytes
let amountIn := calldataload(add(offset, 41)) // 32 bytes
let amountOut := calldataload(add(offset, 73)) // 32 bytes
offset := add(offset, 105) // 1 + 20 + 20 + 32 + 32

// transfer tokenIn to pair contract first
mstore(ptr, TOKEN_TRANSFER_ID)
mstore(add(ptr, 4), pair)
mstore(add(ptr, 36), amountIn)

if iszero(call(gas(), tokenIn, 0, ptr, 68, 0, 0)) {
revert(0, 0)
}

// call swap function in UniswapV2Pair contract
// zeroForOne means the transaction is a swap going from token0 to token1
// Uniswap V2 swap function expects us to pass it in the amountOut value
// so if zeroForOne == 1 (true), the out token is token1
// and if zeroForOne == 0 (false), the out token is token0
mstore(ptr, V2_SWAP_ID)
switch zeroForOne
case 0 {
mstore(add(ptr, 4), amountOut)
mstore(add(ptr, 36), 0)
}
case 1 {
mstore(add(ptr, 4), 0)
mstore(add(ptr, 36), amountOut)
}
mstore(add(ptr, 68), address())
mstore(add(ptr, 100), 0x80)

if iszero(call(gas(), pair, 0, ptr, 164, 0, 0)) {
revert(0, 0)
}
}
}
}

Sandwich simulation engine using REVM

Now that we have the contract ready, we can at last do some real simulations.

We will be running three simulation steps in total:

  1. The appetizer simulation
  2. The input amount optimization simulations
  3. The main dish simulation

You may be wondering why we need so many simulation steps, but you’ll see that they all have a role in the ecosystem.

The appetizer simulation

In the appetizer simulation we try to pass in 0.1 WETH as the amountIn of our first buy transaction (front-run tx), and try to see if it is profitable.

This is to reduce the number of simulations we run in the following steps. If the sandwich bundle can’t even take 0.1 WETH as the input, then it’s not worth moving onto the optimization step.

The input amount optimization simulations

If our appetizer simulation passed, then we want to figure out the optimized amountIn value of WETH before we move on to the next step.

You can check the optimization process in this file:

Using quadratic search we can figure out the amount of tokens we can buy with WETH that’ll maximize our returns.

The main dish simulation

When we’re done with the optimization step, we use that result to run another simulation and calculate the accurate revenue value.

Revenue is calculated as the following:

  • Profit = Contract WETH balance after - WETH balance before
  • Cost = User ETH balance after - ETH balance before
  • Revenue = Profit - Cost

If the revenue is above 0, then it means that the sandwich bundle can cover our gas costs, so we calculate the bribe amount using the revenue value from this step.

Running the system with all these components will result in the following gif. Try running:

cargo run

What to expect next

That was quite a journey. 🙏 Thanks a bunch for sticking around.

Originally, I planned to break this down into several bite-sized articles, but I wanted to stress the importance of diving into the code on Github, line by line. Running the program yourself and witnessing it successfully in action is a stellar practice that I recommend for everyone.

In the upcoming articles of this series, we’ll delve into sending bundles to builders like Flashbots. We’ll compare how our bundles stack up against competitors using the current system and explore potential optimizations we can weave into our setup.

Catch you in the next one, folks! 💥

--

--