来源 |bertcmiller.com
作者 | Robert Miller
几个月前,臭名昭著的 KALEB 在 Flashbots 的公共搜索者 discord 上发布了以下信息:
KALEB 之前泄露了关于 Synthetix 变动政策的重大消息,涉及数十万美元。在这个机器人运营商的巢穴里分享这种消息就像把红肉扔给狮子一样,快速看一下合约就会发现有一笔诱人的钱在里面。
在接下来的几周里,我计划并试图执行一个策略,以捕获 KALEB 在上面分享的 MEV。我将在本文公开我使用的代码,并讲解我的过程和策略。你将无法运行我的代码来印钱,但这篇文章将告诉你我如何设计新的搜索器,且包含很多关于这样做重要信息。这自然会比较技术,但我努力使非技术读者也能理解。
第一步:确定机会范围
我不是 Synthetix 的专家,因此第一步是了解我的工作内容。具体来说:
我找出相关的合约
我在 Sythetix 博客上通读它们功能的概述性文章,并搜索了所有的文档
我确信我理解了即将要实现的治理变更
我查看这些函数,并找出那些看起来相关的
这个阶段工作的总结是,Synthetix 已经试验了使用 ETH 作为抵押品来铸造 sUSD 和 sETH。你可以在合约中存入 ETH 并铸造这些资产,只要你注意你的抵押品价值不会跌至低于你贷款的一定水平。
但是,一年后,协议投票决定结束这个试验。当有数以百万计的未偿贷款时,他们怎么能这样做呢?好吧,你可以让任何头寸都变得可偿还。事实上,在一个漫长的警告期后,贷款会从在一个区块里是安全的,变成可被任何人偿还,无论抵押品的价值是多少!这会触发一笔从 “pDAO” 地址发送到公共交易池的交易。
要偿还一笔贷款,我需要归还所借资产 (sUSD 或 sETH) 的未偿金额。作为回报,我会收到支持我所关闭的贷款的抵押 ETH。作为偿还这些贷款的激励,我会获得比我归还 sETH 或 sUSD 更多的抵押品价值。由于当时仍有数百万美元的贷款,这意味着偿还者可以赚更多的钱。此外,我将不得不从 pDAO 合约里尾追 (backrun) 交易,以便我可以尽可能利用这个机会。
第二步:了解机会
现在我了解了基本机制,并且有了一些我认为相关的函数了。然后,我又深入了解我将调用哪些函数,我需要什么数据,以及如何生成该数据。
我需要两个函数:
setLoanLiquidationOpen():仅能由合约所有者 (pDAO) 调用。允许对未关闭的贷款进行偿还。
liquidateUnclosedLoan():需要一个贷款 ID 和账户地址,并偿还该贷款。在 setLoanLiquidationOpen() 被调用后可以被任何人使用。
请注意,还有其他函数的,但我很快发现它们并不相关。现在,我需要解决我要如何选择偿还哪些贷款,以及这样做我需要多少 sETH/sUSD。以下的函数开启工作所需的大部分东西:
openLoanIDsByAccount():返回所有与某个账户相关的所有公开贷款 ID
getLoanInformation():返回一个给定 ID 和所有者的贷款数据
然而,存在两个隐藏的困难。首先,这些合约不会告诉你哪些地址有未偿还的贷款,这给我带来一定难度。几分钟内,我找到一个解决方法,就是在 Etherscan 上下载所有与这些合约想法u你的交易,并用 Excel 创建一个与这些合约交互过的唯一账户列表。从那里,我建立了以下管道:
使用 getLoans 找出该地址是否有未偿贷款,如果有,记录它们的贷款 ID
使用 getLoanInformation 找出支持该贷款的抵押价值,它们发行了多少 sUSD/sETH,以及它们的贷款有多少利息
按贷款金额排序,首先列出最大额度的贷款,并把数据结构保存到 json 文件中。
第二个难题是偿还一笔贷款能拿回多少抵押的 ETH 并不能马上了解。因为有未偿贷款的函数,你可以粗略估算到,但我需要更精确的数据。要实现这点,我研究了偿还贷款的代码,并了解清楚相关数字是如何产生的。
以上的所有数据都是我可以在链上获取或计算的。但这样做太耗费 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);}
请看由 EmGithub 提供的 rawsAssetsOracle.sol
你们可以在这里我的监听脚本看其使用。现在我能很快判断我需要多少 sETH/sUSD,偿还一笔贷款后能拿回多少抵押价值,从而得知这笔贷款的利润。
总结:这个阶段的工作是深入了解机会,以及以高效的方式搜集所有执行所需的数据。你需要尽力把东西移到链下完成,以降低 gas 消耗。这部分工作的成果是做出了两份搜集所需数据的 fast script。
第三步:写一份执行合约
闪电贷策略
你可能需要一份专门的合约来提取 MEV。我在早期写了一份合约和测试环境,可以用这个环境来更好理解合约,并确保数据是正确的。这几乎与第二和第四步是同时进行的。
我知道我需要获得数百万美元的 sUSD/sETH,因此使用闪电贷是必须的。此外,我会烧毁这些合成资产但拿回抵押的ETH。经过一番思考,我意识到无论如何我都要用 ETH 兑换其他资产,但我可以选择在偿还之前还是之后进行。有两条可能的路径:
Option 1 选项 1: 通过闪电贷贷出 ETH -> 兑换出 USDC -> 兑换出 sUSD -> 偿还 sUSD 贷款 -> 收到 ETH -> 偿还 ETH 的闪电贷
Option 2 选项 2: 通过闪电贷贷出 sUSD ->偿还 sUSD 贷款 ->收到 ETH -> 兑换出 USDC -> 兑换出 sUSD -> 偿还 sUSD 的闪电贷
鉴于 sUSD 仅在 Aave 上可用,而 ETH 可以在多个闪电贷供应商上可用,问题最终会变成我想使用哪个闪电贷供应商。最终,我选了选项 1,因为 dYdX 不产生费用,而 Aava 会产生一项费用。
Gas 优化
你可以在此处找到我的完整合约,但这是我从 dYdX 收到 ETH 并进而偿还 sUSD 贷款后的部分:
// This is the function called by dydx after giving us the loanfunction 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);}
请看由 EmGithub 提供的 rawdYdXLiquidator.sol
我花了大量时间尝试最小化我的 gas 消耗。很多我的设计选择都是以此为依据。关于这份合约的策略,有几点需要注意
我没有发送很多单独偿还和兑换的交易,相反,我选择把多笔偿还打包在一个交易里,这使得我的固定 gas 开销可以在多笔偿还里分摊,由此提高我的交易捆的竞争力。
我需要以最佳方式把 ETH 兑换成 USDC 再兑换成 sUSD,并需要决定是在有函数 exactInput 还是 exactOutput 的 Uniswap v3 上交易。无论我怎么做都会在某个地方产生滑点,因此我选择有函数 exactOutput 的,以避免调用 balanceOf。
在这些交易的精确性和 gas 效率之间存在折衷。只要我能偿还我的闪电贷,缺乏精确性也没什么问题,而且因为我要在 gas 效率上竞争,我选择了对它优化。
还有一些“战术上”的东西需要注意:
对所有东西的批准都前向负载到我的合约里的构建函数。这样,我可以在部署的时候支付开销,并减少执行时使用的 gas。
我不从我的账户烧毁 gas token,而是从我的合约烧毁,同样是为了提高 gas 效率。
函数名称都是指定的,它们的函数选择器的前导符为 0x,使用函数选择器也能稍微减少 gas 的使用。
与直接添加 require 语句相比,函数修改器需要消耗稍微多一点 gas。
这份合约还有一些可以被优化的方法,例如使用 gas 费用而不用 coinbase 转账。
0xSisyphus 非常慷慨提出给我借 ETH,我就不用使用闪电贷了,这能大大节省 gas。但随着时间推移,大额的贷款都还钱了,因此总的机会就减少了。我决定不接受 0xSisyphus 借的钱,因为机会不再大到使这样做是明智的。
总结:在这个阶段,我创建了一份智能合约,以执行捕获可得的 MEV 机会。要做到这点,需要认真思考正确的策略,以及如何最小化 gas 的使用。这份合约是经过多次迭代开发出来的,同时我进行数据上的工作,还把它放在了一个测试环境 (Hardhat) 里。
第四步:计划你的执行
偿还 MEV 和优化 gas 价格的经济学
有了一份精心设计的合约和对机会的深入了解,我需要改进我实现这个机会的策略。回顾一下,Flashbot 的 MEV-Geth 客户端能有效运行竞拍,其中 gas 价格最高的交易捆胜出,会被打包到链上。这一重要事实意味着,我需要最大化我的交易捆的 gas 价格,而不是我支付的 ETH 总额。
记住了这点,并使用我之前收集到的数据,我制作了一个电子表格来优化我的 gas 价格。我的合约既有固定的 gas 开销,也有可变的 gas 开销。固定的 gas 开销用于取出闪电贷和做兑换。可变的 gas 开销来自我想要偿还的贷款数。我很自然地认为在某一点上,偿还一笔贷款的边际收益将低于 gas 开销。我运行了几次测试,以得出实际数字。以下是我的结果:
请注意,这个结果有点令人惊讶——仅偿还前 4 笔 (共 30 笔) 的 sUSD 贷款是最省 gas 的。此后的每笔贷款都会产生更多的整体利润,但会降低我的交易捆的 gas 价格,并降低其竞争力。如果有其他人试图一次性偿还前 10 笔 sUSD 贷款,它们的 gas 消耗效率会降低接近 30%!
考虑到未偿的 sETH 贷款更少了,只做 sUSD 贷款而不把 sUSD 和 sETH 合并到一笔交易是最合理的。因此,潜在的回报更少了,支付给矿工的钱也更少了,这使得它们的 gas 效率相对较低。看到这些发现,我不禁笑了。如果其他人贪婪,一下偿还了所有的贷款,或很懒惰,分开偿还,那么我就会赢。
然而,其他的贷款还在那里,且偿还起来也是有利可图!我再次尝试优化我的 gas 价格,发现如果我偿还前 4 笔 sUSD 贷款,接下来最省 gas 的做法是一并偿还后 6 笔最大的 sUSD 贷款,再分别偿还最大的两笔 sETH 贷款。此外,假设我赢了,我可以使用从之前的交易捆中获利的 ETH,而不再需要闪电贷了。
Flashbots 竞拍和我的交易捆排序策略
重复一下情况:我既要在 gas 效率上竞争,又希望通过偿还每笔贷款来最大化我的收益。最佳策略是在每个交易捆中提交几笔偿还,分几个交易捆进行。这些交易捆会在 Flashbots 竞拍中被各自评估。然而,每笔交易都取决于来自 pDAO 的交易,是它使得贷款可以被任何人偿还。
如果 pDAO 交易不在交易捆里,那么该交易捆就会失败。但如果我的每个交易捆里都有 pDAO 交易,那么只有一个交易捆会成功。也就说,在一个交易捆被成功打包后,其他所有的都是无效的,因为它们会试图对 pDAO 交易重复打包。因此,我需要找到方法使得仅在我的第一个交易捆里发送 pDAO 交易,但同时确保我的其他交易捆不会因为它们没有 pDAO 交易而失败和被扔掉。
解决方案在于 Flashbots 竞拍的一个细微之处。在搜索器开始对竞拍使用“把戏”,降低交易捆合并后的矿工费用,Flashbots 实行两轮的模拟。首先,所有的交易捆都被单独进行模拟,得出它们的 gas 价格并检查是否会失败。在第二轮,成功的交易捆会被按照 gas 价格排序,并再次进行模拟,以找出前后不一致的交易捆,确保没有交易捆的 gas 价格是低于预期的。除非你想这样做,否则你可能永远不会有一个交易捆是 gas 价格在合并后是降低了的。
我意识到我可以做上述搜索器相反的事:我的交易捆不是支付比预期更少的 gas 费,它们会在第二轮模拟中支付更多。为了做到这点,我将如预期般在第一个交易捆里打包 pDAO 交易,但要对剩余的交易捆做额外检查。这些交易捆将推断它们将会在第几“轮”模拟,然后相应改变它们的执行。如果它们在“第一轮”,它们将不会偿还任何贷款——因为它们尝试偿还的话会失败——然后无论如何都给矿工支付费用,以获得高的 gas 价格,通过第一轮的模拟。
通过了第一轮模拟后,这些交易捆将在第二轮模拟中跟在有 pDAO 交易的那个交易捆后面。到了这步,它们就能成功偿还贷款了。此外,这些交易捆的 gas 价格会比竞拍预期的更高,而不是更低,因此在这里改变执行不是问题。
我是如何确定我的交易捆是在哪一“轮”的呢?通过看我合约的余额。如果在区块的早期 (即在前一个交易捆)我已经成功偿还贷款了,那么我的余额应该增加了,因为这样做能从中获得收益。因此,我增加了一个条件来检查我是否获得任何 WETH 收益,如果有,则继续偿还贷款。这在测试中是成功的。
总结:这个阶段还是关于策略。我使用早期得到的数据、合约和测试环境来思考我要竞争获得的 MEV 机会的经济学逻辑,以及最优策略会是什么。通过使用真实数据,我发现了一个令人惊讶的占有策略,但它很难执行。执行它需要一种新方式来提交交易捆。
第五步:执行
现在手上有了数据、合约、以及我可以开始执行的计划。基本上,我需要构建多个这样的交易捆:一方面可以执行我上文的计划,另一方面监听交易池里与 Synthetix 相关的交易以进行尾追。此时,大部分都是实现上的问题。
首先,我使用了 Blocknative 来监听 pDAO 账户,以了解相关交易的情况。我让任何从 pDAO 账户发出的交易信息都会发送到我的机器人。
然后,我同时运行两个监听脚本 (一个用于 sETH 和 sUSD ),以从链上获取数据,得出最优的交易捆策略 (例如先偿还前 3 笔 sETH 贷款,闪电贷 X 个 ETH,并对后 2 个做相同的事,等),并生成我的合约需要的数据。我需要在每个区块如此运作,以防价格改变了或有人关闭了贷款,由此改变了最佳策略。这些结果保存在了本地。
最后,我有了一个执行脚本,它会接收发送到我的机器人的待打包交易信息,并从我的监听脚本加载出最佳打包交易捆策略的结果,自动构建交易捆,并把它们发送到 Flashbots。
剩下的事情就是等待。在这段时间,最高额的 sETH 贷款被借款人偿还了,因此我关了机器人中的该部分。几个最高额 sUSD 贷款也结束了,这大大减少了预期的回报率。
第六步:关键时刻到了
有趣的是,有人试图通过向相关合约发送交易,想诱使机器人在早期失灵。我不确定这种情况是否也会出现在其他人的机器人上,但我的机器人没有被诱导成功。
几个小时后,pDAO 发出了真实的交易。经过数周的研究和准备,我知道关键时刻到了。我这边一切都进行很顺利:我的监听脚本运行的很好,交易被接收,交易捆也被构建和提交了。
…..然后意外发生了。连续多个区块都没有 Flashbots 区块被挖出。我不仅因此失去了机会,也没有 Flashbots 搜索器赢得了机会。在区块顶部没有了 Flashbots 交易捆起阻挡作用,一个雄心勃勃的交易池机器人介入了,并抢走了所有有利可图的贷款。
尽管输了,但我认为我的方法仍然是正确的。我的优势在于策略和发现新机会,而不是参与 PGA (最优 GAS 费竞拍)。因此,使用 Flashbot 给了我胜出的最佳机会。鉴于 Flashbots 已被广泛采用,遇上连续好几个非 Flashbots 区块也是相当不走运了。
MEV 有时被认为是神秘莫测的超级程序员的领域,但它不一定是这样的。它可以是有趣和刺激的。而游戏规则,如果你要搜寻它们的话,可以说是开放的。这篇文章是关于我学习我所参与的游戏的规则的过程,根据这些规则我想出了策略,并最终执行该策略。尽管我输了,我学到了很多东西,并在此过程获得了乐趣。我希望你们也可以,并希望你和我一起参与下一轮的游戏。