Let’s see if our sandwich bot really works

Do we have an edge in the MEV market

Solid Quant
14 min readFeb 4, 2024
Fotor AI: “Rich chihuahuas partying” Sorry about the cut-off ear chihuahua #1

Generating AI images is a fun process. I sat down to write this article and instead found myself generating random Chihuahua images until I got “the one.” It involved a lot of trial and errors, and I got to a point where I could start to understand how the AI model worked under the hood, and what prompt works the best for my purpose.

Similarly, building MEV bots is also enjoyable, and it also involves grinding and understanding why our bot works or doesn’t. But it’s slightly different from AI, because we can actually lose money while we’re testing our strategies. This is why we should analyze our competitive edge very carefully and test everything out before we start running our system.

Today, we are going to continue from where we left off last week:

We had built a system that could detect sandwich opportunities from Uniswap V2 forked DEXs. And we would like to see if we can really profit from sending bundles on-chain.

For readers that haven’t read through the code yet, here’s the code:

Before we get started though, I’ll ruin the fun for you a little bit, and mention that this system isn’t profitable yet. But it can be.

Over the next few weeks, we’ll take a deeper look at the system and try to add more optimizations to the code so that it’ll become more competitive.

  1. You’ll learn to take apart the simulation engine so that it can be applied to stablecoin sandwiches as well.
  2. You’ll also learn to group bundle your sandwiches (multiple victim sandwiches) and maximize profits.
  3. Lastly, you’ll learn how to extend this model to Uniswap V3 pools as well.

Integrating these additional features will significantly increase the likelihood of the system succeeding in real trades.

However, it’s important to note that this exercise shouldn’t be viewed as a shortcut to profiting in the MEV market, as there truly isn’t one. By the time you’ve successfully implemented all the features and stayed with me until the end, you’ll realize the considerable effort required to transform your system into a winning one.

I hope that these open-sourced guides can assist individuals in comprehending the nature of MEV, aiding them in uncovering genuine opportunities in the market.

Before we move on, I’d like to mention one thing.

There’s a question I constantly get from a lot of people:

Is the bot profitable?

And I never really give them a straight-forward answer, because the system can be profitable in the hands of someone who understands the system very well, and at the same time be useless in another’s.

There’s a character I used to like very much in One Piece, Robe Lucci.

One Piece: Robe Lucci, CP9

For those of you that aren’t familiar. Robe Lucci trained his body to become a weapon itself. And his skill, Shigan 👉, is capable of lethally striking an armed man with his index finger alone.

Likewise, I believe that all our systems can become like Shigan. It will become competitive if you understand the core logic very well. But it’s just a fragile finger if you don’t train yourself for it.

Table of contents

  1. Analyzing the sandwich bot’s competiveness
  2. Broadcasting bundles to multiple builders
  3. Next optimization steps

We’ll be going through some interesting topics in today’s article.

And if you ever get stuck anywhere, please feel free to reach out and ask others if they’re experiencing similar issues from the Discord server 🙏:

Let’s get started! 🏎

Analyzing the sandwich bot’s competiveness

To see if we have a chance at winning real sandwich trades, we’ll start running our code from where we left off in the previous article.

The snapshot of the code is on a different branch of the Github repository, you can go to phase1 branch for this:

Try running the code by doing:

cargo run

The program will update new pools and tokens that were launched on Uniswap V2 since our last run, and start monitoring for sandwich opportunities.

We let it run for a while 😴.

And we detect our first sandwich after 5 blocks:

We can see that the transaction of the victim is as follows:

0xd1a41244a9aab38f41ce5fb54ce5ba3bcd20e07afb439bc968b720f5031feb80

It’s a transaction that is making a trade using the Universal Router.

The optimized token in amount was 2.0 WETH, and we could expect to earn a profit of 0.0516 WETH from the sandwich bundle.

However, this is only if we don’t consider gas costs. We are using:

  • Frontrun transaction gas usage: 116,956
  • Backrun transaction gas usage: 106,769

and the base fee is at 22.27 gwei, so our total gas costs end up being within the range of:

0.00475 ~ 0.00498 ETH

There can be a slight difference on the gas costs, because of gas refunds and how foundry-evm calculates this, so we’ll go with the value we get from our simulation engine, which is 0.00475 ETH.

Then our revenue is expected to be:

  • 0.0516 - 0.00475 = 0.0469 WETH

Let’s see if our sandwich bundle is competitive.

Using Gambit Labs’ Auction Stats service, we can figure out how many people were submitting bundles to capture this opportunitiy, and see how much bribe they are sending to builders.

Paste in the transaction hash from Auction Stats tab and let’s see how competitive we were:

You can see that there are a lot of fellow searchers trying to capture the same opportunity:

Our revenue right now will be at top 15~16

The top briber on Gambit Labs was using 0.058102 ETH as the bribe. And our revenue after factoring in gas costs is 0.0469 ETH, so we can see that we need to optimize our contract a little more to win.

This time, let’s go to Eigenphi and see which searcher actually won this trade:

And the winner is Jared. No surprises there, but we’d like to know how he did it.

His frontrunning transaction looks like this:

and his backrunning transaction like this:

First, we check if our optimizations were done correctly. And it does seem like it is:

Jared got a value of 2 something WETH as the optimized amount in value as well.

However, this is the part where you’ll start to get confused. You’ll see that Jared has other trades in his frontrun and backrun transactions. And because of this, he is able to generate more profits compared to other sandwich bots.

The revenue we could have expected is:

0.0469 ETH * $2,300 (current ETH price) = $107.87

wherease Jared is able to generate $160.

This is because Jared is also doing arbitrage in his frontrun / backrun transactions.

Let’s briefly take a look at what he’s doing in the frontrunning transaction:

You can see that Jared is doing a 2-hop arbitrage between Uniswap V3 and V2. He was able to spot an arbitrage opportunity on dogwifhat pools in the two DEXs and add that in his frontrun transaction.

How do we know that this is an arbitrage opportunity?

Because Jared is trading on two pools:

  • Uniswap V3: 0xB9aD117834579543Ed5E79f2a32476d50D7cE35F
  • Uniswap V2: 0x11C20A3b83FF206e4aB6b5935D766564925b8B2b

they are both paired up with:

Well, that’s a bummer…because we can never win Jared now unless we implement arbitrage in our system as well.

No need to get disappointed just yet, because it’ll only get worse once you see how he’s exiting out of his initial RSTK tokens (what the victim was trying to trade on Universal Router).

His backrun transactions are quite complex as well. But it’s a combination of sandwich and arbitrage just like the frontrunning transaction.

Now, that’s strange. I thought we only needed to exit out of the RSTK position using the initial Uniswap V2 pool, right?

Not really, because if you think about it, there can be price differences on any different pools paired up with the same tokens, that means that an arbitrage opportunity might exist among these pools.

And that’s exactly what Jared is doing. Jared’s bot detects a price discrepancy between Uniswap V3 and V2 RSTK pools and performs an arbitrage and get’s an extra profit out of it.

With this, he is able to earn at least $50 more on the same opportunity that we spotted.

📍 There’s so much more to learn from Jared actually. He is also doing:
1. JIT liquidity provisioning in his frontrunning transactions, 2. picks up multiple victim transactions to sandwich them, 3. buys memecoins and performs non-WETH sandwich strategies using these tokens. But these are for after we’ve mastered the art of simple sandwich + arbitrage strategies.

I hope that this can give you an idea of how competitive you have to be to win in the Ethereum sandwich market nowadays.

Let’s try running our sandwich bot a bit longer:

We can see that we’re seeing sandwich opportunities every few blocks. And I hope you can try doing the same and run comparisons on:

  • Gambit Labs: a brief overview of how many people are competing and how much they’re bribing
  • Eigenphi: who’s actually winning the bundles, and what strategy they are using

This will give you a very clear idea about how much more we have to optimize our code.

Broadcasting bundles to multiple builders

In this section, we’re finally going to try and send some real bundles to builder endpoints. As you have seen in the previous section though, our sandwich bundle will not be competitive yet, so don’t expect you’ll land bundles until we’ve optimized the code a bit more.

We’ll still see how we can broadcast our bundles to multiple builders and see for ourselves how competitive this basic strategy is from Gambit Labs. We’ll look at:

  1. How fast we are submitting them,
  2. How much we are able to bribe builders.

The first step to take before we submit our bundles is to deploy our smart contract on the mainnet. We’ll be using Foundry for this.

Before we really deploy the given contract here:

We’ll quickly run some tests and see if all the functions work as intended.

👉 First, let’s see if we can send ETH and ERC-20 tokens to our contract and recover them back. One mistake I made when I was first starting out was forgetting to add this function and I had to look at my ETH get locked up in the contract. Hopefully we know that this won’t happen with the Sandooo contract.

Start an Anvil process by doing:

anvil --fork-url http://localhost:8545 --port 2000

I’ll start a fork of the mainnet and run Anvil on port 2000.

Next, write the test function for the contract as follows in sandooo/contracts/test/Sandooo.t.sol:

pragma solidity 0.8.20;

import "forge-std/Test.sol";
import "forge-std/console.sol";

import "../src/Sandooo.sol";

contract SandoooTest is Test {
Sandooo bot;
IWETH weth = IWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);

receive() external payable {}

function test() public {
console.log("Sandooo bot test starting");

// Create Sandooo instance
bot = new Sandooo();

uint256 amountIn = 100000000000000000; // 0.1 ETH

// Wrap 0.1 ETH to 0.1 WETH and send to Sandooo contract
weth.deposit{value: amountIn}();
weth.transfer(address(bot), amountIn);

// Check if WETH is properly sent
uint256 botBalance = weth.balanceOf(address(bot));
console.log("Bot WETH balance: %s", botBalance);

// Check if we can recover WETH
bot.recoverToken(address(weth), botBalance);
uint256 botBalanceAfterRecover = weth.balanceOf(address(bot));
console.log(
"Bot WETH balance after recover: %s",
botBalanceAfterRecover
); // should be 0

// Check if we can recover ETH
(bool s, ) = address(bot).call{value: amountIn}("");
console.log("ETH transfer: %s", s);
uint256 testEthBal = address(this).balance;
uint256 botEthBal = address(bot).balance;
console.log("Curr ETH balance: %s", testEthBal);
console.log("Bot ETH balance: %s", botEthBal);

// Send zero address to retrieve ETH
bot.recoverToken(address(0), botEthBal);

uint256 testEthBalAfterRecover = address(this).balance;
uint256 botEthBalAfterRecover = address(bot).balance;
console.log("ETH balance after recover: %s", testEthBalAfterRecover);
console.log("Bot ETH balance after recover: %s", botEthBalAfterRecover);

console.log("============================");
}
}

and run:

forge test --fork-url http://localhost:2000 --match-contract SandoooTest -vv

and check the logs we get:

[PASS] test() (gas: 265096)
Logs:
Sandooo bot test starting
Bot WETH balance: 100000000000000000
Bot WETH balance after recover: 0
ETH transfer: true
Curr ETH balance: 79228162514064337593543950335
Bot ETH balance: 100000000000000000
ETH balance after recover: 79228162514164337593543950335
Bot ETH balance after recover: 0
============================

You can see that we can safely recover our funds after we’ve sent them to our contract.

👉 Next, we’ll try to make a simulated swap on a Uniswap V2 pair and confirm that our contract indeed works.

Try adding this to the test function we wrote earlier:

// Transfer WETH to contract again
weth.transfer(address(bot), amountIn);
uint256 startingWethBalance = weth.balanceOf(address(bot));
console.log("Starting WETH balance: %s", startingWethBalance);

address usdt = 0xdAC17F958D2ee523a2206206994597C13D831ec7;
address wethUsdtV2 = 0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852;

IUniswapV2Pair pair = IUniswapV2Pair(wethUsdtV2);
address token0 = pair.token0();
address token1 = pair.token1();

// We will be testing WETH --> USDT
// So it's zeroForOne if WETH is token0
uint8 zeroForOne = address(weth) == token0 ? 1 : 0;

// Calculate the amountOut using reserves
(uint112 reserve0, uint112 reserve1, ) = IUniswapV2Pair(address(pair))
.getReserves();

uint256 reserveIn;
uint256 reserveOut;

if (zeroForOne == 1) {
reserveIn = reserve0;
reserveOut = reserve1;
} else {
reserveIn = reserve1;
reserveOut = reserve0;
}

uint256 amountInWithFee = amountIn * 997;
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = reserveIn * 1000 + amountInWithFee;
uint256 targetAmountOut = numerator / denominator;

console.log("Amount in: %s", amountIn);
console.log("Target amount out: %s", targetAmountOut);

bytes memory data = abi.encodePacked(
uint64(block.number), // blockNumber
uint8(zeroForOne), // zeroForOne
address(pair), // pair
address(weth), // tokenIn
uint256(amountIn), // amountIn
uint256(targetAmountOut) // amountOut
);
console.log("Calldata:");
console.logBytes(data);

uint gasBefore = gasleft();
(bool success, ) = address(bot).call(data);
uint gasAfter = gasleft();
uint gasUsed = gasBefore - gasAfter;
console.log("Swap success: %s", success);
console.log("Gas used: %s", gasUsed);

uint256 usdtBalance = IERC20(usdt).balanceOf(address(bot));
console.log("Bot USDT balance: %s", usdtBalance);

require(success, "FAILED");

We’ll try to buy some USDT using WETH.

Try running the test with the command:

forge test --fork-url http://localhost:2000 --match-contract SandoooTest -vv

and we’ll get:

[PASS] test() (gas: 348846)
Logs:
Sandooo bot test starting
Bot WETH balance: 100000000000000000
Bot WETH balance after recover: 0
ETH transfer: true
Curr ETH balance: 79228162514064337593543950335
Bot ETH balance: 100000000000000000
ETH balance after recover: 79228162514164337593543950335
Bot ETH balance after recover: 0
============================
Starting WETH balance: 100000000000000000
Amount in: 100000000000000000
Target amount out: 229783289
Calldata:
0x0000000001244bcc010d4a11d5eeaac28ec3f61d100daf4d40471f1852c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000000db236f9
Swap success: true
Gas used: 82214
Bot USDT balance: 229783289

We can see that the test succeeds.

Now that we’ve tested out our contracts and have seen that all our functions work well, we can deploy to mainnet.

Luckily, this contract is very simple, so the compiled bytecode amounts to this:

0x6080604052600436101561001e575b361561001c5761001c61012d565b005b6000803560e01c80638da5cb5b146100d05763b29a814014610040575061000e565b3461009e57604036600319011261009e57806001600160a01b0360043581811681036100cc576100776024359284541633146100f5565b82811591826000146100a157505060011461008f5750f35b81808092335af11561009e5780f35b80fd5b60449250908093916040519263a9059cbb60e01b845233600485015260248401525af11561009e5780f35b5050fd5b503461009e578060031936011261009e57546001600160a01b03166080908152602090f35b156100fc57565b60405162461bcd60e51b81526020600482015260096024820152682727aa2fa7aba722a960b91b6044820152606490fd5b60008054610145906001600160a01b031633146100f5565b60405143823560c01c03610209576008600482019160248101925b36831061016e575050505050565b823560f81c926060906001810135821c916015820135901c9487806044878260298701359a6069604989013598019b63a9059cbb60e01b8452898b528d525af1156102055784888094819460a49463022c0d9f60e01b8552806000146101f9576001146101ee575b50306044840152608060648401525af1610160578480fd5b8288528a52386101d6565b508752818a52386101d6565b8780fd5b5080fdfea264697066735822122070cd8d8a51fe625e0f10f1ea26f94679859661cf1936f171d337a6616cfb19ad64736f6c63430008140033

That’s very short, don’t you think?

I tried deploying this on mainnet using the command:

forge create --rpc-url <your_rpc_url> --private-key <your_private_key> src/Sandooo.sol:Sandooo

and I used 181,016 in gas, and at:

  • base fee: 10.6 gwei
  • max fee: 21.57 gwei
  • max priority fee: 3 gwei

used 0.00246 ETH to deploy the contract. That’s $5.67.

Try pulling from Github and you’ll now see that the phase2 branch has been merged to our main branch.

And there’s the execution.rs file in sandooo/src directory:

If you’re planning to test this out, please double check all the logic and take care! There’re so many ways a contract deployment can go wrong, I always take extra steps to ensure I’m not missing anything. (🛑 Also, don’t trust what I tell you in this article, confirm the logic yourself before you can try things on the mainnet. As a matter of fact, don’t trust anyone 🛑)

Change the DEBUG field in .env file so that the code can run using real WETH in the contract:

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=false // <-- change this to false to run with real bot
RUST_BACKTRACE=1

Make sure to add the real BOT_ADDRESS and PRIVATE_KEY of the address that was used to deploy the bot contract.

I tried sending 0.5 WETH to the contract to test out my logic.

With this, we can try running our bot:

cargo run

Now we wait, and pray everything goes well. 🙏

I ended up sending one bundle 8 blocks after I started the system.

We want to focus on the “Bundle sent” part of our log this time:

Bundle sent:
{
"gambit": SendBundleResponse { bundle_hash: 0x9b728ef4bb79af616b2aa9f49d703e2b2f1e28ce8a8115b6cb3622db4ec8ccaa },
"flashbots": SendBundleResponse { bundle_hash: 0x9b728ef4bb79af616b2aa9f49d703e2b2f1e28ce8a8115b6cb3622db4ec8ccaa },
"rsync": SendBundleResponse { bundle_hash: 0x0000000000000000000000000000000000000000000000000000000000000000 },
"penguinbuild": SendBundleResponse { bundle_hash: 0x0000000000000000000000000000000000000000000000000000000000000000 },
"titanbuilder": SendBundleResponse { bundle_hash: 0x63c4bbb135785635c9dde5b571a082800ede504597c09967d84614df7150f321 },
"builder0x69": SendBundleResponse { bundle_hash: 0x0000000000000000000000000000000000000000000000000000000000000000 },
"beaverbuild": SendBundleResponse { bundle_hash: 0x0d0a698ee45dae9a516582651e853f8426fb41ac74fe787f7b4b1059dcec5d95 }
}

We see that we’ve successfully sent to Gambit Labs, Flashbots, Rsync, Penguin Build, Titan Builder, Builder0x69, and Beaverbuild.

We are interested in Gambit Labs, so let’s head over to their website and check out the transaction hash and see what the competition is like:

0x1924235bfe061560fd9725320cf4c26825422ea1aff42ec913a76e53370d7199

This is the victim’s transaction hash:

You can checkout the auction status here, and our bundle is at the bottom of the competition:

Compare our bribe amount to the top bidder who paid 0.009547 ETH as bribe. The top bidder is paying twice as much as we are.

To see why this may be the case. Head on over to Eigenphi this time and see what the optimized amount in value was. Our value 0.39728 WETH.

But our simulation is still fast enough as you can see from here:

Our bundle is being sent well within a second and so we can conclude that network latency isn’t the issue here. We’ll look at what the winning searcher was doing.

This is the same as what Jared got:

but definitely not enough to compete with his massive sandwich, that looks like this:

So, we’re definitely getting somewhere, but we certainly do need to find a way to improve our bot’s performance. Don’t worry, we will. Our system is only doing Uniswap V2 sandwiches right now, and we can only handle single sandwich per bundle, so if we were already earning profits, then the market will be very boring.

Next optimization steps

In the following 2 ~ 3 articles, we’ll try to optimize this system as much as we can by adding:

  1. Stablecoin sandwiches
  2. Multiple sandwich bundling
  3. Uniswap V3 sandwiches

and see if we can compete with this system.

Hint: We still can’t. 🤣 But we’ll get very very close. And I’ll give you some tips on how you can win in this type of market.

If you found this article interesting and would like to keep following the progress, follow me on Twitter! https://twitter.com/solidquant

--

--