Keegan小钢

Posted on Jan 07, 2022Read on Mirror.xyz

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

前言

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

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

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

技术调研

既然接入的是 UniswapV2SushiSwap,而且是从合约层面去接入的,所以第一步就是先要调研如何接入。

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

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

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

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

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

至于 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,请看下图:

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

寻找最优路径

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

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

另外,路径也不能太长,最长的就如 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 这三个配对的流动性池子是有效的

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

合约设计

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

而实例关系图则如下:

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

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

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

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

view函数的限制

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

首先,从业务逻辑上来说,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 address[](3);
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 都是上一步的调用者,如下图:

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

总结

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


扫描以下二维码即可关注公众号(公众号名称:Keegan小钢)