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

一键发币: SUI | SOL | BNB | ETH | BASE | ARB | OP | POLYGON | AVAX | FTM | OK
当你开始构建你的MEV机器人时,你很快会意识到,虽然执行你的逻辑至关重要,但这个过程中一个经常被低估的方面是搜索MEV机会。今天,我想讨论一下我们如何有效地寻找这些机会,这些机会可以出现在两个主要区域:
- 内存池数据: 待处理交易
- 交易/事件数据: 已确认交易
当提到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路路径是否会有盈利。
这可以通过两种方式之一完成:
- 通过编写自己的模拟器,该模拟器实现了所有价格影响函数。(为什么价格影响如此关键,请参阅这里 )
- 通过使用智能合约来模拟价格影响。
今天,我将采用第二种方法,因为第一种方法非常耗时。采用第一种方法时,你需要彻底阅读文档和合约,以理解你的交换/交易如何影响不同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.sol
到 src/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.py、web3.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翻译整理,转载请标明出处
免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。