
Any imbalance that makes it past the Mempool is a potential source of MEV profit. In this post, I describe the process of discovering, analyzing, and executing a long-tail MEV strategy on the Ethereum Mainnet. We will discuss Mempool monitoring, oracle backrunning, recurring Defi exploits, and more. I’ll also showcase how to use mevlog.rs CLI and web interface for finding long-tail profit opportunities.
Disclaimer: The information provided in this blog post is for educational purposes only and should not be treated as financial advice.
Spoiler alert: I made $6.50
How to discover and profit from imbalanced ERC20 pools?
Code examples for this tutorial are available in the repo.
The core of the strategy we will explore is called ”skimming”. It’s a mechanism implemented by the UniswapV2 protocol, that is designed to balance token reserves.
UniswapV2 pools have a built-in skimming feature, and MEV bot operators can use it to profit from imbalanced pools.
This is how it works: UniswapV2 pool has two sources of data on its current ERC20 tokens balances. Internally, it keeps reserve0
and reserve1
storage slots, but actual token balances are determined by calling the balanceOf(address)
method on the ERC20 token contract.
If balanceOf
of token0
returns a value higher than reserve0
, a surplus amount can be “skimmed” from the pool or used to make the standard swap
operation. This is an expected behavior for a UniswapV2 pool. Any swap
operation is executed by first transferring the input token to the pool. Usually, these details are abstracted away by the UniswapV2Router
contract. However, MEV bot operators typically use the swap
method directly, transferring the input token without the help of the router.
Skimming is arguably one of the simplest MEV strategies:
- you don’t need any capital, except for gas fees, to execute it
- custom contract is optional; you can call a pool contract directly
- off-chain calculations are relatively straightforward, or you can call the
skim
method without any maths needed
As you can imagine, these opportunities are highly competitive. I’ve run a skimmer bot on a few EVM chains. On side chains, it’s still possible to regularly find a few cents or even 10+ dollars lying around. But the Mainnet is insanely competitive.
I’ve collected a list of ~20 Mainnet UniswapV2-compatible factories with over 400k pools. Here’s a Rust snippet you can use to discover factory contracts by observing Sync(uint112,uint112)
logs and some duck-typing Solidity calls:
examples/discover_factories.rs
sol! {
event Sync(uint112 indexed current, uint112 indexed delta);
#[sol(rpc)]
interface IUniV2Pair {
function factory() returns(address);
}
#[sol(rpc)]
interface IUniV2Factory {
function allPairsLength() returns(uint);
}
}
#[tokio::main]
async fn main() -> Result<()> {
let mut known_factories = HashSet::new();
println!("Discovering factories...");
let rpc_url = "wss://ethereum-rpc.publicnode.com";
let ws = WsConnect::new(rpc_url);
let provider = ProviderBuilder::new().on_ws(ws).await?;
let provider = Arc::new(provider);
let filter = Filter::new()
.event(Sync::SIGNATURE)
.from_block(BlockNumberOrTag::Latest);
let sub = provider.subscribe_logs(&filter).await?;
let mut stream = sub.into_stream();
while let Some(log) = stream.next().await {
let ipair = IUniV2Pair::new(log.address(), provider.clone());
let factory = match ipair.factory().call().await {
Ok(factory) => factory._0,
Err(_e) => {
continue;
}
};
let ifactory = IUniV2Factory::new(factory, provider.clone());
let all_pairs_length = match ifactory.allPairsLength().call().await {
Ok(all_pairs_length) => all_pairs_length._0,
Err(_e) => {
continue;
}
};
if all_pairs_length == U256::ZERO {
continue;
}
if known_factories.contains(&factory) {
continue;
}
println!("Found new UniV2 factory: {:?}", factory);
known_factories.insert(factory);
}
Ok(())
}
Running it for a while should produce a similar output:
cargo run --example discover_factories
# Discovering factories...
# Found new UniV2 factory: 0x5c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f
# Found new UniV2 factory: 0x5fa0060fcfea35b31f7a5f6025f0ff399b98edf1
# Found new UniV2 factory: 0xc0aee478e3658e2610c5f7a4a2e1777ce9e4f2ac
# Found new UniV2 factory: 0x115934131916c8b277dd010ee02de363c09d037c
It should allow you to quickly gather addresses of active, UniswapV2-compatible factories on any EVM chain. Later, you can get all the pool addresses from the factory contract. Alternatively you can rework this script to analyze past log entries instead of live monitoring.
For my skimmer bot, I’m using aggregate contract similar to UniswapFlashQuery. With this setup running concurrently on a local Reth node, it’s possible to scan these 400k pools for imbalances in ~10 seconds. It was a decent improvement because the initial synchronous and non-batched version took over 15 minutes to scan the same dataset.
It means that all of the new blocks are covered. But, I’ve observed only a few imbalances that made it past the mempool and were immediately snatched by other MEV bots.
Skimming strategy is well-known, so no wonder there was no easy profit there. But I’ve decided to dig deeper.
One exploit a day
One reason for the UniV2 pool to become imbalanced are so-called “rebasing tokens”. These are ERC20 tokens, with balanceOf(address)
implementation that does not map directly to the balances storage slot, but has a dynamic component:
A popular token implementing such mechanism is Staked Ether (stETH) by Lido. Here a source code excerpt:
function balanceOf(address _account) external view returns (uint256) {
return getPooledEthByShares(_sharesOf(_account));
}
function getPooledEthByShares(uint256 _sharesAmount) public view returns (uint256) {
return _sharesAmount
.mul(_getTotalPooledEther())
.div(_getTotalShares());
}
As you can see balanceOf
delegates to getPooledEthByShares
that multiplies _sharesAmount
static storage by results of _getTotalPooledEther()
and divides by _getTotalShares()
. It means that when outputs of these method calls change, balance of stETH
in the UniswapV2 pool will increase (while reserve0
stays the same) making skimming possible.
Call tracing can be useful for quickly spotting ERC20 tokens with non-standard balance logic. Let’s first use mevlog-rs CLI to detect a sample transaction that swapped the stETH and WETH tokens on UniswapV2:
mevlog search -b 100:latest --event 0xae7ab96520de3a18e5e111b5eaab095312d7fe84 --event 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 --event "Sync(uint112,uint112)"
and now visit mevlog.rs
to analyze a sample matching transaction call traces:
It proxies output of the cast run
command, and since the website uses a Reth archive node, it will work even for older transactions.
You can see that the balanceOf
call of the stETH (0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84
) contract triggers subsequent calls to the getApp
method. Compare it to balanceOf
of WETH (0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
), which produces only a simple Return
statement. This technique can help detect tokens with nonstandard (potentially rebasing) balance logic without digging through the smart contracts’ source code.
To observe how stETH rebase is extracted, I’ve added a balance monitoring script to investigate what happens when stETH balances increases.
#[tokio::main]
async fn main() -> Result<()> {
let rpc_url = std::env::var("RPC_WS").expect("RPC_WS is not set");
let ws = WsConnect::new(rpc_url);
let provider = ProviderBuilder::new().on_ws(ws).await?;
let mut block_stream = provider.subscribe_blocks().await?.into_stream();
let steth = ERC20::new(STETH, provider.clone());
let mut balance = steth.balanceOf(TARGET).call().await?._0;
while let Some(block) = block_stream.next().await {
let new_balance = steth.balanceOf(TARGET).call().await?._0;
if new_balance != balance {
balance = new_balance;
notify_slack(
&format!("New stETH balance: {}, block: {}", balance, block.number)
)
.await?;
}
}
Ok(())
}
After getting the block number, I checked what landed on top by running a mevlog-rs CLI command:
mevlog search -b 21916169 -p 0
As expected, position 0 in this block is a Lido oracle update transaction:
mevlog search -b 21916169 -p 1
Followed by a weird transaction executing the expected skim
operation and a dozen “Idol” NFT token transfers. This MEV bot operator is also responsible enough to immediately start farming all the skimmed stETH tokens:
On a side note, the mevlog.rs web version is handy for quickly checking the surrounding context of any transaction. Here’s a link showing data for these two neighboring transactions. On Etherscan, it takes like 5+ clicks to check what’s before and after a transaction from the same block.
You can also get the same data from the CLI:
mevlog tx 0x5afb7ef3d0151ad93d2bd2808b4f333763774635699e52b9846d5cd69abc6403 -a 1 -r --trace revm
It produces:
# Gas Price: 4.82 GWEI
# Gas Tx Cost: 0.00449 ETH | $8.92
# Coinbase Transfer: 0.23117 ETH | $458.65
# Real Tx Cost: 0.23567 ETH | $467.57
# Real Gas Price: 252.75 GWEI
The MEV bot paid ~0.23 ETH
bribe to the coinbase miner account and pocketed ~0.026/$55
of profit. After some digging I’ve found that MEV bots apparently incorporated a past Idol NFT token exploit into their strategy. Despite the audit, a vulnerability was discovered around two years after the protocol launch. It allows the owner of a single NFT token to extract all the stETH staking rewards.
The initial attack did not even have to backrun the oracle update or excessively bribe the miner. But it’s almost identical to the recurring MEV extraction that is still taking place. Idol NFTs contract does not use an upgradeable proxy, so this recurring exploit is here to stay.
Before the exploit, the stETH rebase yielded only ~$150 total profit, with ~%98 going for the bribe. Thanks to the Idol NFTs, profit is now significantly higher. It’s awesome to see MEV searchers constantly evolving and learning from each other.
How to find less competitive MEV strategies?
I don’t really want to become an exploitooor for $50, so I kept searching. I returned to the imbalanced UniswapV2 pool that I discovered using the aggregate reserves scanner.
Apparently, WETH/eETH Sushi pool was not skimmed on time, and over $1 of potential profit made it past the Mempool. I’ve determined that exactly like stETH, eeETH is a rebasing token with oracle-based balance updates. One problem with this strategy was that some eETH oracle update transactions were massive at over 4 MILLION gas, making it less likely to sneak them into the Flashbots priority queue. That’s probably the reason why this opportunity made it past the Mempool. I’ve found a single bot account that did the Mempool oracle backrunning for this pair, with the following methods call trace:
This bot operator is using a Sushi router swapExactTokensForTokensSupportingFeeOnTransferTokens
method instead of a low-level swap
. His transaction uses over 200k gas compared to 120k from my initial simulation. I’ve decided to pursue this opportunity.
Simulating MEV profit opportunity with Foundry
My go-to way to validate an MEV strategy is to write a Forge test/simulation. I’ve started by downloading the eETH token codebase using cast
:
cast source 0x17144556fd3424edc8fc8a4c940b2d04936d17eb -d steth
It uses a proxy pattern, so make sure to use the correct address. After digging into the source code, I’ve found a single method that I care about:
Calling it changes token balances, making skimming possible. But it’s restricted to the admin account. Here’s the forge simulation that I’ve implemented:
contract EEthRebase is Test {
IUniswapV2Pair public sushi =
IUniswapV2Pair(0x6db0fe375ccB8AC3c2a0984D678Bcd99981Ddcb6);
IERC20 public eeth = IERC20(0x35fA164735182de50811E8e2E824cFb9B6118ac2);
IERC20 public weth = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
address public ME = address(0xbeef);
function test_Rebase() public {
vm.deal(ME, 1 ether);
uint256 eethBalanceBefore = eeth.balanceOf(address(sushi));
console.log("Eeth balance before: %d", eethBalanceBefore);
vm.broadcastRawTransaction(rebase_payload());
uint256 eethBalanceAfter = eeth.balanceOf(address(sushi));
uint256 surplus = eethBalanceAfter - eethBalanceBefore;
(uint112 reserve0, uint112 reserve1, ) = sushi.getReserves();
console.log("Eeth balance after: %d", eethBalanceAfter);
console.log("Eeth surplus: %d", surplus);
uint256 myWethBalanceBefore = weth.balanceOf(ME);
console.log("My Weth balance before: %d", myWethBalanceBefore);
uint256 wethAmountOut = getAmountOut(surplus, reserve0, reserve1);
console.log("wethAmountOut: %d", wethAmountOut);
sushi.swap(0, wethAmountOut, ME, new bytes(0));
uint256 myWethBalanceAfter = weth.balanceOf(ME);
console.log("My Weth balance after: %d", myWethBalanceAfter);
}
function getAmountOut(
uint256 amountIn,
uint256 reserveIn,
uint256 reserveOut
) public pure returns (uint256 amountOut) {
uint256 amountInWithFee = amountIn * 997;
uint256 numerator = amountInWithFee * reserveOut;
uint256 denominator = reserveIn * 1000 + amountInWithFee;
amountOut = numerator / denominator;
}
Running it produces:
forge test -vv --fork-url https://eth.merkle.io --fork-block-number 21986784 --via-ir
# [PASS] test_Rebase() (gas: 177393)
# Logs:
# Eeth balance before: 16701405221952935511
# Eeth balance after: 16701674447724070712
# Eeth surplus: 269225771135201
# My Weth balance before: 0
# wethAmountOut: 266528025931141
# My Weth balance after: 266528025931141
The beauty of simulations is that you don’t have to worry about protocol implementation details. eETH token logic is proxies all the way down, but all I care about is: moar token == better.
The test shows that broadcasting (vm.broadcastRawTransaction
) the oracle update transaction changes the pair’s eETH token balances, making the profitable swap
operation possible. BTW you can produce a raw, singed payload of any transaction to use for forge broadcasting by running:
cast tx 0x589d4025ac9337f5bfc465b342896be78e978cf5ff6517dd6d3ac60a4869ebef --raw
It was time to set the trap for the next oracle update tx. To do it I’ve configured a similar mempool monitoring script:
sol! {
event Swap(address,uint256,uint256,uint256,uint256,address);
}
#[tokio::main]
async fn main() -> Result<()> {
let rpc_url = std::env::var("RPC_WS").expect("RPC_WS is not set");
let ws = WsConnect::new(rpc_url);
let provider = ProviderBuilder::new().on_ws(ws).await?;
let mut tx_stream = provider
.subscribe_pending_transactions()
.await?
.into_stream()
.fuse();
let conf = "{\"tracer\": \"callTracer\", \"tracerConfig\": { \"withLog\": true }}";
while let Some(tx_hash) = tx_stream.next().await {
let Ok(Some(tx)) = provider.get_transaction_by_hash(tx_hash).await else {
continue;
};
let tx = tx.into_request();
let tracer_opts = serde_json::from_str::<GethDebugTracingOptions>(conf).unwrap();
let tracing_opts = GethDebugTracingCallOptions::default();
let tracing_opts = tracing_opts.with_tracing_options(tracer_opts);
let trace = match provider
.debug_trace_call(
tx.clone(),
BlockId::Number(BlockNumberOrTag::Latest),
tracing_opts,
)
.await
{
Ok(trace) => trace,
Err(_e) => {
// println!("Error tracing tx: {}", e);
continue;
}
};
let trace = match trace {
GethTrace::CallTracer(frame) => frame,
_ => continue,
};
let mut all_calls = Vec::new();
collect_calls(&trace, &mut all_calls);
for trace in all_calls {
let logs = trace.logs;
for log in logs {
let Some(topics) = log.topics else {
continue;
};
if topics.is_empty() {
continue;
}
let first_topic = topics[0];
if first_topic == Swap::SIGNATURE_HASH {
println!("Found swap tx {}", &tx_hash);
}
}
}
}
Ok(())
}
fn collect_calls(frame: &CallFrame, result: &mut Vec<CallFrame>) {
result.push(frame.clone());
for call in &frame.calls {
collect_calls(call, result);
}
}
Running it produces:
cargo run --example find_swap_txs
# Found swap tx 0xb9e154a84948889b93accb1d08e15b816dd92f111ce7e8f6d9df0bfbbeba0f8c
# Found swap tx 0xa8bb148a32e0c4bb37b2dda6c08f8c1aa3ad8fadc28725eaed5a09885a8fe910
You’ll need a WebSockets RPC endpoint with debug
RPC methods enabled to run this script. It monitors the mempool and traces all the discovered transactions using the built-in {tracer: 'callTracer'}
. Later, it extracts all the logs and prints tx hashes of transactions that emit matching logs.
To execute the eETH oracle backrun I’m monitoring for the Rebase(uint256,uint256)
event generated by a transaction originating from the eETH admin account. It eliminates the need to trace every single transaction in the mempool. But please remember that for mempool monitoring, you’ll probably need a proprietary full node or a commercial 3rd party plan. This mode of operation is likely to exhaust free RPC node limits quickly.
Once you get a matching tx hash, the rest of the process goes as follows:
- use
get_raw_transaction_by_hash
method to get a signed transaction payload - spawn a local Anvil process forking of the mainnet state
- change the local fork state by submitting signed transaction using the
send_raw_transaction
method - calculate a profitable swap amount and prepare your skimming transaction payload
- prepare a Flashbots-compatible bundle with oracle update and swap tx and submit it to the builders using
eth_sendBundle
RPC call
Describing all these steps with code examples would bloat the size of this post. But let me know in the comments if you’d like to read a more detailed tutorial on executing this strategy.
Step three is profit
Contrary to my previous post about Revm arbitrage simulations this time I have some actual profit to show for. Here I am, doxxing myself for the internet points. My first eETH oracle backrunning transaction made ~$0.35 of profit!
For a few days after submitting the first successful oracle backrun, I was dominating this corner of the blockchain and earned a total of ~$6.50:
Later, I shifted my focus to releasing the web version of mevlog.rs. After migrating from the Geth full node to the Reth archive node, I’ve only recently fixed the tracing config bug. So, maybe, this strategy will print a few more $ before the end of the month.
Summary
Finding any loophole in the current state of the Mainnet MEV game is always fun. I hope some of the described tools and techniques will prove helpful for your daily MEV search. And maybe help discover opportunities that can buy more than a cup of coffee. I’m up for more mempool spelunking in the near future so don’t forget to like and subscribe to get notified about new posts.