Understanding MEV of Rebasing Tokens using Phalcon Fork

In this tutorial, I will show you how to use Phalcon Fork to understand the MEV of rebasing tokens. The code is released on the GitHub.

Background

What is MEV

The definition of MEV has been changed in the past years. Previously, it meant the Miner Extract Value. However, MEV means Maximal Extractable Value that can be extracted by manipulating the transaction order inside a block. You can refer to the document for more information.

What is a rebasing token

The rebasing token means the supply is adjusted based on some pre-defined algorithm. One rebasing method is to adjust the balance of each token holder perilously.

Lido's staked Ether token stETH is a rebasing ERC20 rebasing token. The token holder's balance is changed daily. The balance of stETH of a token holder is calculated using the following formula.

balanceOf(account) = shares[account] * totalPooledEther / totalShares

Lido has an excellent document describing the rebasing mechanism.

In summary, the balance of stETH token holders will be increased daily.

Uniswap V2 pool

When swapping token X to token Y inside the Uniswap v2 pool, users usually interact with the Uniswap router contract, which will find the pool contract of the tokens. However, a user can directly invoke the swap function inside a pool.

However, how to determine the price of the tokens? Uniswap uses the constant product formula for this purpose.

X1āˆ—Y1=X0āˆ—Y0X_1 * Y_1 = X_0 * Y_0

X1,X0X_1, X_0means the balance of token X inside the pool after and before the swap. Y0,Y1Y_0, Y_1means the balance of token Y after and before the swap.

However, each swap will have a fee (0.003 currently). So, the formula becomes the following (suppose we use Token X to swap Token Y -- X is the token into the pool, and Y is the token out of the pool).

(X0+Ī”Xāˆ’0.003āˆ—Ī”X)āˆ—(Y0āˆ’Ī”Y)ā‰„X0āˆ—Y0(X_0 + \Delta X - 0.003 * \Delta X)* (Y_0 - \Delta Y) \geq X_0 * Y_0

The Ī”X\Delta X means the number of token X that has been transferred inside the pool, and Ī”Y\Delta Ymeans the number of token Y that will be swapped out.

In the Uniswap pool smart contract, it uses two variables _reserve0 and reserve1 to denote the balance before the swap, and use the balance0 and balance1 to denote the balance after the swap (but do not consider the fee yet -- we can ignore it here).

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

MEV Opportunity

The formula Ī”X=balanceof(X)āˆ’reserve0\Delta X = balanceof(X) - reserve0 calculates the input token X inside the pool (line 19 in the code). This works perfectly in most cases since the reserves recorded inside the pool are equal to the balances of tokens before the swap.

However, for the rebasing token like stETH the token balance increased periodically, which means the balance of stETH in this pool will be increased. This creates a MEV opportunity because the rebasing creates a Ī”X\Delta X inside the pool, which could be used to swap token Y out by anyone.

A Real Example

This opportunity has been observed and actively taken. Let me use a transaction as an example. The hash of this transaction is 0x4b94095fe2c0014156d6b400e4fc405895cb508ab2802fa27b6135f939de5725.

Through the balance change of this transaction in Phalcon Explorer, we can find that the address 0x7024960031000000e9f80000790090f61732277b got a profit of 0.01 Ether in this transaction, and paid 0.36 Ether as the bribe fee to FlashBot.

Why this transaction can make profit?

You can directly view this debug window using the following link.

Because the balance of stETH is bigger than reserve0 in the pool, the 0x7024 address invokes the swap function to use the increased balance (0.38 stETH) to swap WETH.

3,950,436,974,827,841,051,750 - 3,950,055,013,348,304,070,261 = 381,961,479,536,981,489

As a result, 0.379 WETH was swapped out. The address keeps 0.01 Ether and bribed 0.36 Ether to FlashBot.

Deploy/Debug an MEV contract inside Phalcon Fork

From the on-chain data, we can only observe the flow of the MEV transaction. However, we do not have the source code of the contract (0x7024).

To fully understand this process, we can develop our own MEV contract and debug it in Phalcon Fork.

Create a Fork

First, we need to create a Fork inside the dashboard of Phalcon Fork.

Develop the Contract

We use Foundry as the development framework. Add the following information to the configuration file (foundry.toml).

[rpc_endpoints]
phalcon = "${PHALCON_FORK_RPC_URL}"

[etherscan]
phalcon = { key = "${ETHERSCAN_API_KEY}", url = "${VERIFIER_URL}"}

Add a .env file to store the Phalcon Fork RPC, and API_KEY used to verify the contract. If you do not know the values, you can click the Configuration button inside the Fork. The following screenshot shows the detailed RPC, API_KEY, and verifier URL.

The source code of the smart contract is in the following.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

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

// Be cautious!! This is an example contract. DO NOT use it in a production environment.

contract MEVExample {

    IUniswapV2Pair stETH_WETH_pair = IUniswapV2Pair(0x4028DAAC072e492d34a3Afdbef0ba7e35D8b55C4);

    address owner;

    modifier onlyOwner {
        require(msg.sender == owner);
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function trigger() external onlyOwner {

        //query the balance and reserve of the pool
        address token0 = stETH_WETH_pair.token0();
        address token1 = stETH_WETH_pair.token1();

        (uint112 reserve0, uint112 reserve1, ) = stETH_WETH_pair.getReserves();

        uint256 balance0 = IERC20(token0).balanceOf(address(stETH_WETH_pair));
        uint256 balance1 = IERC20(token1).balanceOf(address(stETH_WETH_pair));

        // console.log("[stETH] reserve : balance", reserve0, balance0);
        // console.log("[WETH]  reserve : balance", reserve1, balance1);

        require (balance0 > reserve0, "Balance should be bigger than Reserve!");

        // calculate how many tokens can be swapped out 
        // this is the amount0In
        uint amount0In = balance0 - reserve0;

        uint amount1Out = balance1 - (uint(reserve0)*(reserve1)*(1000**2) / ((balance0 * 1000 - 3 * amount0In)))/1000;

        // console.log("[stETH] out ", amount1Out);

        // perform the swap
        stETH_WETH_pair.swap(0, amount1Out - 1, msg.sender, "");

    }
}

Deploy and Trigger the Contract

We use the Foundry script to deploy and trigger the contract.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Script.sol";
import {MEVExample} from "../src/MEV.sol";

contract MEVScript is Script {
    MEVExample public mevExample;

    function setUp() public {

    }

    function run() external {
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");

        vm.startBroadcast(deployerPrivateKey);

        mevExample = new MEVExample();
        mevExample.trigger();
        
        vm.stopBroadcast();
    }
}

The PRIVATE_KEY is configured inside the .env file. We need to import it before executing the script.

source .env

forge script script/MEV.s.sol:MEVScript --rpc-url $PHALCON_FORK_RPC_URL --broadcast --verify -vvvv

This will build, deploy, verify the contract, and invoke the trigger function to issue the MEV transaction.

From the Phalcon Scan, we can find the deployed contract and issued transactions. Also, we can use the Phalcon Explorer to understand and debug the transaction.

Summary

In this document, we show an example of developing an MEV contract and deploying it inside Phalcon Fork.

In fact, using Phalcon Fork, you can develop and write your contract with real mainnet states. You can ensure everything is correct before deploying it on the mainnet. Besides, Phalcon Fork can be integrated into the CD pipeline through the API.

Last updated