# Flashbots - MEV strategy

  • bixia1994
  • 更新于 2024-10-13 15:25
  • 阅读 560

Flashbots-MEVstrategy

Flashbots - MEV strategy

本文是基于flashbots的PM: RobertMiller的文章进行学习。

最近在写bot,发现自己的nodejs功底实在是太差,很多功能不会实现,与其不断地面向百度编程,不如退后一步,学习下优秀的bot源码,看它是如何去实现具体的逻辑,有什么值得借鉴的地方。

本文参考链接如下:Anatomy of an MEV Strategy: Synthetix | Robert Miller’s blog (bertcmiller.com)

其实不是很喜欢写bot,还是喜欢写合约,分析合约。但是具备基本的nodejs素养是必须的,否则很难自己动手实现一些有意思的事情。与其临渊羡鱼不如退而结网。

背景介绍

简单来讲就是RobertMiller在网上发现了一个MEV机会,Synthetix要结束为期1年的ETH挖矿sETH的合约,当结束合约时,部分用户的借贷头寸会暴露给所有人,作为清算者,我们可以去清算用户的头寸获取额外收益。

清算步骤为:

第一步:合约owner(pDAO)调用setLoanLiquidationOpen()方法,允许任何人去清算

第二步:任何人都可以调用liquidateUnclosedLoan()方法来清算具体的贷款

具体bot执行为:

准备工作:

需要做的准备工作是:选择最优的贷款去执行清算,需要找到所有用户可清算贷款

这里就需要调用openLoanIDsByAccount()getLoanInformation()两种方法来分别获取一个账户的所有可清算贷款ID和返回该ID对应的详细信息

Bot的工作流程为:

  1. 使用getLoan方法找到一个地址是否有可清算贷款,如果有,则记录该账户的贷款ID
  2. 使用getLoanInformation()得到该笔贷款ID的具体信息
  3. 根据可清算贷款金额排序,存储到文件中

这里的难点在于单纯从线下用ethers来访问区块链,其爬取数据的速度很慢,需要写一个批处理的智能合约,用于批量爬取数据。

这里形成了如下的三个文件:sAssetsOracle.sol和monitor-sETH.js,monitor-sUSD.js

执行部分:

需要自己编写一个合约文件,执行如下的路径:

Flashloan ETH -> swap for USDC -> swap for sUSD -> liquidate sUSD loan -> receive ETH -> pay back ETH flashloan

这里使用dYdX来执行flashloan,因为dYdX不收取手续费。

为了进一步减少gas消耗,在设计合约时,需要注意如下事项:

  1. 将多笔清算交易放置在一笔交易中执行,这样摊销了固定的手续费用,并提高整个bundle的竞争力
  2. 执行swap时,需要选择exactOutput还是exactInput两种swap方式,这里选择使用exactOutput以避免在swap之后再调用一次balanceOf方法,从而节省Gas消耗
  3. 交易时的数值精度与Gas效率需要仔细考虑。

写合约时,可以注意以下事项:

  1. 在合约的构造函数中,直接Approve所有的Token,这样就只需要在部署该合约时付Approve这个动作的gas费,而不用在每一次执行时付费
  2. 使用gas Token的销毁操作来冲抵合约的gas消耗
  3. 函数命名时,让其函数选择器以0开头
  4. 直接将require语句写在函数内,而不是使用modifier

这部份形成了如下的文件:dYdXLiquidator.sol和index.js, executor.js, builder.js

Bot执行

由于flashbots中的MEV拍卖机制是最高Gas Price的bundle获胜,并包含进入区块。因此构造bundle时,需要最大化Gas Price,而不是总的付款的ETH。

准备工作部分:

取得相关的背景介绍后,我们需要开始准备工作部分的编码,分为两个板块:合约和脚本。

首先是合约部分,这部分的目的是批量从线上合约中抓数据,需要调用如下接口获取数据

interface iExchangeRates {
    function effectiveValue(
        bytes32 sourceCurrencyKey,
        uint sourceAmount,
        bytes32 destinationCurrencyKey
    ) external view returns (uint value);
}
interface collateralContract{
    function getLoan(address _account, uint256 _loanID)
        external
        view
        returns (
            address account,
            uint256 collateralAmount,
            uint256 loanAmount,
            uint256 timeCreated,
            uint256 loanID,
            uint256 timeClosed,
            uint256 accruedInterest,
            uint256 totalFees
        );
    function openLoanIDsByAccount(address _account) external view returns (uint256[] memory);
    function calculateMintingFee(address _account, uint256 _loanID) external view returns (uint256);
}

然后是脚本部分,脚本moniter-sETH.js部分逻辑如下:

const ethers = require("ethers")
const fs = require("fs")
let sAssetsOracle = new ethers.Contract(addresses.sAssestsOracle, abis.sAssetsOracle, provider)
async function main() {
//脚本在每一个块更新时都需要执行,这里直接调用websocket
    let lock = false
    ethersWSChainProvider.on("block", async (blockNumber) => {
//从json文件中读取对象,require即可
        const sETHAddresses = require("../data/sETH-addresses.json")
        const sETHLoanIDs = require("../data/sETH-loanIDs.json")
//给主线程上锁
        if (lock == false) {
            lock = true
//拿到所有独立的地址,不能重复拿地址
            let uniqueETHAddress = getUniqueAddress(sETHAddresses)
//调用sAssetOracle合约中的batchOpenLoanIDsByAccount方法,获取批量openIDs
            let openIDs = sAssestOracle.batchOpenLoanIDsByAccount(uniqueETHAddress,addresses.sETH_loansAddress, sETHLoanIDs.length)
//将获取到的批量openIDs去除空值后转换成当前地址和当前ID,根据loansID是否为0来判断
            let currentAddr, currentID;
            [currentAddr, currentID] = parseBatchOpenId(openIDs)
//根据当前地址和当前ID调用sAssetOracle合约的batchGetLoanInformationSETH方法,得到每个地址及ID对应的债务和抵押品数量
            let loanData = sAssestOracle.batchGetLoanInformationSETH(currentAddr,currentID,addresses.sETH_loansAddress)
//将地址,ID,债务和抵押品数量整理成一个对象方便调用
            let loanDataComposed = composeData(currentAddr,currentID,loanData[1],loanData[0])
//对该对象以债务数量排序得到排好序后的集合对象
            let sortedData = loanDataComposed.sort(function(a,b){return a.collateralLiquidated - b.collateralLiquidated})
//根据优化方法得到重新排序后的集合
            let firstOptimal = getOptimal(sortedData)
            let sortedData2 = sortedData.slice(firstOptimal.numberToLiquidate + 1)
//迭代优化3次,将优化结果写道控制台
//将当前地址和当前ID写回到JSON文件中
            fs.writeFileSync("../data/sETH-loanIDs.json",JSON.stringify(currentID))
            fs.writeFileSync("../data/sETH-addresses.json",JSON.stringify(currentAddr))
        } 
    })
}

对于sUSD来讲,其脚本与sETH脚本不同之处在于,需要访问外网获取ETH的实时价格,需要使用到一些http的请求。

执行部分:

首先是合约部分:

需要使用到dYdX的闪电贷功能,这里首先需要了解dYdX的闪电贷如何使用:

简单来讲,使用dYdX的闪电贷的模式与AAVE的闪电贷模式是一样的,即调用指定合约的某个方法,然后由AAVE或者dYdX来回调到你借贷合约的回调方法。

dYdX闪电贷:

借款: 
interface ISoloMargin {
    function operate(Account.Info[] memory accounts, Actions.ActionArgs[] memory actions) external;
}
回调:
interface ICallee {
    function callFunction(address sender, Account.Info memory accountInfo, bytes memory data) external;
}

注意到,dYdX的闪电贷的借款部分用到了Account和Actions这两个结构体,需要一并在合约中定义

library Types {
    enum AssetDenomination { Wei, Par }
    enum AssetReference { Delta, Target }
    struct AssetAmount {
        bool sign;
        AssetDenomination denomination;
        AssetReference ref;
        uint256 value;
    }
}
library Account {
    struct Info {
        address owner;
        uint256 number;
    }
}
library Actions {
    enum ActionType {
        Deposit, Withdraw, Transfer, Buy, Sell, Trade, Liquidate, Vaporize, Call
    }
    struct ActionArgs {
        ActionType actionType;
        uint256 accountId;
        Types.AssetAmount amount;
        uint256 primaryMarketId;
        uint256 secondaryMarketId;
        address otherAddress;
        uint256 otherAccountId;
        bytes data;
    }
}

此时,需要根据借贷中的operate的方法定义,来构造其对应的参数:

//借款人
Account.Info[] memory accounts = new Account.Info[](1);
accounts[0].owner = address(this);
accounts[0].number = uint256(1);
//借款操作,为固定的模式:取钱-call-还钱
Actions.ActionArgs[] memory actions = new Actions.ActionArgs[](3);
//withdarw
actions[0] = Actions.ActionArgs({
        actionType : Actions.ActionType.Withdraw,
        accountId : 0,
        amount : Types.AssetAmount({
                    sign : false,
                    denomination : Types.AssetDenomination.Wei,
                    ref : Types.AssetReference.Delta,
                    value: _loanAmount
        }),
        primaryMarketId : 0, //WETH
        secondaryMarketId : 0,
        otherAddress : address(this),
        otherAccountId : 0,
        data : hex""
});

事实上所有的逻辑都写在还款操作中,具体的要实现逻辑是

Flashloan ETH -> swap for USDC -> swap for sUSD -> liquidate sUSD loan -> receive ETH -> pay back ETH flashloan

即需要swap WETH->USDC->sUSD, 然后调用synthesis中的清算方法来执行清算,之后给miner付钱,然后将ETH存款得到WETH,将WETH还款给dYdX.

这里需要设计data字段,即应该如何去调用该方法:

data = (
            address[] memory sUSDAddresses,
            uint256[] memory sUSDLoanIDs,
            uint256 wethEstimate,
            uint256 usdcEstimate,
            uint256 ethToCoinbase
        ) 

后面就可以直接构造一个data字段传入给方法即可。

function callFunction(address sender, Account.Info memory accountInfo, bytes memory data) external {
    //第一步:检查调用者必须是owner或者是dYdX
    //第二步:将传入的data字段解析成需要的字段sUSDAddresses,sUSDLoanIds,wethEstimate,usdcEstimate,ethToCoinbase
    //第三步:利用uniswapV3将WETH交换成USDC
    //第四步:利用Curve将USDC转换成sUSD
    //第五步:利用得到的sUSD执行清算
    //第六步:将清算得到的ETH存入WETH,以WETH还款
    //第七步:给矿工小费
}

在构造函数中,constructor中需要对token进行approve,那么需要对哪些Token执行approve操作呢?

针对uniswap,需要对Uniswap Router 授权WETH,授权量为最大值

针对curve,需要对curvePool 授权USDC,授权量为最大值

针对借贷dYdX,需要针对dYdX授权WETH,授权量为最大值

针对清算sUSDloan,需要针对sUSDLoan授权sUSD,授权量为最大值

constructor(address _executor) public payable {
    owner = msg.sender;
    executor = _executor;
    WETH.approve(address(uniswapRouter), type(uint256).max);
    WETH.approve(address(soloMargin), type(uint256).max);
    IERC20(usdcTokenAddress).approve(address(curvePoolSUSD), type(uint256).max);
    IERC20(sUSDTokenAddress).approve(address(sUSDLoansAddress), type(uint256).max);

    if (msg.value > 0) {
        WETH.deposit{value:msg.value}();
    }
}

在该合约中,还添加了辅助函数:

function call(address payable _to,uint256 _value, bytes memory _data) external payable  returns (bytes memory) {
    require(msg.sender == owner);
    require(_to != address(0));
    (bool _success, bytes memory _res) = _to.call{value:_value}(_data);
    require(_success);
    return _res;
}

此时合约部分就已经完成,现在需要编写脚本部分。

脚本部分可以按照如下的文件来组织:

index.js -- 脚本入口文件
builder.js -- 准备文件,包含环境准备,数据准备等
executor.js -- 具体执行文件,包含

脚本部分需要执行的工作有:

  1. 监听mempool的状态,以每一块出块为单位监听,拿到合约owner发出的open指令
  2. 拿到指令后,构造三个bundle发送给relay中。

这里的难点有:

  1. flashbots根据bundle的整体平均价格进行排序,而不是总的收入排序
  2. 要同时构造3个bundle,每个bundle都必须在pDAO发出open指令后才能交易成功。但是不能同时将pDAO发出open指令的交易同时放进入3个bundle中。这样就只能成功一个bundle,因为pDAO的同一笔交易不能被replay。解决方案是利用flashbots的bundle拍卖机制。

flashbots针对一笔bundle有两轮拍卖机制,

  1. 第一轮拍卖:所有的bundle都会单独模拟以得到单个bundle的平均gas价格;

  2. 第二轮拍卖:经过第一轮拍卖后成功的bundle会根据Gas Price进行排序,然后将排序后的所有bundle重新模拟以发现出是否有冲突的bundle,并且确保每一个bundle的Gas Price都在一个范围以上。

为了让三个bundle按照先后顺序成交,这里对bundle的设计如下:

  1. 第一个bundle包含有pDAO发出open指令的交易
  2. 第二个bundle不包含该交易,且第二个bundle在第一轮拍卖时,通过在合约中的判断语句判断是否在模拟中,避免具体的逻辑执行,只进行coinbase的转账,从而提高第二个bundle的在第一轮模拟的gas price。在第二轮模拟时,由于要使得该bundle必须在第一个bundle执行完成后才执行,需要在第二轮模拟时降低gas Price,从而排在第一个bundle后面。
  3. 第三个bundle的逻辑与第二个bundle的逻辑一致。

这里的问题是如何在合约中设计这个判断条件,以判断是否在flashbots的第一轮模拟中?

解决方案是:在合约编写中,添加一个if语句,以自己合约账户中的WETH余额是否为0来判断。如果是在flashbots的第一轮模拟中,由于pDAO的交易此时没有成交,故此时合约账户中的WETH余额应该为0. 如果是在flashbots的第二轮模拟中,由于第一个bundle已经成交,故此时合约账户中的WETH余额应该不为0。

executor部分编写

如上分析,executor部分主要负责的内容是拿到内存池中的特定交易,组装成3个bundle,并将该3个bundle发送给flashbotsrely层。这里采用类的方式来编写executor部分。

class Executor
constructor函数:
//构造函数作用是将signer,nonce,部署的合约,falshbots的provider写入到类的实例中
executeTransaction(transaction)函数:
//执行交易函数,主要作用是将发送过来的交易打包成bundle,然后构造3个bundle同时发送出去。
//第一步:拿到当前pDAO交易需要执行的blockNumber
//第二步:对pDAO交易重新组装并序列化成可打包如bundle的交易
//第三步:将序列化后的交易打包进入bundle1
//第四步:构造bundle2和bundle3
//第五步:使用Promise.all方式同时将三个bundle发送出去,三个bundle都在同一个的目标区块高度,分别发送三次,区块高度依次递增
getSignedTransaction(transaction)函数:
//将pDAO发送过来的交易重新组装并且序列化后成为可以直接打包进入bundle的交易
//第一步:构造tansactionObject,将pDAO发送的交易的对象分别填入
//第二步:构造交易签名signature
//第三步:调用ethers的serializeTransaction方法对交易和签名进行序列化操作
//第四步:返回序列化后的pDAO交易
getSUSDFlashloanParameters(bundleConfig)函数:
//主要作用是生成调用dYdX合约flashloan_dydx方法的参数中的data字段,后面需要解析
//flashloan_dydx(uint _loanAmount, bytes memory _params, uint8 _trigger)
//使用Web3EthAbi库,也可以直接使用ethers库的ethers.utils.defaultAbiCoder.encode(types,values)方法
//第一步:构造types
//第二步:构造values
//第三步:调用库函数得到abi编码后的数据,并返回
buildSUSDBundles(signedTransaction)函数:
//主要作用是构造第一个bundle,第一个bundle里面含有从内存池中抓来的pDAO交易,以及一笔自己的交易
//第一步:使用ethers的meta-class中的交易分析方法contract.populateTransaction,用于得到未经签名的交易信息。
//flashloan_dydx(uint _loanAmount, bytes memory _params, uint8 _trigger)
//第二步:使用populateTransaction需要提供overrides对象,包含gasPrice,gasLimit,value,nonce等信息
//第三步:构造flashloan_dydx方法需要的参数:params,这部分的params会传递给callFunction中的data。
//第四步:构造bundle,第一笔交易是pDAO的交易,第二笔交易是flashloan_dydx交易
//第五步:使用flashbots里面自带的provider对bundle进行签名,签名后返回该bundle
sendThroughRelays(targetBlockNumber)函数:
//将三个bundle发送给flashbots relay,注意返回值是promise,而不用单独await一下。这里的难点是保证所有的bundle在同时发送出去,而不是依次发送
//第一步:调用flashbotsProvider的sendRawBundle方法发送第一个bundle,此刻这里不能await
//第二步:调用sendRawBundle方法发送第二个bundle,此刻这里不能await
//第三步:调用sendRawBundle方法发送第三个bundle,此刻这里不能await
//第四步:返回所有的promise
module.exports = Executor
builder部分编写

builder部分需要监听内存池中pDAO发出的交易,如果pDAO发出交易,此时将pDAO的交易转发给executor让其去执行。另一方面是进行Executor的初始化操作,需要构造对应的signer,nonce,flashbotsBundleProvider和交互合约dYdXlflashloan.

//根据builder的功能,builder分为两类:1.监听mempool中pDAO的交易;2.初始化Executor
buildExecutorModule(privateKey,rpc)函数
//主要目的是初始化executor
//第一步:根据rpc链接,实例化provider
//第二步:根据privateKey,实例化一个wallet,并链接provider得到signer
//第三步:利用ethers中的signer.getTransactionCount方法得到signer此时的nonce值
//第四步:利用ethers的new Contract构造交互的合约实例
//第五步:构造flashbotsBundleProvider
//第六步:将signer,nonce,dydx,flashbotsBundleProvider传入Executor,实例化一个executor
//第七步:返回该executor
buildSDK(blocknativeAPIKey,Executor):
//主要目的是监听mempool,当pDAO发出交易时,将该笔交易转发给Executor
//这里使用的是blocknative提供的服务
//第一步:设置配置option,包含dappId,networkId,system,ws,transactionHandlers;其中transactionHandlers是检测到该事件发生后的回调函数
//第二步:实例化sdk服务,即 new BlocknativeSdk(option)
//第三步:使用blocknative网站上提供的页面进行配置,然后下载对应的配置文件即可
index部分编写

index部分主要是作为bot入口,调用builder的两个方法即可。

点赞 1
收藏 2
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
bixia1994
bixia1994
0x92Fb...C666
learn to code