This repository implements how to profit from a Maximal Extractable Value (MEV) attack against a Decentralized Exchange (DEX) on the Binance Smart Chain (BSC) network.
Table of contents generated with markdown-toc
Type | Position | Transaction Hash | Transfer (WBNB) | MEV-Relay (BNB) | Transaction Fee (BNB) | Profit (BNB) |
---|---|---|---|---|---|---|
Front Run | 0 | 0.21913 WBNB -> 0xE3...3E90C -> 35,489.53984 IRT 0x906bf0f8bd71319bfb2938d6a07f29c823011d8d30a3afa306afb52773f38203 | -0.21913 | -0.00013 | ||
Victim | 1 | 0.362844 WBNB -> 0xE3...3E90C -> 58,272.54022 IRT 0x3f63df509a8708f75f7280a38ee7b70ac18817eb0c8c9d18c100d2dc73e48380 | ||||
Back Run | 2 | 35,489.53984 IRT -> 0xE3...3E90C -> 0.22019 WBNB 0x1737935a6ade5e3c0cbfa1fa2bb75dce27d3d1a7312e5b2c093bdab20cd369e0 | +0.22019 | -0.00005 | -0.00077 | |
+0.00106 | -0.00005 | -0.0009 | +0.00012 (+$0.7) |
Type | Position | Transaction Hash | Transfer (WBNB) | MEV-Relay (BNB) | Transaction Fee (BNB) | Profit (BNB) |
---|---|---|---|---|---|---|
Front Run | 0 | 0.48336 WBNB -> 0x37...8a8dc -> 37,926,166,753,754.67 PROMISE 0x3b8f2e39dddbb6decf2c1e8b15549b7312835503f3eeddcb19bb242c77c9366c | -0.48336 | -0.00013 | ||
Victim | 1 | 488 USDT -> 0x16...b0daE -> 0.81841 WBNB -> 0x37...8a8dc -> 63,607,618,504,143.055 PROMISE 0xab8f068806754ec0c0ac33570aed59901d3a71873c9d2b84bf440e0da016866b | ||||
Back Run | 2 | 37,926,166,753,754.67 PROMISE -> 0x37...8a8dc -> 0.486725 WBNB 0x01ae865a8e199a41acb5a57071610ee5b60fc241f181d7e08f70c8fa520afed2 | +0.48672 | -0.00273 | -0.00039 | |
+0.00336 | -0.00273 | -0.00052 | +0.00011 (+$0.066) |
Type | Position | Transaction Hash | Transfer (WBNB) | MEV-Relay (BNB) | Transaction Fee (BNB) | Profit (BNB) |
---|---|---|---|---|---|---|
Transfer Fee | 0 | 0x5eee8dce6d7314ef53261f81c3afb6d3e0c2f26955e67197b22a431a7cf989f2 | -0.00248 | |||
Front Run | 1 | 0.03326 WBNB -> 0x9D...103A5 -> 45.30036 LFT 0xfd0528069b42a762c77a1dd311fa073149f6068e63325011cff77cf3c13f2969 | -0.03326 | -0.00066 | ||
Victim | 2 | 0.31134 WBNB -> 0x9D...103A5 -> 397.67103 LFT 0xc76f1a83452ce383fa6cc49f0183172f79eb345538c760448f4afd8e51489f60 | ||||
Back Run | 3 | 45.30036 LFT -> 0x9D...103A5 -> 0.03658 WBNB 0xc4ac3154678e594615cc97bcb3a924bf65ef58a5c033394cf158c0b21b4bc619 | +0.03658 | -0.00014 | ||
+0.00332 | -0.00248 | -0.00080 | +0.00004 (+$0.02) |
Type | Position | Transaction Hash | Transfer (WBNB) | MEV-Relay (BNB) | Transaction Fee (BNB) | Profit (BNB) |
---|---|---|---|---|---|---|
Transfer Fee | 0 | 0xb8adc24e0ff82126b6f6dbecf38098ea69fec8704454d5b2d6c43d5c79f52c62 | -0.00306 | |||
Front Run | 26 | 0.93624 WBNB -> 0x84...E4A73 -> 7,851,389,136,191.288 DoveCoin 0x77df425af519f8f7a0f276fe18506f5994ce3a40a345b33c123cb43637391ef3 | -0.93624 | -0.00060 | ||
Victim | 27 | 0.022 WBNB -> 0x84...E4A73 -> 164,582,801,699.83146 DoveCoin 0x16f893d195bfdc461bb0d98e4c997d55c04ff21fb52bf3249bae8ea1383e2866 | ||||
Back Run | 90 | 7,851,389,136,191.288 DoveCoin -> 0x84...E4A73 -> 0.93976 WBNB 0x3d2ebf6d5bf6574fc59383dfc11013794c7c14f560c2083bcaa80258910b84a8 | +0.93976 | -0.00017 | ||
+0.00352 | -0.00306 | -0.00077 | -0.00031 (-$0.18) |
Type | Position | Transaction Hash | Transfer (WBNB) | MEV-Relay (BNB) | Transaction Fee (BNB) | Profit (BNB) |
---|---|---|---|---|---|---|
Front Run | 0 | 0.69236 WBNB -> 0x36...52050 -> 419.05221 USDT -> 0xCB...50043 -> 829.78960 ARTY 0x19228eff3516d7b868994a64d7800c01f2ab0a8f8dfa3d2548c74b050c62978d | -0.69236 | -0.00386 | ||
Victim (Maybe Try Front Run) | 1 | 914.41511 USDT -> 0xCB...50043 -> 1,768.01385 ARTY 0x5a54f193680c5cfe34bc1729f9047975d059a0463156191c3ee3e8c34bc5c959 | ||||
Victim | 2 | 244.84935 USDT -> 0xCB...50043 -> 470.47796 ARTY 0xc88560c1b4dae7289f4d788a591a3385020d41814b6b9e8f41b5776b9d202513 | ||||
Victim (Maybe Try Front Run) | 3 | 489.27772 USDT -> 0xCB...50043 -> 931.07042 ARTY 0x36c74124c73fe589e62b1a8f6ccd08f9bca6b702c2a932db13b30b2ef6dc9397 | ||||
Back Run | 4 | 829.78960 ARTY -> 0xCB...50043 -> 434.07649 USDT -> 0x36...52050 -> 0.71646 WBNB 0x01cf62f0434cb1afebec9b2cfae79cac199c2901f2891e1f1a7dc6bb2f38a5f1 | +0.71646 | -0.00335 | ||
+0.02410 | -0.00721 | +0.01689 (+$10.13) |
Type | Position | Transaction Hash | Transfer (WBNB) | MEV-Relay (BNB) | Transaction Fee (BNB) | Profit (BNB) |
---|---|---|---|---|---|---|
Front Run | 0 | 0.42776 WBNB -> 0x25...E3fE6 -> 741.06721 QORPO 0xd327fbc24c6bbf39e93692daa06e149e00441160cae16e5936d11a9c2275f92b | -0.42776 | -0.00582 | ||
Victim | 72 | 3,780 USDT -> 0x17...6f849 -> 6.27670 WBNB -> 0x25...E3fE6 -> 10,543.53051 QORPO 0x556e50fe2625706c949d0b071f80bb7f9a9b730561d065f4e5efeb2fa27a8e65 | ||||
Back Run | 86 | 741.06721 QORPO -> 0x25...E3fE6 -> 0.45089 WBNB 0xbc5a1c5107f4f40b4e3c091d94339dbd863883018eaa9bd2aa2021154baf2f74 | +0.45089 | -0.00046 | ||
+0.02313 | -0.00628 | +0.01685 (+$10.11) |
Type | Position | Transaction Hash | Transfer (WBNB) | MEV-Relay (BNB) | Transaction Fee (BNB) | Profit (BNB) |
---|---|---|---|---|---|---|
Front Run | 17 | 0.42469 WBNB -> 0x6e6...90A278 -> 175.20479 METFI -> 0x562...08F3d -> 0.46990 MOGUL 0xc422d6687869388727d3a41c0697039fa9ddf4f6e48be3feecd4e5a92b5a0248 | -0.42469 | -0.00074 | ||
Victim | 23 | 1592.55603 METFI -> 0x562...08F3d -> 4.12 MOGUL 0x7f03c55bbf741e9781cffd80040bbfec3b21b1d8eb173f47afb7d38890b6d537 | ||||
Back Run | 38 | 0.46990 MOGUL -> 0x562...08F3d -> 180.349207 MOGUL -> 0x6e6...90A278 -> 0.434972 WBNB 0x4dea3aa40d1c651fc6cb7f8c8a93f8fe116fe67ac3647cf163621969a6b280ce | +0.43497 | -0.00070 | ||
+0.01028 | -0.00144 | +0.00884 ($+5.30) |
Type | Position | Transaction Hash | Transfer (WBNB) | MEV-Relay (BNB) | Transaction Fee (BNB) | Profit (BNB) |
---|---|---|---|---|---|---|
Front Run | 0 | 0.42622 WBNB -> 0x36...52050 -> 255.56599 USDT -> 0xdb...252D4 -> 12,605.92082 ZEBEC 0x80153922bc5451d890cfe882f37c77ff4465dc23a76df592c450f444f4951166 | -0.42622 | -0.00557 | ||
Victim | 42 | 293.28351 USDT -> 0xdb...252D4 -> 14,371.14467 ZEBEC 0x74cedb99bedfa47b2c281707ebef3918f318a05ad802f567e9d2d657dc44f720 | ||||
Back Run | 54 | 12,605.92082 ZEBEC -> 0xdb...252D4 -> 256.36776 USDT -> 0x36...52050 -> 0.42713 WBNB 0x6068134cea60f8f0d81422b54d35a12c90ec814f9342a1175046f990963f0644 | +0.42713 | -0.00074 | ||
+0.00091 | -0.00631 | -0.00540 (-$3.24) |
This section is theoretical in nature and contains only the core information needed to develop an MEV attack. For specific implementation details, please refer to the other sections.
The theoretically expected block creation process on a blockchain is as follows:
On the blockchain, DEXs make it easier to exchange various tokens. However, users who use DEXs to exchange tokens risk losing their assets. The key to this vulnerability lies in the order of transactions within a block, which can be manipulated to create a security hole.
Arbigrage Attack
Sandwich Attack
MEV attacks have a serious adverse impact on the blockchain ecosystem, but blockchain's structural limitations make it difficult to solve this problem fundamentally. Currently, there are only technologies that partially mitigate these issues.
MEV attackers need to know the victim's transaction information to launch an attack, and this information is exposed to the attacker at the Mempool stage. Therefore, the following strategies can be used to protect the victim's transactions.
Steps
These services benefit all participants.
Services such as Flashbots on ETH, bloXroute on BSC, and 48 Club on BSC provide this functionality. However, in the case of bloXroute, some have claimed that it destroys the BSC ecosystem (see the More Information page below for more details).
Yes. The concept of separating the Block Proposer and Builder is called Proposer/Builder Separation (PBS).
A good place to start is with Ethereum's MEV-Boost.
PBS promotes decentralization of the Ethereum network, increasing the overall benefit to network participants through competition among block builders.
The cost of using the node server and bloXroute was significant, and the need to see results quickly prevented us from giving enough thought or refactoring to the structure of the project. I apologize if the code is hard to read. 🙇
contract: This is a Solidity project based on Hardhat.
docker
iac: Infrastructure as Code (IAC) written in Pulumi for running nodes.
pyrevm: This is a Python wrapper of REVM, a Rust implementation of the Ethereum Virtual Machine (EVM). The original can be found at paradigmxyz/pyrevm.
src: Python code to capture MEV opportunities.
tests/test_dex/test_curve_*.py
for details on supported Curve pools.tests: This is code to test the src
code.
For the region, I recommend New York, the USA, or Germany.
Do not separate clients and nodes performing MEV attacks from load balancers, etc.
It's important to utilize snapshots.
geth
can be somewhat less reliable.The project supports three paths
Name | Support Bundle | Base Price |
---|---|---|
General | X | - |
48 Club | O | - |
bloXroute | O | $5,000/Month |
TxBoost | O | - |
In [Transaction Sample for Sandwich Attack] (#transaction-sample-for-sandwich-attack), you can see that bloXroute and 48 Club have lower profits compared to General. Since MEV attacks are not carried out in isolation, there is inevitably competition from other competitors, which comes at a cost.
First, let's analyze General: General does not use MEV-Relay, so transaction execution and order are not guaranteed during an attack. If you fail to perform DEX transactions in the proper transaction order, you may be victimized by other attackers. If you try to attack after the MEV attack is complete, you may lose the transaction fee. Also, if the amount of tokens in the pool you tried to attack changes, the transaction may fail (assuming another attacker has already tried). Even if it fails, you will still incur gas costs.
Next, let's look at bloXroute and 48 Club. There are two main advantages to using MEV-Relay. First, you are guaranteed the order of your transactions, and second, you are not charged if a transaction fails. To eliminate this risk, you need to pay a de-risking fee. The cost of de-risking basically includes the fee you pay to MEV-Relay and the cost of bidding to win the competition.
For this reason, the cost of General may not be apparent when analyzing a single transaction.
More information
Let's assume that Pool A and Pool B exist.
sandwichFrontRun: The transactions are performed in the following order My Contract -> A Pool -> My Contract -> B Pool -> My Contract
.
sandwichFrontRunDifficult: The transaction is performed in the following order: My Contract -> A Pool -> B Pool -> My Contract
. In this case, each pool needs to verify that it can send tokens to the next pool and that the next pool can receive tokens, which can reduce the number of token transfers.
There are three functions
sandwichBackRunWithBloxroute: This function performs a BackRun, and then the fee is paid via bloXroute.
sandwichBackRun: This function performs a BackRun. It is functionally equivalent to sandwichBackRunWithBloxroute
, except that it does not involve paying a fee.
sandwichBackRunDifficult: This function performs a BackRun similar to sandwichBackRun
, but requires each pool to validate that it can send and receive tokens before the transaction can proceed.
The contract contract/contracts/dexes/SwapRouter.sol
facilitates swaps at a high level. This contract can execute swaps with just the DEX ID, pool address, token address, and amount information.
Swap supports the following DEXs
When writing Solidity test code, i considered the following:
The test code is located in the following files:
contract/test/dexes.ts
contract/test/ArbitrageSwap.ts
main_sandwich.py
file as a reference. This project contains several unused files or functions.on startup, the following features are enabled
main
function with the MEV function as a multiprocess.Filter transactions received from the Mempool stream are based on the presence of gas information and data. (If data is empty, the contract function is not called.)
Trace the transaction using debug_traceCall
:
src/apis/trace_tx.py::search_dex_transaction
, verify if the transaction involves a DEX swap. (You can get the exchange type, pool address, and amount information.) If there are multiple calls by the same swap, merge them into a single SwapEvent
.Transaction
class contains not only the SwapEvent
but also several pieces of information about the transaction.Transaction
through the queue pipe.pull a Transaction
out of the queue from each main
that ran as a multiprocess.
Analyze if the transaction can be attacked for profit:
calculate_uniswap_v2_sandwich
function.If the transaction is determined to have a profit potential, get the validator information to build the next block. Based on the validator address, determine whether to route the attack transaction as General, 48 Club, or bloXroute:
Create a sandwich transaction and send the bundle via the selected path.
Note: Steps 2-3 and 4-7 are split into different processes.
The MEV analysis revealed a few important trends:
Arbitrage vs. Sandwich Attacks: When given the opportunity to attack, sandwich attacks tended to yield higher profits than arbitrage. Therefore, when submitting a bundle to MEV-Relay with arbitrage, it was likely to lose in competition with other sandwich attack bundles.
Cautions for General Paths: If you submit Arbitrage with the General path, the transaction will always be executed unless canceled. This will always result in a GAS cost. If your transaction is placed right after the victim's transaction, you may benefit, but if you lose the race to other arbitrage attackers, you may lose money due to the GAS cost.
Speed up Mempool registration: Due to the lack of node optimization, we did not have enough opportunity to compete with other attackers via the generic path. Researching ways to make it faster for attackers to register their arbitrage transactions to the mempool could increase profits (however, the large cost outlay prevented continued experimentation).
When you trace a transaction, the results are provided on a per-function call basis. This allows you to see if the swap function is called and analyze the values passed as arguments to the function.
{
"jsonrpc": "2.0",
"result": [
{
"action": {
"callType": "call",
"from": "0x83806d539d4ea1c140489a06660319c9a303f874",
"gas": "0x1a1f8",
"input": "0x",
"to": "0x1c39ba39e4735cb65978d4db400ddd70a72dc750",
"value": "0x7a16c911b4d00000"
},
"blockHash": "0x7eb25504e4c202cf3d62fd585d3e238f592c780cca82dacb2ed3cb5b38883add",
"blockNumber": 3068185,
"result": {
"gasUsed": "0x2982",
"output": "0x"
},
"subtraces": 2,
"traceAddress": [],
"transactionHash": "0x17104ac9d3312d8c136b7f44d4b8b47852618065ebfa534bd2d3b5ef218ca1f3",
"transactionPosition": 2,
"type": "call"
},
{
"action": {
"callType": "call",
"from": "0x1c39ba39e4735cb65978d4db400ddd70a72dc750",
"gas": "0x13e99",
"input": "0x16c72721",
"to": "0x2bd2326c993dfaef84f696526064ff22eba5b362",
"value": "0x0"
},
"blockHash": "0x7eb25504e4c202cf3d62fd585d3e238f592c780cca82dacb2ed3cb5b38883add",
"blockNumber": 3068185,
"result": {
"gasUsed": "0x183",
"output": "0x0000000000000000000000000000000000000000000000000000000000000001"
},
"subtraces": 0,
"traceAddress": [
0
],
"transactionHash": "0x17104ac9d3312d8c136b7f44d4b8b47852618065ebfa534bd2d3b5ef218ca1f3",
"transactionPosition": 2,
"type": "call"
},
{
"action": {
"callType": "call",
"from": "0x1c39ba39e4735cb65978d4db400ddd70a72dc750",
"gas": "0x8fc",
"input": "0x",
"to": "0x70faa28a6b8d6829a4b1e649d26ec9a2a39ba413",
"value": "0x7a16c911b4d00000"
},
"blockHash": "0x7eb25504e4c202cf3d62fd585d3e238f592c780cca82dacb2ed3cb5b38883add",
"blockNumber": 3068185,
"result": {
"gasUsed": "0x0",
"output": "0x"
},
"subtraces": 0,
"traceAddress": [
1
],
"transactionHash": "0x17104ac9d3312d8c136b7f44d4b8b47852618065ebfa534bd2d3b5ef218ca1f3",
"transactionPosition": 2,
"type": "call"
}
],
"id": 0
}
This repo detects three kinds of swaps. A value expressed as a hexadecimal number, such as 0x022c0d9f
, is called a Function Selector. Each function has a unique Function Selector value, which can be found in the Ethereum Signature Database. The Function Selector is located at the very beginning of call[“input”]
. If detected, it is assumed that a swap event will occur in that transaction.
The call[“to”]
represents the contract address where the function is executed, and since swaps take place in a pool, it means the pool address. The extracted DEX type and pool address are combined and recorded in swap_events
.
from_ = call["from"]
to = call["to"]
input = call["input"]
if "input" not in call:
return []
if input.startswith("0x022c0d9f"):
swap_events = set_swap_event("UNISWAP_V2", swap_events, to)
if input.startswith("0x6d9a640a"):
swap_events = set_swap_event("BAKERYSWAP", swap_events, to)
if input.startswith("0x128acb08"):
swap_events = set_swap_event("UNISWAP_V3", swap_events, to)
The only remaining necessary information is the token address and token amount. The complicated part is that the token transfer function is not called inside the swap function.
Uniswap V2 Swap cases
In case 1
*In case 2
Uniswap V3 Swap cases
Depending on the order of the swap, extracting the required values can be difficult. Therefore, we detect the transfer
and transferFrom
function calls and extract the sender, recipient, and value from call[“input”]
. Simply extracting the value is not enough; we need to validate that the token transfer is associated with a pool swap. To do this, we utilize a union with the swap_events
variable.
# transfer
is_in_transfer = False
if input.startswith("0xa9059cbb"):
recipient = "0x" + input[2 + 32: 2 + 32 + 40]
value = hex_to_uint256(input[2 + 32 + 40: 2 + 32 + 40 + 64])
if value > 0:
swap_events = set_transfer_event(swap_events, from_, to, recipient, value)
is_in_transfer = True
# transferFrom
if input.startswith("0x23b872dd"):
sender = "0x" + input[2 + 32: 2 + 32 + 40]
recipient = "0x" + input[2 + 32 + 40 + 64 - 40: 2 + 32 + 40 + 64]
value = hex_to_uint256(input[2 + 32 + 40 + 64: 2 + 32 + 40 + 64 + 64])
if value > 0:
swap_events = set_transfer_event(swap_events, sender, to, recipient, value)
is_in_transfer = True
The result of extracting the required data from the swap function
and token transfer function
and combining them is accumulated in the form swap_events: List[SwapEvent]
.
class SwapEvent:
def __init__(self, dex=None, address=None, token_in=None, token_out=None, amount_in=0, amount_out=0):
self.dex = dex
self.address = address
self.token_in = token_in
self.token_out = token_out
self.amount_in = amount_in
self.amount_out = amount_out
The following cases exist:
dex
, address
, token_in
, token_out
, amount_in
, and amount_out
are all present in the SwapEvent
, this indicates a normal swap.token_out
, amount_in
, and amount_out
exist in a SwapEvent
, it is removed because it is not a token transfer for a swap.SwapEvent
only has dex
, address
, there may be a problem with the data or code.Trace the transaction as above to catch the swap event.
The fundamental reason attackers can seize profit opportunities lies within DEX AMMs. This is because the same trade can yield different profits depending on the AMM's calculation.
In this part, we will analyze the AMMs on the DEX and implement an efficient formula to maximize profit.
While Uniswap V2 and V3 have similar overall structures, their main difference is their Automated Market Makers (AMMs).
Uniswap has three main contract addresses:
Router Address
Factory Address
Pair (Pool) Address
The official formula for calculating the number of tokens obtained during a swap on Uniswap V2 is as follows.
Variable definitions
Formula
Source code (Solidity)
function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT');
require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
uint amountInWithFee = amountIn.mul(997);
uint numerator = amountInWithFee.mul(reserveOut);
uint denominator = reserveIn.mul(1000).add(amountInWithFee);
amountOut = numerator / denominator;
}
Many MEV open-source projects use the formula above, but the swap formula can be optimized to even greater extremes.
The main goal is to optimize the formula so that there are no errors during the swap transaction and the maximum number of tokens are received. To do this, we need to analyze how we can validate that the proper tokens are coming in and going out when performing a swap transaction on a DEX contract.
Source code (Solidity)
When calling the function, specify the quantity of tokens to receive as argument
.
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
Sends the requested amount of tokens (amount0Out
or amount1Out
) to the user.
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
You can send the token to pay directly to the pair address
before requesting the swap
function, or you can call the uniswapV2Call
callback function to send it to the pair address
as shown below.
if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
The following code validates that the appropriate amount of tokens were sent to the user for the tokens received from the user in pair contract
. If the amount of tokens returned is not appropriate, the transaction will fail.
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);
}
Variable definitions
The values of $n$ and $s$ are different for different exchanges (e.g. Uniswap, SushiSwap, etc.). More information can be found in the src/apis/contract.py:L431
.
Simplifying
Formula
Formula implement (Python)
def _get_optimal_amount_out(amount_in, n, s, reserve_in, reserve_out):
try:
return reserve_out - ((reserve_in * reserve_out * n) // (n *(reserve_in + amount_in) - s * amount_in))
except ZeroDivisionError:
return 0
Validate optimized formulas.
When using the official formula
When using the formula optimized to the extreme
Suppose you have two pools with the same pair of tokens. The process of putting $x$ into the first pool, and then putting the tokens obtained into the second pool to get $y$, which is the same token as $x$, is as follows:
$x \rightarrow R_{1, in} \rightarrow R_{1, out} \rightarrow R_{2, in} \rightarrow R_{2, out} \rightarrow y$
In this case, find a formula to calculate the value of $x$ for maximum profit.
Variable definitions
Simplifying
Validate the formula
What if there are $n$ pools instead of two pools? Generalize the arbitrage formula above to a formula for maximizing profit from $n$ pools.
3-hop
4-hop
Generalize the formula
Formula implement
def get_multi_hop_optimal_amount_in(data: List[Tuple[int, int, int, int]]):
"""
data: List[Tuple[int, int, int, int]]
Tuple of (N, S, reserve_in, reserve_out)
"""
h = len(data)
n = 0
s = 0
prod_reserve_in_from_second = 1
prod_reserve_in_all = 1
prod_reserve_out_all = 1
for idx, (N, S, reserve_in, reserve_out) in enumerate(data):
if S > s:
n = N
s = S
if idx > 0:
prod_reserve_in_from_second *= reserve_in
prod_reserve_in_all *= reserve_in
prod_reserve_out_all *= reserve_out
sum_k_value = 0
for j in range(1, h):
prod_reserve_out_without_latest = prod([r[3] for r in data[:-1]])
prod_reserve_in_ = 1
for i in range(0, h-j - 1):
prod_reserve_in_ *= data[i + j + 1][2]
sum_k_value += (n - s) ** (j + 1) * n ** (h - j - 1) * prod_reserve_out_without_latest * prod_reserve_in_
k = (n - s) * n ** (h - 1) * prod_reserve_in_from_second + sum_k_value
a = k ** 2
b = 2 * n ** h * prod_reserve_in_all * k
c = (n ** h * prod_reserve_in_all ) ** 2 - (n - s) ** h * n ** h * prod_reserve_in_all * prod_reserve_out_all
numerator = -b + math.sqrt(b ** 2 - 4 * a * c)
denominator = 2 * a
return math.floor(numerator / denominator)
EigenPhi is a web service that allows you to track and analyze MEV attacks. The main page shows the profit per address. For example, it says that one address made a profit of $30,803,554.37 in one week. Is this true?
If you click on that address, you'll see a history of MEV attacks. Let's analyze one of these attacks.
Analyze the token flow below. 0xadd318f803ff19bd5fc60a719bd9857610100066cb0e96108f2c1b0cbf74f7a5
Token Flow
In the above transaction, the position in the block is number 3. Position 2 is most likely the victim's transaction. Let's find transaction 1. You can find transaction 1 at this link.
Do you see the same number we saw in transaction 3? That's adjusting the depth of liquidity. The attack labeled as arbitrage was actually a sandwich attack. Let's actually calculate the profit.
$1424.92365 \text{ WBNB} - 1421.26829 \text{ WBNB} + (5.28327 \text{ WBNB} - 5.26888 \text{ WBNB}) - 2.96989 \text{ BNB} = 0.69986 \text{ WBNB}$
The attacker's actual profit was $419.916 ($0.69986 \times 600$), not the $821,381.16 shown in EigenPhi.
The attack recorded in transaction is a sandwich attack. The profit, after subtracting the cost from the revenue of this transaction, amounts to $22.455143.
Looking at all transactions in the block, there is a suspicious transaction at position 0, as shown below. This transaction is suspicious because the sending address and receiving address are the same, and the sending address matches the sending address of the transaction that performed the sandwich attack.
Let's take a closer look at the suspicious transaction: It was a transfer to yourself and didn't contain any data. It also paid 1,715.244527903 Gwei for gas.
The Sandwich attacker reduced the profit from $22.455143 to $2.155413 with the 0th transaction.
The cost of the gas submitted is paid to the Validator. The Sandwich attacker won the bundle for the victim's transactions by outbidding the other attackers. Transaction 0 was used to pay for the gas to secure the winning bid in the 48 Club bidding process.
This may raise a question: why did we separate the transaction that pays the fee? We could have included the fee in transaction 1, but the transaction cost is calculated as $\frac{Gas Usage \times Gas Price}{10^9} \text{BNB}$. While $GasPrice$ can be used to control the fee, it is difficult to accurately calculate $Gas Usage$ quickly enough. This is why we fixed $GasUsage=21,000$ in transaction 0 and controlled the fee with $GasPrice$.
The fees on the Ethereum network can be excessively high, which prompted my choice of the BSC network, where gas is fixed at 1 Gwei.
To further optimize transaction costs, you can use the 48 Club's Soul Point feature to perform transactions at 0 Gwei.
I can no longer operate due to insufficient funds. I determined that $100K to $1M worth of tokens were necessary to make a net profit from sandwich attacks.