本文翻译自Robert Miller的Anatomy of an MEV Strategy: Synthetix
几个月前臭名昭著的alpha泄露者KALEB在Flashbots公开的searchers频道发表了下列消息
KALBE泄露了关于Synthetix变动的数千万美金的alpha消息。在这个机器人运营商的小房间里分享alpha就像丢给狮子一块红肉一样,在快速看了合约之后可以确认有笔另人晕眩的钱处在危机中。
在接下来的几周,我计划并且尝试去执行策略来捕获KALEB分享的MEV。我会开源我用的代码并一步一步展示整个过程和策略。你将不能运行我的代码去赚钱,但是这篇文章将会教你我是如何设计这个新的搜索者并会包含许多alpha。很自然的,这将会有点技术性,但我会尽量让本文对于非技术读者来说好理解。
我不是一个Synthetix专家,因此第一步是去学习我将要涉及的操作。具体如下:
总结一下这阶段的工作,Synthetix已经试验了以ETH为抵押去铸造sUSD和sETH。你可以在合约中存入ETH并铸造那些资产,只要你留意你的抵押资产不要下跌到低于你铸造的资产的一定水平。
然后,在一年后,协议投票通过结束了此试验。当还有数百万差不多未偿还时他们要怎么处理呢?很好,你将可以清算任何头寸。在一段很长的警告期后,贷款将在一个区块内从安全变为可让任何人清算,不管任何金额的抵押物。这将在公共的mempool里被由pDAO地址发出的一笔交易而触发。
抵押ETH铸造出sUSD或者sETH,就称为一笔贷款(loan)
为了清算贷款,我需要偿还我已借的未还金额(sUSD或者sETH)。做为回报,我将收到我关闭的贷款的抵押物ETH。做为清算激励,我将得到比我偿还的sETH或者sUSD更高价值的抵押物。当前还有数百分美金仍然处于贷款状态,这意味着有一大笔钱可以让清算者来赚。因此,我将会尾追pDAO的交易,尽最大可能性让我从这个机会中获利。
现在,我知道了基础的机制并且找到了一些相关的函数。我继续深入研究哪些函数是我要调用到的,哪些数据是我需要的,以及如何生成这些数据。
以下是两个要在生产环境调用的函数:
注意到还有其它的函数,但我很快就识别出他们是不相关的。现在我需要找出哪些贷款可以被清算以及我需要多少sETH/sUSD。下列函数是我可以开始调用的:
然而,还有两个隐藏的难点。一是合约不会告诉你哪些地址有未还贷款,这有点困惑我。在下载了合约相关的所有交易并用excel创建了唯一用户地址列表后我找到了方法。我应用了以下方法:
第二个难点是并不能马上清楚的知道在清算某个贷款后我可以获得多少抵押物。因为有一个未还贷款的函数可以粗略的估算,但我需要更精确一些。因此,我研究了清算的代码并了解了相关数字是如何生成的。
以上是我如何获取链上数据并计算的过程。但是这么做很消耗gas。考虑到我将要与其它机器人在合约gas交易上竞争,移除逻辑到链下去最小化gas消耗对我来说很重要。
经过几次迭代之后,我了解了需要从链上获取的最小数量的数据,是一些关于贷款的变量,我可以在链下加工然后传入我的合约。然而,这些获取信息的函数太复杂,查询所有的贷款信息会耗费超过一个区块的时间。这是站不住脚的。为解决这些问题,我写了一个简短的合约去批量获取数据,这提高了超过10倍的效率。以下是其中一个函数:
function batchGetLoanInformation(address[] calldata _addresses, uint256[] calldata _loanIDs, address _contractAddress) external view returns (
uint256[] memory,
uint256[] memory){
uint256[] memory totalRepayment = new uint256[](_addresses.length);
uint256[] memory totalCollateralLiquidated = new uint256[](_addresses.length);
for (uint i = 0; i < _addresses.length; i++){
uint loanAmount;
uint accruedInterest;
(,,
loanAmount,,,,
accruedInterest,
) = collateralContract(_contractAddress).getLoan(_addresses[i], _loanIDs[i]);
totalRepayment[i] = loanAmount + accruedInterest;
totalCollateralLiquidated[i] = getCollateralAmountSUSD(sUSD, totalRepayment[i], COLLATERAL);
}
return (totalRepayment, totalCollateralLiquidated);
}
你可以看到这个函数应用在我的监听脚本里。
现在我有一个快速的方式告诉你我需要多少sETH/sUSD,我可以获得多少抵押物,有多少利润。
总结:这阶段的工作是深入理解机会并用高效的方式收集需要的数据。你需要尽量在链下操作去减小gas消耗。成果是两个快速的脚本去获取我需要的数据。
你可能需要一个专门的合约来提取MEV。我写了一个合约并在测试环境中测试以更好的理解合约并确保的我数据是正确的。这跟第二步和第四步是同步进行的。
我知道我需要有数百万美金的sUSD/uETH,因此应用闪电贷是必须的。而且,我将要燃烧掉这些synthetix资产再获得ETH。经过一番思考,我意识到无论如何我都需要把ETH换成其它资产,但我要选择在清算前还是清算后去做这个动作。以下列出两种潜在路径:
借出sUSD只有Aave支持,而ETH可以在很多闪电贷提供商那里借出,最终就是选择哪家闪电贷提供商的问题。最终,我选择了方式1,因为dYdX没有手续费,而Aave有手续费。
dYdX会加2wei的费用,相当于免费;Aave闪电贷需要0.09%的手续费
你可以在这里找到完整的合约,下面是从dYdX借到WETH后处理清算sUSD贷款的一段代码:
// This is the function called by dydx after giving us the loan
function callFunction(address sender, Account.Info memory accountInfo, bytes memory data) external {
// Use chi tokens
uint256 gasStart = gasleft();
// Let the executor or the dYdX contract call this function
// probably fine to restrict to dYdX
require(msg.sender == executor || msg.sender == address(soloMargin));
// Decode the passed variables from the data object
(
address[] memory sUSDAddresses,
uint256[] memory sUSDLoanIDs,
uint256 wethEstimate,
uint256 usdcEstimate,
uint256 ethToCoinbase
)
= abi.decode(data,
(
address[],
uint256[],
uint256,
uint256,
uint256
));
// Swap WETH for USDC on uniswap v3
uniswapRouter.exactOutputSingle(
ISwapRouter.ExactOutputSingleParams(
address(WETH), // address tokenIn;
usdcTokenAddress, // address tokenOut;
3000, // uint24 fee;
address(this), // address recipient;
10**18, // uint256 deadline;
usdcEstimate, // uint256 amountOut;
wethEstimate, // uint256 amountInMaximum;
0 // uint160 sqrtPriceLimitX96;
)
);
// Swap USDC for sUSD on Curve
curvePoolSUSD.exchange_underlying(
1, // usdc
3, // sUSD
usdcEstimate, // usdc input
1); // min sUSD, generally not advisible to make a trade with a min amount out of 1, but its fine here I think because the overall risk of getting rekt is low
// Liquidate the loans
for (uint256 i = 0; i < sUSDAddresses.length; i++) {
sUSDLoansAddress.liquidateUnclosedLoan(sUSDAddresses[i], sUSDLoanIDs[i]);
}
// We got back ETH but must pay dYdX in WETH, so deposit our whole balance sans what is paid to miners
WETH.deposit{value: address(this).balance - ethToCoinbase}();
// Pay the miner
block.coinbase.transfer(ethToCoinbase);
// Use for chi tokens
uint256 gasSpent = 21000 + gasStart - gasleft() + (16 * msg.data.length);
CHI.freeFromUpTo(owner, (gasSpent + 14154) / 41947);
}
我花了很多时间去最小化gas消耗。下面列出我的一些设计准则:
有些战术性的东西需要注意:
在合约构造函数里前置授权所有。这种方式,将在部署合约的时候支付gas费用,并降低在执行过程中的gas费。
在合约里燃烧gas token,而不是在我的地址里燃烧。
作者用了chi gastoken做gas的回补,可以减小gas的消耗,又因为gas fee是一次性通过coinbase.transfer支付的,因此,gas总量越低,gas price就越高,对于miner来说越有吸引力。
函数命名让selectors以0s前导,这也可以降低一点gas消耗
函数flashloan_4247(uint _loanAmount, bytes memory _params, uint8 _trigger)的selector是0x0000eaff。
直接使用require比用modifier更省gas
还有一些可以被优化的点,比例:用gas fee代替coinbase.transfer
0xSisyphus慷慨的要提供ETH贷款给我让我代替闪电贷,可以节省一笔gas支出。然后在一些大笔贷款被偿还了之后,总体机会在减小。我决定不从Sisyphus那里借款,因为机会不再足够大到值得去这么做。
总结:在这个阶段,我创建了智能合约去执行可用的MEV机会。要做到最佳需要努力思考正确的关于如何最小化gas消耗的策略。这个合约是迭代开发的,与数据工作同步进行,并且在测试环境中测试。
有了精心编写的合约和对机会的深入理解,我需要去优化如何执行的策略。回顾了Flashbots MEV-Geth客户端高效执行拍卖时根据高gas price bundle赢并上链的方式。最重要的因素是我需要最大化bundle的gas price,而不是总的支付了多少ETH。
考虑到这一点,我将之前收集到的数据整理到表格里去优化我的gas price。我的合约有固定的gas支出和可变的gas支出。固定的gas支出是闪电贷和交换。可变的gas支出是我想要清算多少个贷款。很直观的可以想到将有一些点到达清算的边际收益而不再值得去消耗gas。我运行了几次测试去获取实际的数字。下面是我的结果:
结果有些惊喜,只清算30个sUSD贷款中的前4个是gas最高效的。在这之后的每个贷款都将增加总收益,但会降低bundle的gas price,让其减少竞争力。如果其它人试图一次性清算前10个sUSD贷款,那么他们将降低至少30%的gas效用!
考虑到未偿还的sETH贷款比较少,在一个交易里只清算sUSD而不是将sUSD和sETH合并清算会更有意义。因此潜在的奖励会比较少,也就支付更少的费用给矿工,意味着缺少gas竞争力。想到这我不禁笑起来。如果有人贪婪的一次性清算所有贷款,或者懒惰的一个一个头寸单独清算,那么我将胜出。
然而,其它贷款还在那里且有利润可供清算!再次,我试图去优化gas price,发现如果我清算前4个sUSD贷款再接着清算6个最大的sUSD贷款再接着2个sETH贷款,这样是gas最高效的。进而,假设我赢了,我还可以用前面bundle收益的ETH来代替闪电贷。
重复一下这个情况:我要竞争gas效用,但是我还想要最大化每个贷款的清算利润。最佳的策略是在分开的几个bundle里每个bundle提交几个清算。他们将让Flashbots拍卖单独评估。然后,每个bundle是依赖于pDAO打开清算开关交易的。
如果pDAO交易没有在bundle里,那bundle将会失败。但是如果我包括的pDAO交易在每个bundle里,就只会有一个bundle成功。在一个bundle打包后其它bundle将不再有效,因此它们尝试去二次打包pDAO交易。因此,我需要一些方式只在第一个bundle里发送pDAO交易,但是确保其它bundle不会因为没有包括pDAO交易而失败和被抛弃。
解决方案是Flashbots拍卖的一个细微差别。在搜索者开始”在bundle合并后降低矿工费用来戏弄拍卖”,Flashbots设置了两轮的模拟。
首先,所有bundle单独模拟以获取他们的gas price并检查失败。再来,成功的bundle按gas price从大到小排序,再次模拟去找到冲突的bundle并确保没有bundle的gas price是低于期望的。除非你试图去做些工作,否则你的bundle的gas price将不会在合并后降低。
我意识到我可以做一些与上述搜索者相反的工作,来让我的bundle不会支付低于预期的矿工费,而是在第二轮的模拟中超付费。为了实现这个方案,我将要在第一个bundle里提交带pDAO交易,但是要额外检测剩余的bundle。这些bundle将会推测出他们在第几轮,并改变相关的执行。如果他们在第一轮他们将不会清算,因为如果他们想要清算将会失败,并不顾一切支付给矿工高额的gas price以通过第一轮的模拟。
我是如何检测我的bundle处于第几轮的呢?用检查合约余额的方式。如果在前面的bundle成功清算了,我的余额会因为利润而增加。因此,我增加了一个是否获得WETH利润的条件检查,再处理清算。这在测试中通过了。
也就是说假设,一次提交三个bundle,第一个bundle带有pDAO交易,第二三个没有pDAO交易。在Flashbots在第一轮模拟时,正常情况下第二三个bundle会因为没有带pDAO而执行失败,然后会被抛弃,从而不会被打包入块。作者在合约里做了一个条件判断,用合约的WETH余额来判断是第几轮,因为第一轮是单独模拟,所以WETH余额肯定是0,还有另外一个trigger参数是用来区分是不是第一个bundle的,这样能判断出是第二三个bundle且是第一轮模拟,然后就强制用coinbase.transfer转gas给矿工,让交易不会失败且有gas费,让矿工愿意打包这个bundle。
总结:这个阶段还是关于策略。我用之前获取的数据、合约和测试环境来思考我要争夺的 MEV 机会的经济学逻辑,以及最优策略会是什么。用真实数据,我发现了一个令人惊讶的显性因子,但它很难执行。执行它需要一种新方式来提交bundle。
有了数据,合约,计划,我就可以开始执行了。本质上,我需要编写能够执行我上述计划的bundle并且监控mempool里相关的Synthetix交易去尾追。这些大部分都是实现上的问题。
首先,我用Blocknative去监控pDAO帐户的相关交易。我把pDAO相关的所有交易都流向我的机器人。
同此同时,我运行了两个监控脚本(sETH&sUSD)去获取链上的数据,获取最佳的bundle策略(例如:闪电贷x个ETH去清算前3个sETH贷款,再以同样的做法清算下面两个,以此类推)并生成我的合约需要的数据。我需要每个区块都运行,以防价格发生变化或者某人关闭了贷款,来改变我的最佳策略。结果保存在本地。
最后,我有一个执行脚本将会收到pending交易并流向我的机器人,加载最佳策略,编写bundle并发送到Flashbots。 剩下的就是等待了。在这段时间内,最大的sETH贷款被借款人偿还,因此我关闭了部分机器人。一些最大的sUSD贷款也被关闭了,这显著的降低了潜在的钱款。
有趣的是有人试图发送相关合约的交易去诱饵机器人让它在早期失灵。我不确定这是否对别的机器人生效,但我的是没有上当。
几个小时过后,真正的交易从pDAO发出了。经过几周的研究和准备,关键时刻来临了。一切都进展顺利在我这边,我的监控脚本运行完美,交易被成功捕获到,bundle成功创建并提交。
...然后,灾难发生了。连续几个区块都没有Flashbots块被挖出。我不止因此失去了机会,而且也没有Flashbots搜索者成功。没有Flashbots bundle在区块头部去阻止企业级的mempool机器人进入并狙击到所有的有利润贷款。
尽管我输了,但我想我的方法是正确的。我的优势在于策略和找寻新机会,而不是参与PGAs(Priority Gas Auction最优gas费竞拍, 简称PGA)。因此使用Flashbots给了我获胜的最好机会,鉴于Flashbots的广泛使用,连续几个区块没有Flashbots块产出是有点不可因议的不走运了。
MEV有时候被看做是隐藏的超级科学家领域,但这并不一定。它很好玩,有趣并可模拟。然后游戏规则-可以说-如果你想寻找就是开放的。这篇文章是关于我学习游戏规则,开发这些规则对应策略并最终执行策略的过程。尽管我输了,但我学到了很多,并且享受这个过程。我希望我下次再来做,你也可以在下回加入我。
gg,mempool机器人,你赢了这次。但下回我会赢。