老胡茶室
老胡茶室

Uniswap 非权威开发指南(v1 版)

胡键

作为 defi 系列的第一篇(同时也是微信公众号付费文章的第一篇),我想写写 uniswap,并且从 v1 开始写起。这里面的原因很简单:

  • uiniswap 的名气足够大。
  • 虽然 v2 版已经发布,但并不影响 v1 版的运行。并且,相对于 v2 来讲,v1 要简单很多,作为系列开篇是一个不错的选择。
  • 关于 uniswap 的文章并不少,但真正对开发有价值的文章并不多。有感于自己在近期开发过程中的各种抓瞎,觉得有必要将一些经验和解决办法分享出来。

这是一篇面向开发者的文章,从中你可以学到:

  • uiniswap 的协议
  • sdk 接入指南和关键代码
  • 基于 ganache 的自动化测试
  • 相关公式

关于 uniswap 的好处和优点,外面的文章已经说的很多了,我就不在这里浪费口舌,直接进入正题,从协议说起吧。

uniswap 协议(v1)

uniswap v1 的协议并不复杂,这里有篇图解可以帮助开发很快了解它。简单来讲,这里涉及到三个对象:工厂合约、交易所合约和 ERC20 合约。主要的过程如下:

  1. 为要实现兑换的 ERC20 合约创建交易所合约
    • 这是一次性操作。
  2. 增加流动性,第一次放入的流动性决定了交易所的兑换比率。
    • 任何人都提供流动性,成为 provider,但需按同样比例
  3. 兑换,分两种:
    • 直接兑换:进自己的账户
    • 兑换并转账:进第三方账户
  4. 当 provider 希望撤出时,可以移除流动性。

这几个合约之间的关系:

  • 工厂合约负责创建交易所合约。
  • 交易所合约与 ERC20 合约一一对应,其作用有两个:
    • 兑换 ETH 和 ERC20
    • 管理流动性

由此可知,在前面的步骤中,除了第一步是与工厂合约交互之外,其余的操作都是与新创建出来的交易所合约进行交互。

流动性挖矿

合约的接口可以看出:交易所合约本身也是 ERC20 合约,这是起什么作用呢?这涉及到一个概念:流动性挖矿。名字虽然起得高大上,但其实说白了就是:作为流动性提供者,可以收取交易过程的手续费。关于手续费:

  • ETH <-> ERC20,3bp(1bp = 1‰)
  • ERC20 <-> ERC20,6bp
    • 在 v1 中,这种兑换形式借助 ETH 来完成,相当于两次交易所合约调用。即 ERC20 <-> ETH <-> ERC20。

前面说过:任何人都可以成为流动性提供者,这时就需要一种技术手段来决定每次交易的手续费如何在这些流动性提供者中进行分配。将交易所合约实现为 ERC20 合约则是在这样一个背景下得出的技术选择:

  • 合约会在每位 provider 提供流动性时为他们分配一定份额的 token(具体分配公式没有必要了解,详见合约代码),这个 token 代表了未来能参与利益分配的权益。
  • provider 持有的 token 数与合约的总发行量之比则为其预期可得的利益(ETH 和合约负责兑换的 ERC20)

每次兑换的手续费并不会实时反映在 provider 的钱包中,而是记录在交易所合约中(以输入 * 997 bp 的形式体现,剩下的 3bp 就是交易费)。只有在 provider 移除流动性时,合约才会兑现这部分收益,退回等比例的(amount/totalSupply)ETH 和对应的 ERC20

看起来流动性挖矿是一门不错的生意,只要投入本金,接下来就可以坐地分成,有百利而无一害。

可事实是如此吗?这里面需要讲一讲 uniswap 的定价模型。

定价模型和 provider 的风险

uniswap 采用恒定乘积模型来确定兑换价格,对于每一个接触 uniswap 的来讲估计都已经听滥了。但这只是针对 uniswap 自身而言。在开放的市场,一般来讲不会只有一家交易所。虽然 uniswap 工厂合约限定了每个 ERC20 只能对应一个交易所合约,但是脱离于 uniswap 体系,还有其他交易所存在(不论去中心化的,还是中心化的)。

多个交易所存在的事实决定了价格存在差异,而这种差异则为投机者提供了套利空间,它们则是 provider 必须面对的风险。看一下例子演示。

为了简单起见,这里设定一些假设:

  • 只有一个 provider
  • 不考虑 3bp 的手续费

假定一个 provider 向 uniswap 交易所合约(ETH <-> A)内注入的流动性是:100 ETH : 100 A。此时,交易所 B 中,1 ETH = 10 A。显然,按照下面的方式进行操作就存在套利空间:

  • 在交易所 B 中,用 ETH 换 A
  • 在 uniswap 交易所合约中,用 A 换 ETH
  • 循环操作,直至两者兑换比例接近

具体演示计算:

  • 交易所 B,10 ETH -> 100 A
  • Uniswap 交易所合约:+100 A
    • 返 50 ETH(100 - 10000/200)
    • 此时两个交易池为:50 ETH : 200 A
  • 交易所 B,20 ETH -> 200 A
  • Uniswap 交易所合约:+200 A
    • 返 25 ETH(50 - 10000/400)
    • 此时两个交易池为:25 ETH : 400 A

两轮下来,即便 provider 是 100% 获得交易的手续费,但其是以损失投入的本金为代价的。以上为例,provider 前后资产的变化,为了方便比较,全部按交易所 B 的价格换算成 A 计:

  • 交易前:100 * 10 + 100 = 1100
  • 交易后:25 * 10 + 400 = 650

其损失是显然的。

从另一个方面来讲,这也说明放在 uniswap 交易所合约中的流动性有必要尽可能与公认的价格保持一致。

协议小结

那么最后小节一下,总的来讲,uniswap v1 协议涉及:

  • 3 种合约:工厂合约、交易所合约和 ERC20 合约。
  • 2 类角色:交易用户和流动性提供者。
  • 采用恒定乘积定价,当交易所合约内价格与外部交易所价格之间存在差异时,存在套利空间,作为流动性提供者有可能面临损失。

此外,其交易所合约基于 ERC20 设计针对于流动性提供者的激励也值得学习和借鉴。

使用 sdk

uniswap 提供了多种集成方式:

  • 直接 UI 集成,即将 app.uniswap.org 集成到你的应用。
  • 通过 SDK 开发接入。
  • 直接合约调用。

第一种方式最省事,但灵活度最低,它不是本文的重点,各位可以自己去看文档。有过以太坊开发经验的同学可能会觉得:既然 uniswap 的协议就是一组以太坊上的智能合约,那我直接去跟智能合约交互就好了,何必还要 sdk 呢,简直多此一举!

理论上,直接去调用合约完全没有问题。但在实际开发中,我不建议如此,反而建议通过 sdk 进行集成。原因在于:

  • 这其中涉及复杂的数学计算,并非随便传几个值给合约方法那么简单。若传的数字不对,合约内部验证会失败。使用 SDK 可以帮助我们省掉这部分工作量,将精力放在业务逻辑上。
  • 此外,SDK 还提供了方便的方法让我们可以随时掌握交易池内部状态、当前利率、滑点等等信息,避免了繁琐的“查询再计算”的步骤。

本节以 ethers.js + uniswap sdk 为例,给出主要代码片段的示例。请注意,由于 uniswap 目前版本已经升至 v2,如果要继续与 v1 交互的话,需要使用针对于 v1 的 sdk。这里列出相应的版本信息:

"dependencies": {
  ...,
  "@uniswap/sdk": "1.0.0-beta.4",
  "ethers": "^4.0.46"
}

公共代码

这里列出常用的公共代码段(但略去了 import)。

钱包:

const wallet = new ethers.Wallet(私钥, provider);

工厂合约

const uniswapFactory = new ethers.Contract(
  uniswap.FACTORY_ADDRESS[1], // 这里是主网地址,若是测试网,需更换
  uniswap.FACTORY_ABI,
  wallet
);

ERC20 合约和交易所合约代码同上,只需改变相应的地址和 abi 就行了。uniswap 提供了交易所合约的 abi:

  • uniswap.EXCHANGE_ABI

由于每个合约方法都有 deadline,这里也给出一个范例(注意它是一个函数):

// 15 分钟后过期
const deadline = () => {
  return Math.ceil(Date.now() / 1000) + 60 * 15;
};

部署

app.uniswap.org 上可以让用户自行创建交易所合约,但我个人建议采用代码来部署,因为你可以直接将“部署-首次流动性注入”一次完成。而且,自己可控。

创建 ERC20 对应的 uniswap 交易所

await uniswapFactory.createExchange(myCoinAddress);

获取 ERC20 对应的交易所地址

myExchangeAddress = await uniswapFactory.getExchange(myCoinAddress);

首次增加流动性

myExchange = new ethers.Contract(myExchangeAddress, uniswap.EXCHANGE_ABI, wallet);

// 当前用户需先允许交易所合约动用其 ERC20 合约内的 token
await myCoin.approve(myExchangeAddress, tokenAmount);

// 第一次注入流动性时,第一个参数为 0
await myExchange.addLiquidity(0, tokenAmount, deadline, {
  value: ethAmount,
});

兑换

获得交易所合约内部交易池数据

const tokenReserves = await uniswap.getTokenReserves(myCoinAddress, provider);
  • ETH:tokenReserves.ethReserve.amount
  • ERC20:tokenReserves.tokenReserve.amount

获得市场信息(包括兑换比例)

// undefined 表示 ETH -> ERC20
const marketDetails = await uniswap.getMarketDetails(undefined, tokenReserves);
  • “1 ETH -> N MyCoin: ”, marketDetails.marketRate.rate.toString()
  • “1 MyCoin -> N ETH: ”, marketDetails.marketRate.rateInverted.toString()

实际交易过程分三步进行:

  1. 获取交易数据
  2. 获得执行细节
  3. 调用合约方法

ETH -> ERC20

// 交易数据
let tradeDetails = await uniswap.tradeExactEthForTokens(myCoinAddress, ethAmount, provider);
// 执行细节
let executionDetails = await uniswap.getExecutionDetails(tradeDetails);
// 调用合约,注意以下方法参数都是 executionDetails
// 本例是兑换并转账,若直接兑换到自己账户,则换成 ethToTokenSwapInput
await myExchange.ethToTokenTransferInput(
  executionDetails.methodArguments[0],
  executionDetails.methodArguments[1],
  accounts[2],
  {
    gasLimit: 4000000,
    value: ethAmount,
  }
);

ERC20 -> ETH

// 交易数据
let tradeDetails = await uniswap.tradeExactTokensForEth(myCoinAddress, tokenAmount, provider);
// 执行细节
let executionDetails = await uniswap.getExecutionDetails(tradeDetails);
// 注意要先 approve
await myCoin.approve(myExchangeAddress, executionDetails.methodArguments[0]);
// 调用合约,注意以下方法参数都是 executionDetails
// 本例是兑换并转账,若直接兑换到自己账户,则换成 tokenToEthSwapInput
await myExchange.tokenToEthTransferInput(
  executionDetails.methodArguments[0],
  executionDetails.methodArguments[1],
  executionDetails.methodArguments[2],
  accounts[3]
);

流动性

流动性的市场数据:

  • 资金池
    • ETH:await web3.eth.getBalance(myExchangeAddress)
    • ERC20:await erc20Contract.balanceOf(myExchangeAddress)
  • 当前份额
    • 流动性通证:exchange.balanceOf(当前账户)
    • 份额:流动性通证 / exchange.totalSupply
    • ETH:上面的 ETH * 份额
    • ERC20:上面的 ERC20 * 份额

增加流动性:在第二次增加流动性时,myExchange.addLiquidity 的第一个参数不能为 0 ,并且 minEth 和 minToken 也需有限制,而 uniswap sdk 并没有提供相应的方法来计算这些。为此,仿造其 sdk 风格,也将此步换成两步:

  • 计算需要参数,为了方便使用,输入参数为 ETH,UI 时限定用户由 ETH 来推算。
// 根据 eth 算参数
const getAddLiquidityArgumentByEth = async (ethAmount) => {
  const totalSupply = await myExchange.totalSupply();
  const tokenReserves = await uniswap.getTokenReserves(myCoinAddress, provider);
  const marketDetails = await uniswap.getMarketDetails(undefined, tokenReserves);

  // 注意这里的加一/减一操作
  return {
    minLiquidity: ethAmount
      .mul(totalSupply)
      .div(bigNumberify(tokenReserves.ethReserve.amount))
      .sub("1"),
    maxTokens: ethAmount.mul(bigNumberify(marketDetails.marketRate.rate)).add("1"),
    deadline: deadline,
    value: ethAmount,
  };
};
  • 调用合约方法,注意需要先 approve
// 算参数
let arguments = await getAddLiquidityArgumentByEth(ethAmount);
// approve
await myCoin.approve(myExchangeAddress, arguments.maxTokens);
// 增加流动性
await myExchangeForAccount2.addLiquidity(
  arguments.minLiquidity,
  arguments.maxTokens,
  arguments.deadline,
  {
    value: arguments.value,
  }
);

移除流动性:同上的风格一样,但计算基准以用户所持有的流动性通证数量为基准。因为这样对于用户来讲也能很清楚自己还有多少权益。

  • 计算参数
const getRemoveLiquidityArgumentByAmount = async (amount) => {
  const totalSupply = await myExchange.totalSupply();
  const tokenReserves = await uniswap.getTokenReserves(myCoinAddress, provider);
  return {
    amount: amount,
    minEth: bigNumberify(tokenReserves.ethReserve.amount).mul(amount).div(totalSupply).sub("1"),
    minTokens: bigNumberify(tokenReserves.tokenReserve.amount)
      .mul(amount)
      .div(totalSupply)
      .sub("1"),
    deadline: deadline,
  };
};
  • 调用合约
arguments = await getRemoveLiquidityArgumentByAmount(liquidityAmount);
await myExchangeForAccount2.removeLiquidity(
  arguments.amount,
  arguments.minEth,
  arguments.minTokens,
  deadline
);

开发小节

至此,关键代码段示例基本都有了。这里总结一下:

  • 优先使用 sdk 进行集成。
  • 交易方法的风格:先计算参数,再调用合约方法。
  • 凡事涉及到 ERC20 token 从用户账户转入交易所合约的,都需要先进行 approve。
  • 注意单位的转换,以上代码示例中把此步给省略了。

最后,还需提醒用户,由于交易过程中兑换比例是实时变化的,这里计算得到的值只是预估值,实际成交需要依据实际情况来定。

这里也暗示着不要缓存类似兑换比率这样的信息,因为这个值时刻变化,保存旧的值只会算出来错误的参数,最终导致合约执行失败。

基于 ganache 的自动化测试

以太坊开发者应该都对 ganache 不陌生,只要你做自动化测试,基本上都离不开它。 但这里的情形有些特别:有一部分交互的合约并不是我们开发的,而是直接部署在主网上的。此时,该如何跟我们的代码进行自动化测试呢?直接连主网上的合约来交互显然不是一个有效地选择!

一种朴素而直观的想法可能是:把依赖合约代码拿过来,直接部署在 ganache 环境中,反正合约源码都是公开的嘛。对不起,想法太天真了:

  • 对于简单合约还好说,但假若第三方合约本身也有依赖怎么办?难道一起都部署过来?!
  • 就算合约都部署了,那像 uniswap 这种我们要通过 sdk 做集成的怎么办?再把 sdk 源码下载下来重新改一下?!
  • 即使都可行,但等到要跟真正的合约进行交互时,还得重新更改环境和依赖 …… 头大!

那直连测试网测试怎么样?呃,这种想法离题了!

—fork

其实,ganache 已经为我们提供了解决方案,使用 —fork 选项就能实现从主网 fork,从而在测试环境模拟主网。废话不多说了,直接看启动脚本吧:

ganache-cli -i 1 -f "你的 infura url"  -m "super elite small measure crew liar oil rigid legend adult episode cluster"

这里说明一下各个参数的含义:

  • -f,从 infura url fork 出来,因此如果你的 url 指向的是主网,那就是从主网 fork。
  • -m,明确指定助记词,以便生成的 account 始终不变,因为测试中需要私钥(上面的代码示例中需要用私有创建钱包)
  • -i,需要明确指出,否则从当前 web3 的 provider 无法创建 ethers 的 Web3Provider,会始终报找不见 network_id

同时,每 30 分钟之后重启一次 ganache,否则测试运行时会报错。这是目前免费 infura api 的限制。

truffle 相关

相应的,假如你用的是 truffle 的话,网络配置如下:

test: {
    skipDryRun: true,
    host: "127.0.0.1",
    port: 8545,
    network_id: "*",
},

同时,测试代码中获得 ethers.js 的 provider,采用以下代码:

const provider = new ethers.providers.Web3Provider(web3.currentProvider);

至于创建钱包所需的私钥,可直接从 ganache 的输出中选择复制,由于已经固定了启动的助记词,每次生成的私钥也都是固定的。

最后需要注意的地方就是 uniswap sdk 中 provider 在这里需要明确指定为上面创建的 provider。因为缺省 uniswap sdk 中 provider 会指向主网,但其实目前交易所合约只会创建于咱们新启动的 ganache 环境里。

剩下的就属于常规操作了,结合上一节中的代码基本不会有问题。

自动化测试小节

ganache fork 是一个强大的选项,不仅仅只限于本文中 uniswap 开发相关的测试中。采用本文中介绍的方法,可以很好地跟现有的 truffle 开发流程结合起来,也能跟现有的 truffle 测试代码的书写很好地结合,没有什么大的差异。

总的来讲,这种方式是目前开发摩擦最小的方法。

公式说明

最后简单说一下相关的公式。

恒定乘积模型

uniswap v1 采用恒定乘积模型来确定当前的兑换率,整个过程如下图:

恒定乘积模型

新一轮的价格则基于新的 ETH * OMG 来计算。并且,由于在实际过程中要收取 3bp 的手续费,因此实际参与计算时会去掉它,即上图中实际为 0.997。

价格计算

存在两种计价方式:

  • 给定输入,最小的输出( x 增加,y 减少)
    • 设:输入 x1
    • 则为保持乘积不变,需要改变的 y 计算如下:
      • y - (x * y) / (x + x1) = (y * x1) / (x + x1)
      • 由于要收 3bp 手续费,故将 x1 变换成 x1 * 997 bp 后替代 x1 。
  • 给定输出,最大的输入( y 增加,x 减少)
    • 设:输出 y1
    • 同理,则需要变化的 x 如下
      • (x * y) / (y - y1) - x = (x * y1) / (y - y1)
      • 由于 3bp 的手续费,故买入的 x 需要在乘以 1000/997

上面的代码示例使用的第一种方式,这也是比较自然的方式,由输入决定价格。

滑点

  • 滑点 = abs(新利率 - 旧利率) / 旧利率
  • getTradeDetails 会返回两个滑点:
    • 市场利率滑点:取两次市场利率,然后按以上公式计算。
      • 其中,旧市场利率为方法传入的参数值;在方法内会再次调用一次。
    • 执行利率滑点
      • 旧利率,传入的市场利率;新利率,执行利率

利率计算即两种 token 的比值。

验证条件

合约方法会对参数进行验证,从其接口参数命名中可以看出其验证规律:

  • min 开头的参数,在合约中计算得到的相关结果必须要大于传入值
  • max 开头的参数,在合约中计算得到的相关结果必须要小于传入值

之所以这样,也是因为滑点的存在导致的。这是刚上手进行 uniswap 开发最容易忽略和理解出错的地方,需注意。

写在最后

总算到了结束的时候!读完全文,相信你已经对 uniswap 有了深入的认识,开发对于你而言也不在是障碍。那剩下的就是在必要时翻翻合约代码和 sdk 代码,再细细品味它了。

付费内容

本文包含付费内容,需要会员权限才能查看完整内容。