# 这几天我写了一个DEX交易聚合器

By [Keegan小钢](https://paragraph.com/@keeganlee) · 2022-01-07

---

前言
--

目前，DeFi 赛道中，专门做 DEX 交易聚合的产品挺多的，以下是其中一些平台：

![](https://storage.googleapis.com/papyrus_images/00c46bcf24079f935f8b6951502d18cbd958ab065786b64be0fdd51b8e859396.png)

可以看到，这些平台都聚合了很多家 DEX，包括 **AMM**（自动做市商）模式的 DEX，也包括 **Orderbook** 模式的 DEX，主要功能都是为了将各个 DEX 的分散流动性整合到一起，提供最优的价格、最佳的深度和清晰简洁的界面。

这几天我也写了一个 DEX 交易聚合器，纯合约的。不过功能还比较简单，只聚合了 **UniswapV2** 和 **SushiSwap**，且只实现了从这两个平台中找出最优成交价来实现每笔交易。虽然只是个简单的交易聚合器，却也接连踩了好几个坑，这也暴露出了我的一些知识盲区。下面我就分享下在这过程中的一些经验和总结。

技术调研
----

既然接入的是 **UniswapV2** 和 **SushiSwap**，而且是从合约层面去接入的，所以第一步就是先要调研如何接入。

UniswapV2 的合约分为了两个项目：

*   [uniswap-v2-core](https://github.com/Uniswap/uniswap-v2-core)
    
*   [uniswap-v2-periphery](https://github.com/Uniswap/uniswap-v2-periphery)
    

**uniswap-v2-core** 的核心有三个合约：

*   [UniswapV2ERC20](https://github.com/Uniswap/uniswap-v2-core/blob/master/contracts/UniswapV2ERC20.sol)：UNI-V2 代币合约
    
*   [UniswapV2Factory](https://github.com/Uniswap/uniswap-v2-core/blob/master/contracts/UniswapV2Factory.sol)：工厂合约
    
*   [UniswapV2Pair](https://github.com/Uniswap/uniswap-v2-core/blob/master/contracts/UniswapV2Pair.sol)：配对合约
    

UNI-V2 代币合约即是 LP Token 合约，工厂合约则主要用来创建配对合约，配对合约则维护着每个币对的流动性池子，另外，配对合约还继承了 UniswapV2ERC20 合约，即是说，**配对合约同时也是 LP Token 合约**。

**uniswap-v2-periphery** 被称为外围，其实就是供外部接入使用的，其主要有三个合约：

*   [UniswapV2Migrator](https://github.com/Uniswap/uniswap-v2-periphery/blob/master/contracts/UniswapV2Migrator.sol)：迁移合约，用来将 v1 的流动性迁移到 v2 用的
    
*   [UniswapV2Router01](https://github.com/Uniswap/uniswap-v2-periphery/blob/master/contracts/UniswapV2Router01.sol)：旧版的路由合约
    
*   [UniswapV2Router02](https://github.com/Uniswap/uniswap-v2-periphery/blob/master/contracts/UniswapV2Router02.sol)：新版的路由合约，现在都是用这个
    

Uniswap 前端的兑换、添加流动性等操作其实都是通过和**路由合约**交互完成的，所以这个路由合约也是我们的聚合交易接入 Uniswap 的入口合约。以下页面是官方文档中对 Router02 的介绍：

*   [https://docs.uniswap.org/protocol/V2/reference/smart-contracts/router-02](https://docs.uniswap.org/protocol/V2/reference/smart-contracts/router-02)
    

至于 **SushiSwap**，则是完全复用了 UniswapV2Router02 合约作为接入 SushiSwap 的入口，**只是和 Uniswap 的合约地址不一样而已**。

不过，调研路由合约后发现，兑换的两个币种之间的路径，其实从是外部传入给到路由合约的。在 Uniswap 中，路径的选择算法实现是被封装在前端的 SDK 里的，但我所做的聚合器需在合约里自己完成最优路径的寻找，这成为了第一个难题。

最优路径
----

用户兑换时选定的两个币种 tokenA 和 tokenB，有时候并不存在直接配对的流动性池子。但是，只要存在另一个币种 tokenC，满足 tokenA 和 tokenC 存在流动性池子，tokenB 和 tokenC 也存在流动性池子，那么，只要先将 tokenA 换成 tokenC，再将 tokenC 换成 tokenB，tokenA 和 tokenB 就可以完成兑换，如此，**tokenA > tokenC > tokenB** 就组成了 tokenA 和 tokenB 兑换的一条路径。

这种路径可能存在不止一条，比如，也可能存在 **tokenA > tokenD > tokenB**，甚至 **tokenA > tokenC > tokenD > tokenB**。当然，如果 tokenA 和 tokenB 之间存在直接配对的流动性池子，那么 **tokenA > tokenB** 也是一条路径。

因为每个池子的流动性不一样，当指定币种数量后，比如指定 100 个 tokenA，那么每条路径最后兑换出来的 tokenB 数量其实也是不一样的。对用户来说，自然是希望能兑换回来的 token 数量越多越好，所以，这些路径中，那条兑换结果数量最多的就能成为**最优路径**。

有些人可能会陷入一个误区，觉得最优路径应该是**最短路径**，而实际上：**最短路径不一定是最优路径**。比如，**ETH-WBTC** 其实存在直接配对的流动性池子，所以最短路径就是 **ETH > WBTC**，但是，在界面查询时看到匹配的最优路径却是 **ETH > USDC > WBTC**，请看下图：

![](https://storage.googleapis.com/papyrus_images/e378877d69a4cc6a259157c54c77568361114e955909651a4f880db39147629f.png)

不过，币种那么多，要如何才能高效地找到一条最优路径呢？

寻找最优路径
------

寻找最优路径的第一步，要先找出所有潜在路径。但是，币种那么多，不可能将所有币种都进行路径组合，尤其在合约层面，效率太低了。其实，如果看看 [Uniswap](https://app.uniswap.org/#/swap) 前端页面，选择代币时，可以看到列出了几种常用代币，如下图：

![](https://storage.googleapis.com/papyrus_images/612bf62ea9ca061a2cf703ff28b51075d9c95f404447bda2bb30e71f4e811489.png)

可以看到，这些都是最主流的代币，所有代币都是与这些代币中的一种或多种配对组成流动性池子的。因此，只要用这些代币作为路径组合的中间币种即可，而无需考虑全部代币。

另外，路径也不能太长，最长的就如 **tokenA > tokenC > tokenD > tokenB** 就够了。

总而言之，tokenA 兑换 tokenB 可遍历的路径包括：

*   **tokenA > tokenB**：只有两个代币存在直接配对的流动性池子时，该路径才有效
    
*   **tokenA > tokenC > tokenB**：tokenC 就是常用代币中的一种，要求 tokenA-tokenC 和 tokenC-tokenB 分别都存在流动性池子
    
*   **tokenA > tokenC > tokenD > tokenB**：tokenC 和 tokenD 是常用代币列表中的两种代币，要求 tokenA-tokenC、tokenC-tokenD、tokenD-tokenB 这三个配对的流动性池子是有效的
    

对每个有效路径读取出最后价格，对比后就知道最优路径是哪个了。

合约设计
----

设计上也很简单，核心的合约类图如下：

![](https://storage.googleapis.com/papyrus_images/73a038b14637c61b5e28154623ac5a5d2a9fb4ab0ba8b28564dfe75353f5cbc6.png)

而实例关系图则如下：

![](https://storage.googleapis.com/papyrus_images/a3564cf7d44d9a1a90ac66ef7d42ba2edf25b975e73d1858f8f2aca515bd8083.png)

在 **Aggreswap** 中保存一个 **dexs** 数组，用来存放所支持的 DEX，当调用 swap() 时，则遍历所有 Dex，查出具有最优价格的 Dex 并转给该 Dex 的 Handler 实例去完成兑换工作。

**UniswapV2Handler** 中则会保存常用代币列表 **baseTokens**。每次兑换时，则遍历 baseTokens，组装出每条有效路径并读取价格，从而查询出最优价格和路径，再去调用路由合约完成兑换工作。

**Aggreswap** 和每个 **Handler** 实现都统一用 **ISwap** 接口做交互，则具备了灵活性，而且调用方还可以根据需要绕开 Aggreswap 而与具体的 Handler 进行交互，且交互接口无需改动。

合约设计的整体思路就大致如此了，简单易理解。但具体实现时所犯过的错，我觉得很有必要分享下。

view函数的限制
---------

刚开始的时候，我写过下面这个函数，用来获取两个代币间的所有路径，不包括三级路径。但实际上，这里面存在着一些问题。

![](https://storage.googleapis.com/papyrus_images/6c8dc4e49f91318f7bb4d3192dcc537727aa4ff617e6c5a6835298f7ef15e6cc.png)

首先，从业务逻辑上来说，**tokenA > bases\[i\] > tokenB** 路径，缺失了是否可配对的检查，应该对 **tokenA-bases\[i\]** 和 **bases\[i\]-tokenB** 这两对分别检查 pair 是否都存在，通过 **factory.getPair()** 函数得到币对的 pair，如果 pair 不为零地址就说明是匹配的，如果不匹配就说明该路径是无效的。示例代码如下：

    if (factory.getPair(tokenA, bases[i]) != address(0) 
        && factory.getPair(bases[i], tokenB) != address(0)) {
      paths.push([tokenA, bases[i], tokenB]);
    }
    

其次，从 **solidity** 层面来说，数组的 **push()** 函数是不能在 **view** 函数中使用的，因为调用 push 函数会有固定的 gas 成本，但 view 函数是不能产生 gas 的，所以就用不了。因此，在 view 函数中使用数组只能用下标的方式进行赋值，如以下代码：

    address[] memory tempPath = new addressUnsupported embed;
    tempPath[0] = tokenIn;
    tempPath[1] = baseTokens[i];
    tempPath[2] = tokenOut;
    

最后，返回二维数组，在默认情况下是不支持的，要用 **ABIEncoderV2** 才能支持，需要对合约添加以下指令才能使用：

    pragma experimental ABIEncoderV2;
    

experimental 说明这还是实验性的，所以建议尽量少用，因为可能存在未知的 bug。

最后，我就完全抛弃了该函数，将路径的遍历和价格对比的逻辑都放在了同一个函数去完成。

链式授权转账
------

而我遇到的第二个错误则是关于授权转账的，也是因为我对授权转账的原理没真正理解导致的。

在我的实现中，兑换函数存在着几层不同合约之间的链式调用。假设我编写了 A 和 B 两个合约，两个合约都分别定义了 swap() 函数，在 A 合约的 swap() 函数中会调用 B 合约的 swap() 函数，而 B 合约的 swap() 函数再去调用 Uniswap 的路由合约的 swap() 函数。

    A.swap() -> B.swap() -> Router.swap()
    

而在 **Router.swap()** 中会调用代币的 **transferFrom()** 函数将调用者 **msg.sender** 的代币转入 **Pair** 合约。所以，在兑换之前，还需要调用者对合约进行授权。一开始我以为，只要对 A 合约进行授权就可以了，当然，结果就是兑换失败了。后来，我又添加了链式授权，即调用者授权给 A，A 再授权给 B，B 再授权给 Router，但结果依然还是失败。最后，真正理解了授权转账的原理之后，只需要调用者授权给A，B 授权给 Router，并在 A 合约增加一步操作，调用代币的 **transferFrom()** 函数将调用者 **msg.sender** 的代币转入 B 合约，整个链条的兑换就能成功了。

首先，先搞清楚在整个链条中，每一步的 **msg.sender** 是谁？像这种合约之间的直接调用，msg.sender 都是上一步的调用者，如下图：

![](https://storage.googleapis.com/papyrus_images/341e995933492c720bc21b89c6772230c62ba50222b57956a531e2e0c1edd211.png)

而在 **Router.swap()** 中会调用代币的 **transferFrom()** 函数将 **msg.sender** 的代币转入 **Pair** 合约，即是说，Router 会从 B 合约中将代币转入 Pair 合约，所以 B 合约中必须有代币才能完成转账。那 B 合约的代币从哪来呢？自然是要从 Caller 中来。只要 Caller 授权给 A，A 再用 transferFrom 将 Caller 的代币转给 B，如此就解决问题了。

总结
--

虽然这个 DEX 交易聚合器功能很简单，只有查询和兑换功能，但扩展起来很简单，后续还会接入 UniswapV3、Bancor、DODO 等，功能上也还可以再加入添加流动性、移除流动性等功能。

* * *

扫描以下二维码即可关注公众号（公众号名称：Keegan小钢）

![](https://storage.googleapis.com/papyrus_images/d7c403afb092d3e3b0f1ac00f72534edb3f21be17304dc5878846d6696f44c1a.jpg)

---

*Originally published on [Keegan小钢](https://paragraph.com/@keeganlee/dex)*
