Adding stablecoin sandwiches and group bundling to improve our sandwich bot

Let’s add stablecoin sandwiches this time!

Solid Quant
12 min readFeb 18, 2024
Fotor AI: “Chihuahua on top of a sandwich”

Hello everyone! This article is the third installment in our sandwich bot series. Readers who haven’t read the previous articles yet can refer to the following articles for some context:

  1. Setting up the sandwich bot:

2. Sending sandwich bundles:

All the code from this series will beopen sourced on Github:

✅ In this article, we’re going to add non-WETH sandwich bundles — specifically stablecoin sandwiches using USDT/USDC — and see if they can help improve our system.

✅ After this, we’ll try to group bundle multiple sandwiches together when it’s possible, and try to maximize profits so that our chances of winning will go up.

With these new features, our system will take a step closer to becoming a production-ready system. Don’t forget, though, we’re only doing Uniswap V2 sandwiches at the moment, and we’ll be adding V3 orders as well the following week. In the end, we’ll have a complete system that can do the following:

  • Uniswap V2 sandwiches
  • Uniswap V3 sandwiches
  • Stablecoin sandwiches
  • Group bundling of multiple sandwiches

Adding more currencies

So far, we’ve been using the WETH token as our main currency. But we’d like to change that today. We would like to make our system more flexible so that it can trade USDT pair, USDC pairs, etc. as well.

We’ll start pushing some updates to our code to make this possible right away.

Right now, we are at phase2 of our roadmap:

And now we’ll moving to phase3:

We’ll first add more currencies that we’d like to use other than the WETH token. We need to change sandooo/src/common/constants.rs file for this:

Before:

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

After:

pub static WETH: &str = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
pub static USDT: &str = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
pub static USDC: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";

pub static WETH_BALANCE_SLOT: i32 = 3;
pub static USDT_BALANCE_SLOT: i32 = 2;
pub static USDC_BALANCE_SLOT: i32 = 9;

pub static WETH_DECIMALS: u8 = 18;
pub static USDT_DECIMALS: u8 = 6;
pub static USDC_DECIMALS: u8 = 6;

We’ll add the tokens USDT and USDC. And we specify the balance slot values of each. The balance slot value of these tokens can be found by calling the get_balance_slot method of EvmSimulator (sandooo/src/common/evm.rs):

pub fn get_balance_slot(&mut self, token_address: H160) -> Result<i32> {
let calldata = self.abi.token.encode("balanceOf", token_address)?;
self.evm.env.tx.caller = self.owner.into();
self.evm.env.tx.transact_to = TransactTo::Call(token_address.into());
self.evm.env.tx.data = calldata.0;
let result = match self.evm.transact_ref() {
Ok(result) => result,
Err(e) => return Err(anyhow!("EVM ref call failed: {e:?}")),
};
let token_b160: B160 = token_address.into();
let token_acc = result.state.get(&token_b160).unwrap();
let token_touched_storage = token_acc.storage.clone();
for i in 0..30 {
let slot = keccak256(&abi::encode(&[
abi::Token::Address(token_address.into()),
abi::Token::Uint(U256::from(i)),
]));
let slot: rU256 = U256::from(slot).into();
match token_touched_storage.get(&slot) {
Some(_) => {
return Ok(i);
}
None => {}
}
}

Ok(-1)
}

Calling this function as below:

let mut simulator = EvmSimulator::new(provider.clone(), None, block_number);

let usdt = H160::from_str(USDT).unwrap();
let balance_slot = simulator.get_balance_slot(usdt).unwrap();
println!("USDT balance slot: {}", balance_slot); // 2

will get us the results we want. The function will figure out the balance slot value by statically calling the ERC-20 function balanceOf which will have to reference the balance mappings of the token contract.

However, this method isn’t a perfect solution for figuring out the balance slot. It won’t work on tokens that have separate implementation and storage contracts, a.k.a proxy tokens. But it’ll do the job for now, because it works for all WETH, USDT, USDC tokens.

Next we update our sandooo/src/common/utils.rs:

#[derive(Debug, Clone)]
pub enum MainCurrency {
WETH,
USDT,
USDC,

Default, // Pairs that aren't WETH/Stable pairs. Default to WETH for now
}

impl MainCurrency {
pub fn new(address: H160) -> Self {
if address == to_h160(WETH) {
MainCurrency::WETH
} else if address == to_h160(USDT) {
MainCurrency::USDT
} else if address == to_h160(USDC) {
MainCurrency::USDC
} else {
MainCurrency::Default
}
}

pub fn decimals(&self) -> u8 {
match self {
MainCurrency::WETH => WETH_DECIMALS,
MainCurrency::USDT => USDC_DECIMALS,
MainCurrency::USDC => USDC_DECIMALS,
MainCurrency::Default => WETH_DECIMALS,
}
}

pub fn balance_slot(&self) -> i32 {
match self {
MainCurrency::WETH => WETH_BALANCE_SLOT,
MainCurrency::USDT => USDT_BALANCE_SLOT,
MainCurrency::USDC => USDC_BALANCE_SLOT,
MainCurrency::Default => WETH_BALANCE_SLOT,
}
}

/*
We score the currencies by importance
WETH has the highest importance, and USDT, USDC in the following order
*/
pub fn weight(&self) -> u8 {
match self {
MainCurrency::WETH => 3,
MainCurrency::USDT => 2,
MainCurrency::USDC => 1,
MainCurrency::Default => 3, // default is WETH
}
}
}

We add a handler for our new main currencies. It’s straightforward enum data type that can return the main currency’s decimal and balance slot values. But you’ll recognize that we have an extra field called “weight”. This new field is used in another new function (still sandooo/src/common/utils.rs):

pub fn return_main_and_target_currency(token0: H160, token1: H160) -> Option<(H160, H160)> {
let token0_supported = is_main_currency(token0);
let token1_supported = is_main_currency(token1);

if !token0_supported && !token1_supported {
return None;
}

if token0_supported && token1_supported {
let mc0 = MainCurrency::new(token0);
let mc1 = MainCurrency::new(token1);

let token0_weight = mc0.weight();
let token1_weight = mc1.weight();

if token0_weight > token1_weight {
return Some((token0, token1));
} else {
return Some((token1, token0));
}
}

if token0_supported {
return Some((token0, token1));
} else {
return Some((token1, token0));
}
}

We use the weight value to figure out which main currency to prefer if we are trying to trade a pair of two main currencies, like WETH-USDT, USDT-USDC pairs.

Making our simulator multi-currency

To support stablecoin sandwiches, we next have to update our simulator code in sandooo/src/sandwich/simulation.rs. We’ll start with the extract_swap_info function.

Before:

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)
};

After:

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

let (main_currency, target_token, token0_is_main) =
match return_main_and_target_currency(token0, token1) {
Some(out) => (out.0, out.1, out.0 == token0),
None => continue,
};

Instead of filtering out the WETH pairs, we’ll figure out the main currency by using our newly added function return_main_and_target_currency.

👆 The trickiest part about performing non-WETH pair sandwiches is in determining the profitability of the bundle.

This is because we’ll be performing swaps in this manner:

  • Frontrun Transaction: USDT/USDC → Target token
  • Backrun Transaction: Target token → USDT/USDC

You can see that our profits will be represented in USDT/USDC, not in WETH, so it becomes difficult to subtract the gas fees and assess if our bundle was profitable or not.

We add a neat trick to take care of this issue.

What if we thought about non-WETH sandwiches in this manner:

  • Frontrun Transaction: USDT/USDC → Target token
  • Backrun Transaction: Target token → USDT/USDC
  • USDT/USDC → WETH (an extra swap step to change the output amount of tokens to WETH)

Of course, we won’t actually be making the swap, because that’s an extra step in our transaction which will cost more gas, and eventually make our sandwich bundles uncompetitive.

But we’ll assume that we do by adding a a conversion function to think of USDT/USDC amounts in WETH values. These functions will look like (sandooo/src/sandwich/simulation.rs):

pub fn convert_usdt_to_weth(
simulator: &mut EvmSimulator<Provider<Ws>>,
amount: U256,
) -> Result<U256> {
let conversion_pair = H160::from_str("0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852").unwrap();
// token0: WETH / token1: USDT
let reserves = simulator.get_pair_reserves(conversion_pair)?;
let (reserve_in, reserve_out) = (reserves.1, reserves.0);
let weth_out = get_v2_amount_out(amount, reserve_in, reserve_out);
Ok(weth_out)
}

pub fn convert_usdc_to_weth(
simulator: &mut EvmSimulator<Provider<Ws>>,
amount: U256,
) -> Result<U256> {
let conversion_pair = H160::from_str("0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc").unwrap();
// token0: USDC / token1: WETH
let reserves = simulator.get_pair_reserves(conversion_pair)?;
let (reserve_in, reserve_out) = (reserves.0, reserves.1);
let weth_out = get_v2_amount_out(amount, reserve_in, reserve_out);
Ok(weth_out)
}

We’ll use two popular and liquid V2 pools:

to convert between these tokens.

In phase2, we calculated the sandwich profitability as follows (BatchSandwich.simulate function):

  let eth_balance_after = simulator.get_eth_balance_of(simulator.owner);
let weth_balance_after = simulator.get_token_balance(weth, bot_address)?;

let eth_used_as_gas = eth_balance_before
.checked_sub(eth_balance_after)
.unwrap_or(eth_balance_before);
let eth_used_as_gas_i256 = I256::from_dec_str(&eth_used_as_gas.to_string())?;

let weth_balance_before_i256 = I256::from_dec_str(&weth_balance_before.to_string())?;
let weth_balance_after_i256 = I256::from_dec_str(&weth_balance_after.to_string())?;

let profit = (weth_balance_after_i256 - weth_balance_before_i256).as_i128();
let gas_cost = eth_used_as_gas_i256.as_i128();
let revenue = profit - gas_cost;

this assumed that all bundles always resulted in WETH tokens.

But we’ll change this to look like:

let eth_balance_after = simulator.get_eth_balance_of(simulator.owner);
// Get all main currency balances for: WETH/USDT/USDC
let mut mc_balances_after = HashMap::new();
for (main_currency, _) in &starting_mc_values {
let balance_after = simulator.get_token_balance(*main_currency, bot_address)?;
mc_balances_after.insert(main_currency, balance_after);
}

let eth_used_as_gas = eth_balance_before
.checked_sub(eth_balance_after)
.unwrap_or(eth_balance_before);
let eth_used_as_gas_i256 = I256::from_dec_str(&eth_used_as_gas.to_string())?;

let usdt = H160::from_str(USDT).unwrap();
let usdc = H160::from_str(USDC).unwrap();

let mut weth_before_i256 = I256::zero();
let mut weth_after_i256 = I256::zero();

for (main_currency, _) in &starting_mc_values {
let mc_balance_before = *mc_balances_before.get(&main_currency).unwrap();
let mc_balance_after = *mc_balances_after.get(&main_currency).unwrap();

// convert USDT/USDC to WETH so that we can think in terms of WETH values
let (mc_balance_before, mc_balance_after) = if *main_currency == usdt {
let before =
convert_usdt_to_weth(&mut simulator, mc_balance_before).unwrap_or_default();
let after =
convert_usdt_to_weth(&mut simulator, mc_balance_after).unwrap_or_default();
(before, after)
} else if *main_currency == usdc {
let before =
convert_usdc_to_weth(&mut simulator, mc_balance_before).unwrap_or_default();
let after =
convert_usdc_to_weth(&mut simulator, mc_balance_after).unwrap_or_default();
(before, after)
} else {
(mc_balance_before, mc_balance_after)
};

let mc_balance_before_i256 = I256::from_dec_str(&mc_balance_before.to_string())?;
let mc_balance_after_i256 = I256::from_dec_str(&mc_balance_after.to_string())?;

weth_before_i256 += mc_balance_before_i256;
weth_after_i256 += mc_balance_after_i256;
}

let profit = (weth_after_i256 - weth_before_i256).as_i128();
let gas_cost = eth_used_as_gas_i256.as_i128();
let revenue = profit - gas_cost;

You can see that we are using the functions convert_usdt_to_weth and convert_usdc_to_weth to convert the resulting USDT/USDC balances into WETH and that we’re adding all of the main currency balances together to calculate the profitability.

Non-WETH pair appetizer 🥗

If you have added support for non-WETH pair simulation, let’s now go ahead and take a look at our appetizer.

Instead of having a hardcoded value for the decimals variable:

Before:

let decimals = WETH_DECIMALS;

After:

let main_currency = info.main_currency;
let mc = MainCurrency::new(main_currency);
let decimals = mc.decimals();

We’ll get the decimals value of all supported main currencies.

Also, instead of testing the sandwich bundle with 0.01 WETH as we did in phase2:

let small_amount_in = U256::from(10).pow(U256::from(decimals - 2)); // 0.01 WETH

We’ll change this to:

let small_amount_in = if is_weth(main_currency) {
U256::from(10).pow(U256::from(decimals - 2)) // 0.01 WETH
} else {
U256::from(10) * U256::from(10).pow(U256::from(decimals)) // 10 USDT, 10 USDC
};

We’ll run the simulation using:

  • WETH: 0.01
  • USDT/USDC: 10

tokens.

Moreover, the ceiling_amount_in value will now be:

let ceiling_amount_in = if is_weth(main_currency) {
U256::from(100) * U256::from(10).pow(U256::from(18)) // 100 ETH
} else {
U256::from(300000) * U256::from(10).pow(U256::from(decimals)) // 300000 USDT/USDC (both 6 decimals)
};

We’ll be using this value to optimize the sandwich bundle by assuming that no sandwiches are bigger than 100 ETH, 300000 USDT/USDC.

Group Bundling + Main Dish 🥪🍛

We’ll go ahead and serve the main dish in this section. We’ll be looking at how to group multiple sandwiches together. Now our sandwich bundles will look something like:

  • Frontrun Transaction: WETH → Target token #1, USDT → Target token #2
  • Victim Transaction #1: Buy target token #1
  • Victim Transaction #2: Buy target token #2
  • Backrun Transaction: Target token #1 → WETH, Target token #2 → USDT

Although the bundle looks like a big mac now with double the size of our previous sandwich bundles, it’s not that complicated so don’t worry. 🙏

We just have to find all the potential sandwiches that can be done using WETH, USDT, USDC tokens. And we’re already doing this with our simulator.

We keep the results of our simulations in our sandooo/src/sandwich/strategy.rs file, specifically in promising_sandwiches:

let mut promising_sandwiches: HashMap<H256, Vec<Sandwich>> = HashMap::new();

You’ll notice that we use this HashMap in our appetizer function after we’re done optimizing:

if optimized_sandwich.max_revenue > U256::zero() {
// add optimized sandwiches to promising_sandwiches
if !promising_sandwiches.contains_key(&tx_hash) {
promising_sandwiches.insert(tx_hash, vec![sandwich.clone()]);
} else {
let sandwiches = promising_sandwiches.get_mut(&tx_hash).unwrap();
sandwiches.push(sandwich.clone());
}
}

If the optimized sandwich has a max revenue greater than 0, then we know that this sandwich bundle is profitable even after excluding the gas fees. So we save it into our promising_sandwiches HashMap.

📍 Sandwich strategies are capital intensive strategies, because they aren’t atomic and so flashloan/flashswaps aren’t possible.

Because of this, if you compare two searcher who are starting with 1 ETH and 100 ETH, the latter will win.

Some sandwiches are small in size so you can even compete with something like 0.5 ETH, but most sandwiches will be greater than 1 ~ 2 ETH in size, and when there are multiple sandwiches we ideally want to capture all of them so that our bundle is the most profitable.

So how do we know how to allocate our capital when there are multiple sandwiches opportunities?

You’ll recall in phase2 that we looped through all the possible sandwiches and sent single sandwich bundles to builders.

But the updated code will score all the sandwich bundles and sort them by this score and distribute funds according to the rank.

We’re now on sandooo/src/sandwich/main_dish.rs file’s main_dish function.

First, we’ll get WETH, USDT, USDC balances from our bot contract:

let weth = H160::from_str(WETH).unwrap();
let usdt = H160::from_str(USDT).unwrap();
let usdc = H160::from_str(USDC).unwrap();

let bot_balances = if env.debug {
// assume you have infinite funds when debugging
let mut bot_balances = HashMap::new();
bot_balances.insert(weth, U256::MAX);
bot_balances.insert(usdt, U256::MAX);
bot_balances.insert(usdc, U256::MAX);
bot_balances
} else {
let bot_balances =
get_token_balances(&provider, bot_address, &vec![weth, usdt, usdc]).await;
bot_balances
};

Next, we create a plate vector, and store a new data type called Ingredients to plate.

#[derive(Debug, Clone)]
pub struct Ingredients {
pub tx_hash: H256,
pub pair: H160,
pub main_currency: H160,
pub amount_in: U256,
pub max_revenue: U256,
pub score: f64,
pub sandwich: Sandwich,
}

let mut plate = Vec::new();
for (promising_tx_hash, sandwiches) in promising_sandwiches {
for sandwich in sandwiches {
let optimized_sandwich = sandwich.optimized_sandwich.as_ref().unwrap();
let amount_in = optimized_sandwich.amount_in;
let max_revenue = optimized_sandwich.max_revenue;
let score = (max_revenue.as_u128() as f64) / (amount_in.as_u128() as f64);
let clean_sandwich = Sandwich {
amount_in,
swap_info: sandwich.swap_info.clone(),
victim_tx: sandwich.victim_tx.clone(),
optimized_sandwich: None,
};
let ingredients = Ingredients {
tx_hash: *promising_tx_hash,
pair: sandwich.swap_info.target_pair,
main_currency: sandwich.swap_info.main_currency,
amount_in,
max_revenue,
score,
sandwich: clean_sandwich,
};
plate.push(ingredients);
}
}

The part to take note of is the new score variable introduced here.

Score is calculated as:

score = max_revenue / amount_in

You can think of this value as an indicator of how much profit we can expect to earn with the amount_in we invest.

We’ll sort Ingredients in descending order:

plate.sort_by(|x, y| y.score.partial_cmp(&x.score).unwrap());

❗ ️However, we have to take caution doing this. You should understand what bug you’re introducing to this system by doing this.

Recall how our USDT/USDC sandwich bundles are simulated. The amount_in value will be represented in terms of USDT/USDC tokens. And the max_revenue will be represented in terms on WETH (we intentionally converted USDT/USDC values to WETH).

This means that USDT/USDC sandwich scores are always greater than that of WETH sandwiches.

But this could be a good thing, because we want to consider stablecoin sandwiches before WETH sandwiches if we believe they’ll be more competitive. And with the current sorting mechanism, we’ll always enter into stablecoin sandwiches before we do WETH sandwiches. So we’ll leave the sorting system like this for now, and fix later if we want a different asset allocation technique.

Next, we’ll create batch sandwiches using the Ingredients in our plate vector:

for i in 0..plate.len() {
let mut balances = bot_balances.clone();
let mut sandwiches = Vec::new();

for j in 0..(i + 1) {
let ingredient = &plate[j];
let main_currency = ingredient.main_currency;
let balance = *balances.get(&main_currency).unwrap();
let optimized = ingredient.amount_in;
let amount_in = std::cmp::min(balance, optimized);

let mut final_sandwich = ingredient.sandwich.clone();
final_sandwich.amount_in = amount_in;

let new_balance = balance - amount_in;
balances.insert(main_currency, new_balance);

sandwiches.push(final_sandwich);
}

let final_batch_sandwich = BatchSandwich { sandwiches };

// ...
}

We’ll loop through all sandwiches on our plate like the following:

  • Sandwich #1
  • Sandwich #1, Sandwich #2
  • Sandwich #1, Sandwich #2, Sandwich #2

and try to invest as much as we can by taking the min value of main currency balance and the optimized amount in.

Say for instance that our balance looks like this:

  • WETH: 2
  • USDT: 100

and we are considering three sandwich opportunities:

  • Sandwich #1 optimized amount in: 90 USDT
  • Sandwich #2 optimized amount in: 1.2 WETH
  • Sandwich #2 optimized amount in: 1 WETH

Since investing in all three requires us to have 90 USDT, 2.2 WETH, which we are short on the WETH side, we take the following approach:

  • Sandwich #1: min(100, 90) = 90 USDT

First, take the min value of our current USDT balance which is 100 with the optimized amount in value of 90. This is 90 USDT.

  • Sandwich #2: min(2, 1.2) = 1.2 WETH

Second, we take the min value of our current WETH balance of 2 WETH with 1.2 WETH. This is 1.2 WETH. After this, we subtract 1.2 WETH from our WETH balance, and update the balance to 0.8 WETH.

  • Sandwich #2: min(0.8, 1) = 0.8 WETH

Third, we take the min value of our updated WETH balance which is 0.8 WETH with the optimized amount in value of 1 WETH and get 0.8 WETH as a result.

In sum, we are investing 90 USDT, 2 WETH in total.

Note that investing in every sandwich bundles will be the most profitable. The method mentioned above was a fun exercise to support bots running on small funds.

With these updates, we can now capture stablecoin sandwiches and group bundle multiple sandwiches.

Hope this article was a fun read. 😃

🏄 Join our Discord community and discuss MEV/crypto related subjects with like-minded people from all over the world 🏖️

☕ Also, follow my Twitter and reach out for a coffee chat: https://twitter.com/solidquant

--

--