从零开始的聚合器开发: Lotus Router 合约解析

  • WongSSH
  • 发布于 1天前
  • 阅读 161

Lotus Router是一个专门针对MEV交易的交易路由合约,支持多种DeFi协议,如Uniswap V2/V3等。文章详细介绍了合约的核心逻辑、数据结构及压缩编码方法,展示了如何在处理复杂回调及数据解码时,使用内联汇编来优化执行效率和减少calldata体积,并指出代码中存在重入攻击的风险。

概述

Lotus Router 是 jtriley2p 开发的一个用于 MEV 的交易路由的合约。此处我们需要特别强调该路由合约是适用于 MEV 交易的,该路由合约完全不会处理来自 Uniswap 合约的回调内的数据,这使得交易发起者必须预先计算出精确数值已进行交易。

目前路由合约支持以下几个协议:

  1. Uniswap v3
  2. Uniswap v2
  3. Uniswap Flashloan
  4. ERC20 Transfer and TransferFrom
  5. WETH Wrap and Unwrap
  6. Dynamic Contract Call

实际上,看上去功能很多,但合约并不复杂。合约的核心功能主要聚集在使用内联汇编进行 Calldata 编码和解码上。我认为 Lotus Router 可以作为学习内联汇编后的第一个实战项目。

由于 Lotus Router 涉及到了 MEV 方面的内容,jtrileyp2p 编写此合约后不久就被其先前所在的企业发起了 版权投诉,认为 jtrileyp2p 窃取了公司机密来完成了该路由合约的编写。在此处,我们复制一段来自 Lotus Router Readme 的一段话:

从事搜索和解决问题的公司像我们一样,反复构建前沿的路由技术。

搜索者和解决者用“阿尔法衰退”等伪学术术语为保密辩护,以囤积最前沿的技术和伴随而来的资本。

我们厌倦了不断重复构建同样的软件。

我们厌倦了签署一份又一份的保密协议。

我们厌倦了不断重复自己。

所以我们,研究者和开发者,编写该软件以意图使路由技术的前沿民主化。

所以我们,研究者和开发者,编写该软件以意图解放一个寄生行业的秘密。

所以我们,研究者和开发者,编写该软件以意图揭示隐藏在字节码混淆器和我们当地精英主义背后的优雅简约。

幸好,我们拥有 IPFS 作为去中心化工具。读者可以非常简单的使用 bafkreif2ffb2kghamkdjp5pcgrsxu26hx42w3imujcq6zeqacwzsg5pbla 获得代码的 zip 压缩版本,读者也可以选择直接使用 pinata gateway 下载。

基础执行逻辑

对于所有的路由合约,我们都可以将路由合约视为一个虚拟机,该虚拟机接受来自 calldata 的指令,然后进行对应的动作。比如我们可以想路由合约传入以下 2 条指令:

  1. 使用 Uniswap V2 将 ETH 兑换为 USDC
  2. 使用 Uniswap V3 将 USDC 兑换为 USDT

路由合约就会读取指令 1 完成第 1 步兑换,然后读取指令 2 完成第 2 步操作。我们首先不考虑 Uniswap 系列的回调情况,我们只需要将 calldata 分割成一系列指令,然后在循环中依次执行就可以。我们可以编写如下代码:

fallback() external payable {
      Ptr ptr = findPtr();
      Action action;
      bool success = true;

    while (success) {
        (ptr, action) = ptr.nextAction();

        if (action == Action.Halt) {
            assembly {
                stop()
            }
        } else if (action == Action.DynCall) {
            bool canFail;
            address target;
            uint256 value;
            BytesCalldata data;

            (ptr, canFail, target, value, data) = BBCDecoder.decodeDynCall(ptr);

            success = dynCall(target, value, data) || canFail;
        } else {
            success = false;
        }
}

此处的 ptr 并不是指内存指针而是 calldata 指针,记录我们目前在 calldata 内读取的位置。而 nextAction 是指读取下一个 bytes,其实现如下:

function nextAction(
    Ptr ptr
) pure returns (Ptr, Action action) {
    assembly {
        action := shr(0xf8, calldataload(ptr))

        ptr := add(ptr, 0x01)
    }

    return (ptr, action);
}

我们会在后文介绍路由合约特殊的 calldata 编码逻辑时再次详细介绍以上代码的含义。此处我们只需要知道 action 其实是一个枚举类型,被编码在每一个动作最前面。 action 的编码如下:

<action> ::=
  | ("0x00")
  | ("0x01" . <swap_uni_v2>)
  | ("0x02" . <swap_uni_v3>)
  | ("0x03" . <flash_uni_v3>)
  | ("0x04" . <transfer_erc20>)
  | ("0x05" . <transfer_from_erc20>)
  | ("0x06" . <transfer_from_erc721>)
  | ("0x07" . <transfer_erc6909>)
  | ("0x08" . <transfer_from_erc6909>)
  | ("0x09" . <deposit_weth>)
  | ("0x0a" . <withdraw_weth>)
  | ("0x0b" . <dyn_call>) ;

继续回到 fallback 函数内的 while (success) { 循环,该循环最终跳出循环的条件是:

  1. success = false 该条件出现的原因包含解析出的 action 没有被定义或者是在不允许调用失败( canFail = False )的情况下出现了调用失败
  2. action = 0x00 该条件本质上就是读取到了 calldata 的末尾。根据 CALLDATALOAD 的定义,当 calldataload 读取大于当前 calldata 长度的数据时,calldataload 将会返回 0

此处可能会令读者迷惑的是 canFail 变量。简单来说, call 操作失败并不会直接导致当前执行中断,我们可以不处理返回的错误继续执行合约代码。当然,在 solidity 中,默认情况下调用失败会直接导致合约中断执行。在使用 try-catch 是一个例外情况。

危险警告:我们上述 while 循环内的代码并没有做重入攻击防护,实际上 Lotus Router 源代码内也没有处理 call 之后的重入锁定。没有做重入防护的原因是路由合约大部分情况下内部并没有资金,一般来说我们会在调用时转入资金,在调用结束后将所有资金从路由合约内转出,更加重要的是路由合约应该仅于受信任合约交互。当然,假如读者认为存在进行重入防护的情况,读者可以自己编写重入锁定相关的代码。

接下来,我们需要处理一种最复杂的情况。Uniswap V2 和 Uniswap V3 都存在 callback 的回调逻辑,我们需要对回调逻辑进行特殊处理,这是因为一旦发生回调,我们上下文中的 calldata 会被 Uniswap 回调中的 calldata 会 覆盖原有上下文中的 calldata。我们以 Uniswap V3 的 callback 为例,以下代码来自 Uniswap V3 合约:

function uniswapV3SwapCallback(
    int256 amount0Delta,
    int256 amount1Delta,
    bytes calldata data
) external;

简单来说,我们可以在调用 swap 等函数时,传入 data 参数,然后 Uniswap 在执行 swap 结束后,会使用反向调用发起交易合约,Uniswap V3 内存在以下代码:

IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);

我们会使用 data 参数来解决回调覆盖上下文 calldata 的问题。具体来说,我们会将当前没有完成的 calldatadata 的形式直接发送给 Uniswap V3。这样,我们会接受回调时就可以获得之前的上下文信息。这意味着在接受回调后,我们需要在新的上下文 calldata 内检索出我们需要的 data。仍以 uniswapV3SwapCallback 为例,我们需要跳过 amount0Deltaamount1Delta,直接处理 data 字段。上文中出现的 findPtr 就是进行该操作的,我们可以看到其实现如下:

function findPtr() pure returns (Ptr) {
    uint256 selector = uint256(uint32(msg.sig));

    if (selector == takeAction) {
        return Ptr.wrap(0x04);
    } else if (selector == uniswapV2Call) {
        return Ptr.wrap(0xa4);
    } else if (selector == uniswapV3SwapCallback) {
        return Ptr.wrap(0x84);
    } else if (selector == uniswapV3FlashCallback) {
        return Ptr.wrap(0x84);
    } else {
        revert Error.UnexpectedEntryPoint();
    }
}

我们会直接跳过不需要的 amount0Delta 等信息直接进入 data 字段,然后继续从 data 内读取数据。值得注意的是,此时路由合约的代码执行更加类似递归系统。下图展示进行 A -> B -> C 的流程,与我们的预期并不一致,在 Uniswap V3 中,我们协议先进行 B -> C 的兑换,然后再进行 A -> B 的兑换。注意,Lotus Router 并不会自动帮助用户进行代币转账操作,下图中的由 Lotus Router 发起的 trasfer Atransfer B 都需要调用者自己将其编码到 calldata 内部。

sequenceDiagram
    Lotus->>+MarketBC: swap(B, C)
    MarketBC-->>Lotus: transfer C
    MarketBC->>+Lotus: uniswapV3SwapCallback
    Lotus->>+MarketAB: swap(A, B)
    MarketAB-->>Lotus: transfer B
    MarketAB->>+Lotus: uniswapV3SwapCallback
    Lotus-->>MarketAB: transfer A
    Lotus-->>MarketBC: transfer B
    Lotus->>-MarketAB: return
    MarketAB->>-Lotus: return
    Lotus->>-MarketBC: return
    MarketBC->>-Lotus: return

非常幸运的是,Uniswap V4 引入了 Flash Account 系统,我们可以以任意顺序进行兑换只需要在最后补齐代币即可

上述递归流程可以被拆分为如下流程:


sequenceDiagram
    Lotus->>+MarketBC: swap(B, C)
    MarketBC-->>Lotus(1): transfer C
    MarketBC->>+Lotus(1): uniswapV3SwapCallback
    Lotus(1)->>+MarketAB: swap(A, B)
    MarketAB-->>Lotus(2): transfer B
    MarketAB->>+Lotus(2): uniswapV3SwapCallback
    Lotus(2)-->>MarketAB: transfer A
    Lotus(2)-->>MarketBC: transfer B
    Lotus(2)->>-MarketAB: return
    MarketAB->>-Lotus(1): return
    Lotus(1)->>-MarketBC: return
    MarketBC->>-Lotus: return

总结来说,对于常规调用,Lotus Router 会使用 while 循环逐个处理,但对于带有回调的情况,Lotus Router 会将未执行的 calldata 作为参数发送出去,然后在回调中使用 findPtr 寻找之前传入的未执行 calldata。

压缩编码

众所周知,solidity 的 ABI 编码是没有经过优化的,使用 solidity 编码获得的 calldata 体积往往较大。大部分路由合约都会有自己的一套独特的压缩编码方法。Lotus Router 就有一套自己的编码格式,该编码格式受到了 bigbrainchad.eth 的启发。这套编码方法的核心是不进行任何前导 0 的填充以降低 calldata 体积。在上文中,我们已经展示过了 action 的情况,不同的参数代表后续 calldata 代表的动作。在 action 后,我们就需要指定具体的动作内容,我们此处以 ERC20 代币的转移为例,ERC20 代币转账的 calldata 可以被编码为以下格式:

<transfer_erc20> ::=
  . <can_fail_bool>
  . <token_byte_len_u8>
  . <token>
  . <receiver_byte_len_u8>
  . <receiver>
  . <zero_for_one_bool>
  . <amount_byte_len_u8>
  . <amount> ;

can_fail_bool 指当前调用是否可失败,而 token_byte_len_u8 代表代币地址的长度,Lotus Router 并不对任何参数进行前缀 0 补全,所以我们需要使用 token_byte_len_u8 手动指定 token 代币地址的长度。 receiver_byte_len_u8receiver 的组合同理。 zero_for_one_bool 由于该参数属于 bool 类型,其长度固定为 1 bit,所以我们不需要指定其长度。 amount_byte_len_u8amount 也是因为 amount 的长度并不确定,所以需要使用 amount_byte_len_u8 指定。简单来说,在 Lotus Router 内除了 bool 类型之外的所有类型都是被视为动态类型需要指定长度的。

由于 Uniswap V2 等兑换涉及到递归过程,所以 Uniswap V3 等编码方法较为特殊。以 Uniswap V3 为例:

<swap_uni_v3> ::=
  . <can_fail_bool>
  . <pool_byte_len_u8>
  . <pool>
  . <recipient_byte_len_u8>
  . <recipient>
  . <zero_for_one_bool>
  . <amount_specified_byte_len_u8>
  . <amount_specified>
  . <sqrt_price_limit_x96_byte_len_u8>
  . <sqrt_price_limit_x96>
  . <data_byte_len_u32>
  . <data> ;

我们可以看到相比于常规的 transfer_erc20 编码, swap_uni_v3 增加了 data_byte_len_u32data 内容。其中, data_byte_len_u32 代表 data 的长度,而 data 部分则代表其他的交易数据,比如我们可以在 data 部分写入 transfer_erc20 的数据。

在具体执行流程中,当路由合约执行 swap_uni_v3 动作时,该合约会将 data 部分的数据作为附加数据发送给 Uniswap 的合约,然后在 callback 流程中继续执行 data 内的动作。

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

0 条评论

请先 登录 后评论
WongSSH
WongSSH
0x1147...955C
江湖只有他的大名,没有他的介绍。