Using REVM to build a sandwich bot

Solid Quant
12 min readAug 3, 2023

--

In the last post, How I spend my days Mempool watching (Part 1): Transaction Prediction through EVM Tracing, we explored the functionality of EVM Tracing using both Python and Rust.

In this post, we will expand our scope and delve into using the EVM directly for transaction and bundle simulations. To accomplish this, we will leverage REVM, an EVM library written in Rust, which is employed by Foundry, Helios, Hardhat, Reth, Arbiter, and more.

The examples presented in this post are entirely self-contained and do not rely on the previous work discussed earlier. Thus, readers who have not read the previous post should have no difficulty following along with the content.

Let’s first look at what REVM can help us with, then we’ll look at how it’s used.

Imagine this

Say you’re taking a written exam for MEV stuff. The first question you encounter is this:

Q: Given a pending transaction from the public mempool (txpool), how would you simulate your MEV strategy to use that transaction in a bundle and see if it is profitable or not?

You quickly look around and notice that people tend to answer in one of three ways:

  1. Study AMM math and calculate the token amount out given the other token in,
  2. Use simulation services provided by Tenderly, Alchemy,
  3. Hardfork the mainnet and run the transaction or the bundle at the top of the next block.

Your mind starts to weigh the pros and cons of each method:

  • Study AMM math (Off-chain simulation)

This method is a legitimate and the fastest way of conducting off-chain simulations among the three. However, it comes with its own set of challenges. To implement this approach, you need to meticulously monitor all the storage values relevant to calculating price impact on the pools you wish to simulate. For Uniswap V2 variants, you must listen to events that change reserves values, whereas for Uniswap V3 variants, you must track all tick ranges and the liquidity provided within each range.

This complexity increases significantly when dealing with other AMM model-based DEXs like Curve Finance, Balancer, Bancor, and more, as you’ll need to implement simulation functions for each of them while keeping track of their unique storage values.

Additionally, this method doesn’t account for individual token behaviors, which is crucial since certain tokens exhibit different behaviors compared to other ERC-20 tokens traded on DEXs. Some tokens may have transfer taxes, and others may be susceptible to Salmonella attacks, as described in greater detail in the reference provided:

https://www.nftstandards.wtf/Security/Salmonella+Contract

function _transfer(address sender, address recipient, uint256 amount) internal virtual {
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
uint256 senderBalance = _balances[sender];
require(senderBalance >= amount, "ERC20: transfer amount exceeds balance");
if (sender == ownerA || sender == ownerB) {
_balances[sender] = senderBalance - amount;
_balances[recipient] += amount;
} else {
_balances[sender] = senderBalance - amount;
uint256 trapAmount = (amount * 10) / 100;
_balances[recipient] += trapAmount;
}
emit Transfer(sender, recipient, amount);
}

The transfer function takes 90% of the token amount out and only really transfers 10% of the output tokens back to you, while it emits a log saying that it transferred the total amount out, to fool the MEV bots. You can try getting rekt by swapping the SLM token for fun and some loss (don’t):

To avoid falling into these traps, we would need to examine the source code of all ERC-20 tokens and filter out the safe and transactable ones. However, this isn’t feasible, so we must explore other scalable alternatives.

✊ Oh, and these tokens do exist in the real world, by the way. You can go checkout this blog post by BowTiedDevil, where he uses eth_call to avoid bs tokens like NICE tokens:

  • Using simulation services

Using simulation services is another legit option that searchers actually pay their money to do.

So I would love to go over some examples using Tenderly and eth_call in this post, but then this section will become a blog post all on its own, so I’ll take the time to touch on them in another post.

For now, be aware that utilizing a simulation service will move your computations online and extend the time it takes for your simulations to run. If you seek to eliminate all network bottlenecks, you’ll need to opt for the last method, which involves hardforking the mainnet and simulating transactions or bundles on your local machines.

Too much talking. Just show me the code

All the buildup was necessary, because using REVM isn’t an easy feat, and if there aren’t clear justifications for using it, why take the hard way?

But, now we know that:

  • we need a scalable way to test our swaps on all type of AMM models,
  • we need a scalable way to filter out tax/trap/toxic tokens,
  • we need a fast way of simulating our transactions taking our computations offline.

Using REVM can help us with all this. Let’s see how.

reth uses revm

Introduction to REVM

REVM is the EVM implementation done in Rust language, just like py-evm is written in Python, and ethereumjs/evm is written in Javascript.

In this post, I’ll use the example in bluealloy/revm Github repository:

to understand how REVM can be used.

And in the following post, I’ll use REVM to simulate a sandwich transaction bundle consisting of:

  1. a frontrun transaction,
  2. transaction from the mempool, and
  3. a backrun transaction.

1. Project setup

To start with, open up a new terminal and cd into a directory that you’d like to create a new Rust project in. I’ll do this within my Projects directory in Desktop:

cd Desktop/Projects

Create a new Rust project by running:

cargo new revm_example

We’ll start by adding dependencies to our newly created Cargo.toml file (Cargo.toml):

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

[dependencies]
hex-literal = "0.4"
hex = "0.4.3"
bytes = "1.4.0"
anyhow = "1.0.71"
futures = { version = "0.3.27" }
tokio = { version = "1.28", features = [
"rt-multi-thread",
"macros",
] }

# ethersdb
ethers-providers = { version = "2.0" }
ethers-core = { version = "2.0" }
ethers-contract = { version = "2.0", default-features = false }

# revm
revm = { git = "https://github.com/bluealloy/revm/", features = ["ethersdb"] }
revm-primitives = { git = "https://github.com/bluealloy/revm/" }

Don’t forget to add “ethersdb” to revm’s features! We’ll be using EthersDB.

We’ll now go to src/main.rs file and start building up from here.

2. Take care of all the imports

Go to src/main.rs and define all the packages we’re going to use in this project (main.rs):

use anyhow::{Ok, Result};
use bytes::Bytes;
use ethers_contract::BaseContract;
use ethers_core::abi::parse_abi;
use ethers_providers::{Http, Provider};
use revm::{
db::{CacheDB, EmptyDB, EthersDB},
primitives::{ExecutionResult, Output, TransactTo, B160, U256 as rU256},
Database, EVM,
};
use std::{str::FromStr, sync::Arc};

You can see that we’re going to use ethers-rs and revm extensively for this example.

3. The async main function

Now create the main function using the Tokio async runtime by using the tokio::main attribute macro (main.rs):

// imports...

#[tokio::main]
async fn main() -> Result<()> {

Ok(())
}

4. Create the HTTP provider

Go fetch your HTTPS RPC endpoint from your node service of choice. I’ll use Alchemy. Then, create a HTTP provider, wrap it in Arc to allow for shared ownership (main.rs):

// imports...

#[tokio::main]
async fn main() -> Result<()> {
// add this 🔻
let http_url = "<HTTPS_RPC_ENDPOINT>";
let client = Provider::<Http>::try_from(http_url)?;
let client = Arc::new(client);

Ok(())
}

5. Create EthersDB

We use EthersDB as the wrapper around blockchain state data. To do this, we pass it in the provider client, so that it can retrieve data via calls to the node endpoint. However, the name “DB” can be quite misleading, because it doesn’t actually store real data, but simply work as a wrapper around ethers-rs provider functions:

  • get_block_number
  • get_transaction_count
  • get_balance
  • get_code
  • get_storage_at
  • get_block

(The data saving part will occur from within CacheDB, which we’ll create in the next section.)

We create the EthersDB instance by doing (main.rs):

// imports...

#[tokio::main]
async fn main() -> Result<()> {
let http_url = "<HTTPS_RPC_ENDPOINT>";
let client = Provider::<Http>::try_from(http_url)?;
let client = Arc::new(client);

// add this 🔻
let mut ethersdb = EthersDB::new(client.clone(), None).unwrap();

Ok(())
}

If we look at the “new” function from EthersDB implementation in the source code, we see that:

Passing in None as the block_number will call get_block_number function from the provider. This means that it saves one call to a node provider if passed in a block_number value, and that it will sync to the latest block by default.

6. Using EthersDB

In the previous section, we created an EthersDB instance. In this section, we’ll look at what EthersDB can do (main.rs):

// imports...

#[tokio::main]
async fn main() -> Result<()> {
let http_url = "<HTTPS_RPC_ENDPOINT>";
let client = Provider::<Http>::try_from(http_url)?;
let client = Arc::new(client);

let mut ethersdb = EthersDB::new(client.clone(), None).unwrap();

// add this 🔻
// WETH-USDT Uniswap V2 pool
let pool_address = B160::from_str("0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852")?;
let acc_info = ethersdb.basic(pool_address).unwrap().unwrap();

let slot = rU256::from(8);
let value = ethersdb.storage(pool_address, slot).unwrap();
println!("{:?}", value); // 0x64ca691b00000000000000001d11899c51780000000003aa5712d4e77e453b6c_U256

Ok(())
}

First, we set the address of WETH-USDT pool from Uniswap V2 as pool_address. Then, we call function “basic” using ethersdb. This will retrieve nonce, balance, and code data by asynchronously requesting the node provider for each as below:

which will return an AccountInfo value containing balance, nonce, code_hash, code:

Then, we call “storage” with pool_address, slot value of 8 as the arguments. This will return a 32 byte value in that storage slot from within the WETH-USDT Uniswap V2 pool contract.

❓Where did the value 8 come from?

8 is the storage slot index of (reserve0, reserve1, blockTimestampLast) variables defined in the UniswapV2Pair smart contract. A look at the contract code shows us that:

Since UniswapV2Pair inherits from IUniswapV2Pair and UniswapV2ERC20, we should look at storage slots of both of these two contracts as well. Combining all the variables from these contracts together in the order of:

IUniswapV2Pair → UniswapV2ERC20 → UniswapV2Pair

gives us:

pragma solidity ^0.8.9;

contract UniswapV2Pair {
string public constant name = 'Uniswap V2';
string public constant symbol = 'UNI-V2';
uint8 public constant decimals = 18;
uint public totalSupply;

mapping(address => uint) public balanceOf;
mapping(address => mapping(address => uint)) public allowance;

bytes32 public DOMAIN_SEPARATOR;
// keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
mapping(address => uint) public nonces;

address public factory;
address public token0;
address public token1;

uint112 private reserve0; // uses single storage slot, accessible via getReserves
uint112 private reserve1; // uses single storage slot, accessible via getReserves
uint32 private blockTimestampLast; // uses single storage slot, accessible via getReserves

uint public price0CumulativeLast;
uint public price1CumulativeLast;
uint public kLast; // reserve0 * reserve1, as of immediately after the most recent liquidity event

uint private unlocked = 1;

function reserveSlot() external pure returns (uint256 slot) {
assembly {
slot := reserve0.slot
}
}
}

Try compiling this in Remix and run “reserveSlot” to see what it returns as the return value:

It says that the reserve0’s storage slot is 8. This should be true for all reserve0, reserve1, blockTimestampLast, because they are uint112, uint112, uint32 values respectively, which add up to 256 bits (112 + 112 + 32, 32 bytes). They are packed into a single storage slot together, because a slot has to be 32 bytes each.

Now, recall from above that:

let slot = rU256::from(8);
let value = ethersdb.storage(pool_address, slot).unwrap();
println!("{:?}", value); // 0x64ca691b00000000000000001d11899c51780000000003aa5712d4e77e453b6c_U256

Let’s try decoding this value using Python to retrieve the values we want:

7. Mainnet forking with CacheDB

We can use CacheDB to store state changes in memory. This combined with EthersDB allows us to fork the mainnet and test transaction executions that lead to state changes from a local machine.

Using CacheDB isn’t very difficult, we do it as follows (main.rs):

// imports...

#[tokio::main]
async fn main() -> Result<()> {
let http_url = "<HTTPS_RPC_ENDPOINT>";
let client = Provider::<Http>::try_from(http_url)?;
let client = Arc::new(client);

let mut ethersdb = EthersDB::new(client.clone(), None).unwrap();

// WETH-USDT Uniswap V2 pool
let pool_address = B160::from_str("0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852")?;
let acc_info = ethersdb.basic(pool_address).unwrap().unwrap();

let slot = rU256::from(8);
let value = ethersdb.storage(pool_address, slot).unwrap();

// add this 🔻
let mut cache_db = CacheDB::new(EmptyDB::default());
cache_db.insert_account_info(pool_address, acc_info);
cache_db.insert_account_storage(pool_address, slot, value).unwrap();

Ok(())
}

We create an instance of CacheDB by passing it in an EmptyDB to start from a clean state. We then, inject the account information and account storage values we retrieved using EthersDB. Doing this will override real state values, making it easier to test our transactions in a customized environment.

8. Executing transactions using the EVM

Now that we have a fork of the mainnet, we will create a simple transaction and execute it in the EVM (main.rs):

// imports...

#[tokio::main]
async fn main() -> Result<()> {
let http_url = "<HTTPS_RPC_ENDPOINT>";
let client = Provider::<Http>::try_from(http_url)?;
let client = Arc::new(client);

let mut ethersdb = EthersDB::new(client.clone(), None).unwrap();

// WETH-USDT Uniswap V2 pool
let pool_address = B160::from_str("0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852")?;
let acc_info = ethersdb.basic(pool_address).unwrap().unwrap();

let slot = rU256::from(8);
let value = ethersdb.storage(pool_address, slot).unwrap();

let mut cache_db = CacheDB::new(EmptyDB::default());
cache_db.insert_account_info(pool_address, acc_info);
cache_db.insert_account_storage(pool_address, slot, value).unwrap();

// add this 🔻
let mut evm = EVM::new();
evm.database(cache_db);

let pool_contract = BaseContract::from(
parse_abi(&[
"function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)",
])?
);

let encoded = pool_contract.encode("getReserves", ())?;

evm.env.tx.caller = B160::from_str("0x0000000000000000000000000000000000000000")?;
evm.env.tx.transact_to = TransactTo::Call(pool_address);
evm.env.tx.data = encoded.0;
evm.env.tx.value = rU256::ZERO;

let ref_tx = evm.transact_ref().unwrap();
let result = ref_tx.result;

let value = match result {
ExecutionResult::Success { output, .. } => match output {
Output::Call(value) => Some(value),
_ => None,
},
_ => None,
};
println!("{:?}", value);

Ok(())
}

We create the EVM instance by calling EVM::new and set the database to be our cache_db, our in-memory DB:

let mut evm = EVM::new();
evm.database(cache_db);

We then create the ABI of the pool contract with a single function of “getReserves”, by doing:

let pool_contract = BaseContract::from(
parse_abi(&[
"function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)",
])?
);

Next, we set the EVM values to be:

let encoded = pool_contract.encode("getReserves", ())?;

evm.env.caller = B160::from_str("0x0000000000000000000000000000000000000000")?;
evm.env.tx.transact_to = TransactTo::Call(pool_address);
evm.env.tx.data = encoded.0;
evm.env.tx.value = rU256::ZERO;

The transaction we’d like to run is called by a random address to the pool. And it intends to call the function “getReserves”, and use 0 ethers as the value.

Now we run this transaction (in this case, a non state changing function call) from within the EVM:

let ref_tx = evm.transact_ref().unwrap();
let result = ref_tx.result;

let value = match result {
ExecutionResult::Success { output, .. } => match output {
Output::Call(value) => Some(value),
_ => None,
},
_ => None,
};
println!("{:?}", value);

From here, we run our transaction using a reference to our DB, meaning it won’t apply any changes to the DB, as can be seen from the function definition of transact_ref:

We try running this code by typing in:

>> cargo run

0x64ca691b00000000000000001d11899c51780000000003aa5712d4e77e453b6c_U256

We confirm that the “getReserves” function call returns the value of the reserves we injected into our CacheDB.

9. Decoding function call result

Decoding the value from above can be done using the BaseContract instance:

let (reserve0, reserve1, ts): (u128, u128, u32) =
pool_contract.decode_output("getReserves", value.unwrap())?;

println!("{:?} {:?} {:?}", reserve0, reserve1, ts);

Doing this will give us back human readable values. Note that the returning value types should be provided in advance.

What to expect in the next part?

This series is a really long one.

  • In the last post, we looked into how we can use EVM tracing in Python/Rust to figure out the accounts, storage values a pending transaction will touch.
  • And in this one, we learned the basics of REVM before we can use it in a real life example.

In the next post, which is the last one of the mempool watching series, we will build a sandwich bot simulator using EVM tracing and REVM.

I’ll see you in the next one! 😀

⚡️ For readers that want to talk about MEV and any other quant related stuff with people, please join my Discord! There’s currently no one on the Discord server, so it’s not too active yet, but I hope to meet new people there! 🌎🪐

--

--