MEV机器人交易模拟引擎

为了准确地判断你的交易是否会成功完成,并模拟预期能赚取的利润,每个MEV机器人开发者都需要构建自己的模拟引擎。本文介绍如何使用Solidity/Foundry模拟多个DEX交换路径。

MEV机器人交易模拟引擎
一键发币: SUI | SOL | BNB | ETH | BASE | ARB | OP | POLYGON | AVAX | FTM | OK

当你开始构建你的MEV机器人时,你很快会意识到,虽然执行你的逻辑至关重要,但这个过程中一个经常被低估的方面是搜索MEV机会。今天,我想讨论一下我们如何有效地寻找这些机会,这些机会可以出现在两个主要区域:

  1. 内存池数据: 待处理交易
  2. 交易/事件数据: 已确认交易

当提到MEV时,人们通常会想到对内存池数据的分析,特别是待处理的交易。你可能已经听说过利用待处理交易进行获利的前跑、后跑和三明治机器人等概念。这些机器人会在内存池中搜寻尚未包含在区块中的交易,并尝试识别可能对自己有利的潜在交易。

这些机器人的背后想法是对内存池中的待处理交易进行分析,并模拟各种场景以确定是否可以通过这些交易获利。通过仔细检查这些交易的特征,如其内容、Gas费用和目标,这些机器人可以评估潜在的结果并做出明智的决策。

然而,今天我想谈谈一种更简单的搜索/模拟方法,每个链上交易者都应该理解和在策略开发过程中使用这种方法。这种方法使用已经在区块链上确认的数据。 虽然状态转换已经发生并在区块链上最终确定,但在不同的去中心化交易所(DEX)协议之间可能存在差异,我想跨这些协议模拟多个交换路径,并查看我的路径是否有盈利的可能性。

2、为什么我们需要一个模拟引擎?

你可能会好奇,为什么我们需要一个模拟引擎呢?如果我们谈论的是涉及与单一DEX协议交互的极其简单的策略,那么我们可能不需要它。但我们正在讨论多种区块链生态系统上的多个DEX。因此,为了准确地判断你的交易是否会成功完成,并模拟你预期能赚取的利润,每个MEV机器人开发者都需要构建自己的模拟引擎。

事实上,浏览与MEV机器人相关的Github仓库会给你提供执行交易的整个代码库,比如使用私有中继器如Flashbots的机器人,但它们不会给你提供搜索/模拟引擎。

这个链接libevm用JS编写的完整三明治机器人实现。另一个是由Flashbots提供的。

当我以后讨论MEV机器人的执行部分时,我会有机会审查这些代码。

我们现在明白,构建自己的搜索机器人和模拟引擎是很重要的。但我们要从哪里开始呢?

搜索和模拟,这两者都很重要,但它们需要不同的知识体系,所以今天我将只专注于模拟引擎部分。

3、立即开始构建

最好的学习方式就是实践,尤其是在区块链领域——特别是在交易领域!

今天我们构建什么?

我想在多个DEX上进行多跳交换(典型的n路套利,如三角套利),使用单个链。例如,我的交换可以在Curve Finance、Uniswap V2、Uniswap V3以及你想要包括的任何其他DEX上进行。

我们在模拟什么?

我想弄清楚,如果我发送一笔执行这些交换的交易,我找到的n路路径是否会有盈利。

这可以通过两种方式之一完成:

  1. 通过编写自己的模拟器,该模拟器实现了所有价格影响函数。(为什么价格影响如此关键,请参阅这里
  2. 通过使用智能合约来模拟价格影响。

今天,我将采用第二种方法,因为第一种方法非常耗时。采用第一种方法时,你需要彻底阅读文档和合约,以理解你的交换/交易如何影响不同DEX协议上的配对价格。难点在于这些DEX使用的AMM公式各不相同。

但采用第二种方法时,你不需要理解那些公式。你只需要稍微浏览一下,找出DEX用来模拟交换的函数。这些信息通常是公开的,熟悉Solidity后很容易找到。此外,如果你不改变区块链状态(除了合约创建费),调用智能合约函数是免费的。

4、项目设置

我将使用Foundry编写我的智能合约来模拟潜在的盈利交换路径。

Foundry使用Rust,所以你需要安装Rust/Cargo。运行以下命令来安装Foundryup:

curl -L https://foundry.paradigm.xyz | bash

现在运行:

foundryup

这将安装你开始使用Foundry所需的所有命令。

现在你已经安装了依赖项,你可以初始化你的Foundry项目:

forge init swap-simulator-v1  
cd swap-simulator-v1 && forge build  
forge install OpenZeppelin/openzeppelin-contracts

src目录下,创建一个新的名为 SimulatorV1.sol的Solidity文件。

// SPDX-License-Identifier: MIT  
pragma solidity ^0.8.9;  
  
import "openzeppelin-contracts/contracts/utils/math/SafeMath.sol";  
  
contract SimulatorV1 {  
    using SafeMath for uint256;  
  
    // Polygon网络地址  
    address public UNISWAP_V2_FACTORY = 0x5757371414417b8C6CAad45bAeF941aBc7d3Ab32;  
    address public UNISWAP_V3_QUOTER2 = 0x61fFE014bA17989E743c5F6cB21bF9697530B21e;  
  
    struct SwapParams {  
        uint8 protocol;   // 0 (UniswapV2), 1 (UniswapV3), 2 (Curve Finance)  
        address pool;     // 用于Curve Finance  
        address tokenIn;  
        address tokenOut;  
        uint24 fee;       // 仅用于Uniswap V3  
        uint256 amount;   // 金额输入 (1 USDC = 1,000,000 / 1 MATIC = 1 * 10 ** 18)  
    }  
  
    constructor() {}  
  
    function simulateSwapIn(SwapParams[] memory paramsArray) public returns (uint256) {  
  
    }  
  
    function simulateUniswapV2SwapIn(SwapParams memory params) public returns (uint256 amountOut) {  
  
    }  
  
    function simulateUniswapV3SwapIn(SwapParams memory params) public returns (uint256 amountOut) {  
  
    }  
  
    function simulateCurveSwapIn(SwapParams memory params) public returns (uint256 amountOut) {  
  
    }  
}

这是我们的模拟器的基本结构。我们将使用Polygon,因为那里的Gas费用非常便宜,是一个测试代码的好地方。

代码相当自解释,我们可以看到我们将通过发送一个SwapParams数组来调用“simulateSwapIn”函数。

我们现在将构建这个函数:

function simulateSwapIn(SwapParams[] memory paramsArray) public returns (uint256) {  
    uint256 amountOut = 0;  
    uint256 paramsArrayLength = paramsArray.length;  
  
    for (uint256 i; i < paramsArrayLength;) {  
        SwapParams memory params = paramsArray[i];  
  
        if (amountOut == 0) {  
            amountOut = params.amount;  
        } else {  
            params.amount = amountOut;  
        }  
  
        if (params.protocol == 0) {  
            amountOut = simulateUniswapV2SwapIn(params);  
        } else if (params.protocol == 1) {  
            amountOut = simulateUniswapV3SwapIn(params);  
        } else if (params.protocol == 2) {  
            amountOut = simulateCurveSwapIn(params);  
        }  
  
        unchecked {  
            i++;  
        }  
    }  
  
    return amountOut;  
}

将此函数定义插入到上面的空“simulateSwapIn”块中。暂时不要担心它做了什么。我们很快就会深入探讨。

不过,在我们看这个函数之前,我们需要了解DEX如何让你使用诸如:

  • getAmountOut (UniswapV2)
  • quoteExactInputSingle (UniswapV3)
  • get_dy (Curve Finance)

这样的函数来模拟你的交易。

5、首先,UniswapV2

UniswapV2是最简单的。而且由于许多DEX都是UniswapV2的分支,这种方法也适用于其他DEX。

如果你访问这里 ,会看到一个类似这样的函数:

// 给定输入资产数量和池子的储备量,返回另一种资产的最大输出量  
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;  
}

使用这个函数,你可以模拟如果你向这个UniswapV2池子中输入“amountIn”,你将会得到多少代币。

为了使用它,创建一个名为src的新目录,然后在src下创建protocols目录,并在protocols下创建uniswap目录。它将看起来像这样: src/protocols/uniswap

我已经在我的src目录中添加了我需要的所有Solidity文件。你可以这样做。现在复制并粘贴 UniswapV2Library.solsrc/protocols/uniswap/UniswapV2Library.sol。不过有一个小问题。UniswapV2使用的Solidity编译器版本与更现代的版本不匹配。所以跳转到我的Github并复制、粘贴代码。那样应该就能工作了。

这是仓库链接

在你完成在protocols目录中创建接口/库文件后,你现在就可以理解如何在UniswapV2中模拟交换了。

让我们回到SimulatorV1代码:

// SPDX-License-Identifier: MIT  
pragma solidity ^0.8.9;  
  
import "openzeppelin-contracts/contracts/utils/math/SafeMath.sol";  
  
// 所有导入  
import "./protocols/uniswap/UniswapV2Library.sol";  
import "./protocols/uniswap/IQuoterV2.sol";  
import "./protocols/curve/ICurvePool.sol";  
  
contract SimulatorV1 {  
    using SafeMath for uint256;  
  
    // Polygon网络地址  
    address public UNISWAP_V2_FACTORY = 0x5757371414417b8C6CAad45bAeF941aBc7d3Ab32;  
    address public UNISWAP_V3_QUOTER2 = 0x61fFE014bA17989E743c5F6cB21bF9697530B21e;  
  
    struct SwapParams {  
        uint8 protocol;  
        address pool;  
        address tokenIn;  
        address tokenOut;  
        uint24 fee;  
        uint256 amount;  
    }  

    constructor() {}  
  
    // 其他代码在这里  
  
    function simulateUniswapV2SwapIn(SwapParams memory params) public returns (uint256 amountOut) {  
        (uint reserveIn, uint reserveOut) = UniswapV2Library.getReserves(  
            UNISWAP_V2_FACTORY,  
            params.tokenIn,  
            params.tokenOut  
        );  
        amountOut = UniswapV2Library.getAmountOut(  
            params.amount,  
            reserveIn,  
            reserveOut  
        );  
    }  
  
    // 其他代码在这里  
}

使用UniswapV2Library,我们从由tokenIn和tokenOut组成的交易对中获取储备量。这些将是地址,例如:

  • USDC: 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 (polygonscan)
  • USDT: 0xc2132D05D31c914a87C6611C10748AEb04B58e8F (polygonscan)

有了这些储备量,它将调用“getAmountOut”并获取交易“amount”的结果。我们将这个值返回。

6、UniswapV3

UniswapV3稍微复杂一些,但不要害怕。文档可以告诉你很多东西。特别是这里

使用Quoter2,你可以使用 quoteExactInputSingle来模拟来自V3池的单次交换。同样,为了实现这一点,跳转到我的Github并复制、粘贴IQuoterV2.sol到 src/protocols/uniswap/IQuoterV2.sol

现在回到SimulatorV1.sol文件:

// 导入...  
  
contract SimulatorV1 {  
  
    // 其他代码在这里  
  
    function simulateUniswapV3SwapIn(SwapParams memory params) public returns (uint256 amountOut) {  
        IQuoterV2 quoter = IQuoterV2(UNISWAP_V3_QUOTER2);  
        IQuoterV2.QuoteExactInputSingleParams memory quoterParams;  
        quoterParams.tokenIn = params.tokenIn;  
        quoterParams.tokenOut = params.tokenOut;  
        quoterParams.amountIn = params.amount;  
        quoterParams.fee = params.fee;  
        quoterParams.sqrtPriceLimitX96 = 0;  
        (amountOut,,,) = quoter.quoteExactInputSingle(quoterParams);  
    }  
  
    // 其他代码在这里  
}

由于QuoterV2实际上已经被部署到了网络上(可以从这里看到),你需要用IQuoterV2接口包装QuoterV2的地址,并创建 QuoteExactInputSingleParams输入结构体来调用目标函数。

7、Curve Finance

Curve有点棘手,因为它与其他Uniswap分叉的DEX非常不同。但这个项目已经为我们准备好了接口 。

我从这里复制、粘贴了Curve Finance池的接口。完成后,我检查了一下它们是否是最新的。并且我检查了至少我感兴趣的Curve Finance中的3pool接口是否匹配。

设置好这些之后,让我们再次回到我们的SimulatorV1.sol文件:

// 导入...  
  
contract SimulatorV1 {  
  
    // 其他代码在这里  
  
    function simulateCurveSwapIn(SwapParams memory params) public returns (uint256 amountOut) {  
        ICurvePool pool = ICurvePool(params.pool);  
  
        int128 i = 0;  
        int128 j = 0;  
  
        int128 coinIdx = 0;  
  
        while (i == j) {  
            address coin = pool.coins(coinIdx);  
  
            if (coin == params.tokenIn) {  
                i = coinIdx;  
            } else if (coin == params.tokenOut) {  
                j = coinIdx;  
            }  
  
            if (i != j) {  
                break;  
            }  
  
            unchecked {  
                coinIdx++;  
            }  
        }  
  
        amountOut = ICurvePool(params.pool).get_dy(  
            i,  
            j,  
            params.amount  
        );  
    }  
}

这看起来更复杂一些,因为Curve不存储关于token 0、token 1的信息。这是因为Curve池可以包含超过两个代币作为交易对。而3pool中有三种稳定币在池中。其他的也可能更多。

所以在Solidity中运行一个while循环,并尝试匹配池合同中使用的索引号和代币。

当我们确定了tokenIn和tokenOut的代币索引后,我们将调用“get_dy”函数来模拟Curve Finance的稳定交换。我们也会返回这个值。

8、模拟SwapIn函数

现在我们可以理解“simulateSwapIn”函数,我们将再次查看它:

function simulateSwapIn(SwapParams[] memory paramsArray) public returns (uint256) {  
    // 初始化结果值为0  
    uint256 amountOut = 0;  
    uint256 paramsArrayLength = paramsArray.length;  
  
    // 逐一遍历paramsArray中的每个值  
    for (uint256 i; i < paramsArrayLength; ) {  
        SwapParams memory params = paramsArray[i];  
  
        // 如果还没有模拟过任何交换,则将amountOut设置为params结构体中的初始amount值  
        if (amountOut == 0) {  
            amountOut = params.amount;  
        } else {  
            // 如果amountOut不是0,意味着至少已经模拟了一次交换路径,  
            // 使用该输出作为“amount”  
            params.amount = amountOut;  
        }  
  
        if (params.protocol == 0) {  
            amountOut = simulateUniswapV2SwapIn(params);  
        } else if (params.protocol == 1) {  
            amountOut = simulateUniswapV3SwapIn(params);  
        } else if (params.protocol == 2) {  
            amountOut = simulateCurveSwapIn(params);  
        }  
  
        // 不要担心这部分  
        // 它只是将i增加1  
        // 这段代码参考自:https://github.com/Uniswap/universal-router/blob/main/contracts/UniversalRouter.sol  
        unchecked {  
            i++;  
        }  
    }  
  
    return amountOut;  
}

上面的代码现在更有意义了。

如果我们完成了Simulator代码的编写,我们应该将其部署到生产网络进行测试——无论是主网还是测试网。我会立即部署到主网。

使用Foundry,你可以非常容易地部署这个智能合约:

forge create --rpc-url <rpc-url> --private-key <private-key>  
```src/SimulatorV1.sol:SimulatorV1

执行上述命令时,使用你的RPC URL(我使用了Alchemy)和私钥即可直接部署合约(在自动编译Solidity代码后)。有关此内容的更多信息,请参阅这个内容

上述命令的输出如下:

我在那里放了(—legacy)标志,因为我部署到了Polygon主网。

SimulatorV1合约地址为: 0x37384C5D679aeCa03D211833711C277Da470C670

现在我们已经部署了合约,接下来尝试使用JavaScript调用模拟函数。它应该也可以与其他语言一起工作,因为现在你的模拟函数已经在区块链上生效了,任何web3库,例如web3.js、ethers.js、web3.pyweb3.rs、web3.go都应该可以正常工作。

我会使用ethers.js来测试我的模拟函数。

在swap-simulator-v1目录下创建一个Node.js项目:

npm init  
npm install --save-dev ethers@5.7.2 dotenv

这个项目应该可以与所有ethers版本兼容,但我只是坚持使用5.7.2,因为Flashbots不支持此版本以上的版本,并且我想在未来为这个项目使用Flashbots。

接下来,在JS脚本中输入以下内容:

const { ethers } = require("ethers");  
  
require("dotenv").config();  
  
const SimulatorV1ABI = require("./out/SimulatorV1.sol/SimulatorV1.json").abi;  
  
// DO NOT USE REAL PRIVATE KEY  
const provider = new ethers.providers.JsonRpcProvider(process.env.ALCHEMY_URL);  
const signer = new ethers.Wallet(process.env.TEST_PRIVATE_KEY, provider);  
  
const SimulatorV1Address = "0x37384C5D679aeCa03D211833711C277Da470C670";  
  
const contract = new ethers.Contract(  
  SimulatorV1Address,  
  SimulatorV1ABI,  
  signer  
);  
  
(async () => {  
  const swapParam1 = {  
    protocol: 0,  
    pool: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", // 随机地址  
    tokenIn: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",  
    tokenOut: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",  
    fee: 0,  
    amount: ethers.utils.parseUnits("1", 6),  
  };  
  
  const swapParam2 = {  
    protocol: 1,  
    pool: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", // 随机地址  
    tokenIn: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",  
    tokenOut: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",  
    fee: 500,  
    amount: 0, // 不需要  
  };  
  
  const swapParam3 = {  
    protocol: 2,  
    pool: "0x445FE580eF8d70FF569aB36e80c647af338db351", // 真实的Curve.fi池地址  
    tokenIn: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",  
    tokenOut: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",  
    fee: 0,  
    amount: 0, // 不需要  
  };  
  
  const swapParams = [swapParam1, swapParam2, swapParam3];  
  
  const amountOut = await contract.callStatic.simulateSwapIn(swapParams);  
  console.log(amountOut.toString());  
})();

按原样运行它,应该可以在主网上正常工作,因为它已经被部署在那里了。

我正在尝试使用1 USDC模拟以下交换路径:

  • (UniswapV2) USDC → USDT
  • (UniswapV3) USDT → USDC
  • (Curve Finance) USDC → USDT

最终结果将是:996819 (= 0.996819 USDT)。

这是一个相当无用的路径模拟,但它很好地展示了目的。

9、结束语

这篇文章相当长。所以那些只想立即投入代码的人可以直接参考我的GitHub仓库

此外,对于刚开始MEV机器人构建之旅的人来说,你们并不孤单。几周前我开始了这个旅程,有一点CeFi交易背景知识/经验,我觉得与人交谈是解决这里许多问题的最可靠方法。我也缺乏这一领域的资料,但这是完全可以理解的。


原文链接:First key to building MEV bots: The simulation engine

DefiPlot翻译整理,转载请标明出处

免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。
通过 NowPayments 打赏