老胡茶室
老胡茶室

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

胡键

uniswap v2 已经发布,虽然 v1 版依然可供人使用,但官方页面已经不再支持 v1 的 pool liquidity 注入(当然,你还是可以自己编程调用合约完成),并且提供了从 v1 pool 向 v2 pool 进行迁移的按钮。同时,项目团队的重点也转移至后续版本,对于 v1 的更新也不再那么积极。

显然,作为开发者,了解最新的 v2 版是必要的,这也是本文出现的初衷:帮助你快速了解 v2 ,同时提供实际的代码示例,而不只是面向非开发者的那种泛泛而谈的介绍性文章。

从本文中,你将看到:

  • uiniswap v2 主要的协议变化。
  • 最新的合约架构。
  • 还有实际可运行的代码示例。

但文章的篇幅是有限的,不可能包罗万象。因此:

  • 虽然 uniswap v2 引入了元交易,但本文不是一篇介绍元交易的文章,故虽然你可以看到元交易的代码示例,但是关于元交易本身的细节并不是本文的重点。
  • 由于预言机本身也是一个范围广泛的主题,并且 uniswap 自身的文档也提到未来将补充面向预言机开发者的文档,因此本文也不会涵盖这部分内容。并且,从常规开发来讲,这部分内容的缺失并不会阻碍 uniswap 在项目中采用。

或许,在不远的未来,关于元交易和预言机的内容也会出现在这个系列中哟。

协议的主要变化

从大的方面讲,v2 版的主体协议仍然是:恒定乘积定价、工厂合约和交易所合约、兑换和流动性等。但毕竟是一次大版本升级,一些重要的功能更新还是有的。

原生支持 ERC20 对

原生支持 ERC20 对之间的兑换可以说是 v2 版中最重要的变化,为何这样说呢?最主要原因就在于:降低了手续费。在 v1 中,要实现这样的目的需要花费 2 倍的手续费,因为需要借助 ETH 进行中转。即:A 到 B 之间的兑换实际上是以下两步:

  • A -> ETH
  • ETH -> B

原生支持 ERC20 对之间的兑换后,以上操作就没有必要了,手续费降低是自然的结果。但这一变化也带来了一个后果:不再支持原生的 ETH,取而代之的是 WETH。这暗示着:

  • uniswap v2 的使用者需要先将 ETH 换成 WETH。
  • 在使用 ETH 的场景中,开发者用 WETH 来替代。

是不是感觉将使用和开发门槛都提高了?这也是我一开始接触 uniswap v2 时的感觉,仔细阅读文档之后,发现官方其实已经对此提供了解决之道,使用体验和开发方式完全兼容前一个版本,关于细节会在后面说明。

FlashSwap

FlashSwap 是本次版本升级的另一个亮点。或许刚开始看到这个单词时会让你有点摸不着头脑,但假如换成另一个词,相信你会立刻领会其中的含义:小额贷款

即,用户可以从 uniswap 中借出一定数量的 token(就算该用户没有给 uniswap 增加过 liquidity 都行,也就说无门槛借钱!),在规定期限内连本带息归还 token 即可。只不过这个期限是限制在整个方法的执行期内:第一条执行语句和最后一条执行语句之间。

uniswap 会检查在此期限内用户是否偿还贷款,若没有,则借助以太坊的事务性特点,将整个交易回滚,就当一切没有发生过。

这一切使得以下操作成为可能:

  1. 调用合约,从 uniswap 借出 x 数量的 token。
  2. 利用这些 token 完成其他套利操作,当然同样是借助程序的力量。
  3. 归还 y 数量的 token。
  4. 调用返回。

是不是有了点算法交易的感觉?关于 FlashSwap 的细节参见后文。

元交易

所谓“元交易”其实是利用签名由第三方代自己完成交易,这样的好处有二:

  • 降低了用户使用以太坊的门槛,即使没有 ETH 也能借助第三方完成交易。
  • 为新的应用场景打开了大门,比如,利用 ERC20 完成某些需要 ETH 才能完成的操作。

至于第三方为何愿意帮你来做这些事情(别忘了,这其中的交易费也自然是由第三方承担),原因也可以归纳为:第三方可从中获得好处,不论这个好处是你俩之间的协定(线上或线下),还是你本身就是第三方的客户(这样他当然就有理由降低使用门槛)。

看上去是不是有点复杂?但其实 uniswap v2 中的元交易应用场景很简单:省掉移除 liquidity 时的授权,仅此而已。

熟悉 v1 的同学可能会对移除 liquidity 时还需要授权感到奇怪,这就涉及到了 v2 的合约架构调整,请参见后文。

价格预言机

在 v2 中引入预言机的目的是为了更好的应对价格操控,通过在区块内保存历史价格信息来实现这一点。但诚如前文所言,预言机本身是一个大话题,并且常规操作其实涉及到它的地方并不多,因此本文对于这方面的内容就此略过。

小节

相比 v1 版而言,v2 版的协议有了很大的进化,支持的功能和场景也更丰富。尤其是 FlashSwap 的引入,使得 uniswap 不再只是一个简单的去中心化交易所,而且还充当了类似银行的角色。

关于协议交互的整体流程,以及作为流动性提供者的风险,由于前一篇已经给出了比较详细的介绍,这里不再重复,有兴趣的同学可以前去访问。

新的合约架构

从事过 v1 开发的同学对于 uniswap v1 的合约架构不会陌生:

  • 涉及三类合约:工厂合约、交易所合约和 ERC20 合约。
  • 应用通过与交易所合约交互完成业务功能。

在 v2 中,合约架构重新做了调整,分成两层:

  • 核心层(core)
    • 工厂合约,同 v1 。
    • 交易对合约,相当于 v1 中的交易所合约,但不再建议用户直接调用它了。
  • 外围(periphery)
    • 工具库,分为两类:面向预言机和面向常规使用
    • Router,应用主要交互对象,包括 v1 中类似功能以及新版本中新增功能。
    • Migrator,负责将 v1 的 liquidity pool 迁移到 v2

简单讲,v1 中交易所合约的职责在 v2 中被分拆成了两部分:

  • 交易对合约,管理流动性
  • Router,负责兑换和增减 liquidity,应用通过该合约完成与 uniswap 的交互。
    • 由于这种分拆,在移除 liquidity 时,需要交易对合约授权 Router,这样后者才能动用对应的 liquidity token。

这种分拆带来了几个好处:

  • 核心逻辑更简单,也更稳定,方便被其他人复用。比如,假若你对 uniswap v2 提供的 router 不满意,完全可以自行实现一个。
  • 这是典型的“数据 + 业务”架构,对升级更友好。相对而言,数据层面的需求变化很少,而业务层面的需求变化会比较大,并且这种架构也方便在业务逻辑出现 bug 时迅速反应,及时更新业务代码。事实上,当前的 router 就有两个版本,最新的 router 恰恰是对前一个版本的修复。
  • 更灵活的交互方式。

对于最后一点,这里解释一下。前面说过,v2 版本中不再直接支持 ETH,取而代之是使用 WETH。从技术层面来讲,这种变化固然带来了好处,但是增加了使用门槛和开发门槛。

为了取得与 v1 类似的体验,Router 发挥了重要作用。对外,用户只需要对它取存 ETH 即可;对内,它负责将 ETH 转成交易对合约所需的 WETH。于是,通过这种“ extra level of indirection ”,实现了鱼和熊掌的兼得。

主要使用场景的代码示例

同样的,uniswap v2 的使用与前一个版本大同小异,都是 sdk 和合约一起结合完成功能:前者负责计算,后者负责执行。

依赖

文中代码示例依赖于以下依赖:

"dependencies": {
    ……,
    "@uniswap/sdk": "3.0.2",
    "@uniswap/v2-periphery": "1.1.0-beta.0",
    "ethereumjs-util": "7.0.4",
    "ethers": "5.0.8"
},

注意:

  • uniswap v2 必须依赖 ethers 5,假如你自己也要用的话,就请使用该版本。
  • ethereumjs-util 将在元交易的使用场景里用到,用于对消息进行签名。

公共代码

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

钱包:

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

工厂合约

const uniswapFactory = new ethers.Contract(uniswap.FACTORY_ADDRESS, IUniswapV2Factory.abi, wallet);

Router

const ROUTE02_ADDRESS = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D";
const route02 = new ethers.Contract(ROUTE02_ADDRESS, IUniswapV2Router02.abi, wallet);

交易对合约和 ERC20 合约对象创建代码类似,只需更换地址和 abi 即可。对于交易对的 abi:

  • IUniswapV2Pair.abi

WETH

const WETH = uniswap.WETH[uniswap.ChainId.MAINNET];

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

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

部署

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

创建交易对

// erc20 <-> erc20 pair
await uniswapFactory.createPair(myCoin1.address, myCoin2.address);
// eth <-> erc20 pair,注意使用了 WETH
await uniswapFactory.createPair(WETH.address, myCoin1.address);

获取交易对地址

// erc20 <-> erc20 pair
pairAddressForErc20ToErc20 = await uniswapFactory.getPair(myCoin1.address, myCoin2.address);
// eth <-> erc20 pair
pairAddressForETHToErc20 = await uniswapFactory.getPair(myCoin1.address, WETH.address);

首次增加流动性

await myCoin1.approve(ROUTE02_ADDRESS, tokenAmount1);
await myCoin2.approve(ROUTE02_ADDRESS, tokenAmount2);
await route02.addLiquidity(
  myCoin1.address,
  myCoin2.address,
  tokenAmount1,
  tokenAmount2,
  0,
  0,
  accounts[0],
  deadline(),
  {
    gasLimit: 4000000,
  }
);

对于 ETH <-> ERC20 交易对来讲,代码类似,只是替换成后缀为 ETH 的函数即可,并且只需要一次 approve 就行。

兑换

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

const pair = await uniswap.Fetcher.fetchPairData(token1, token2, provider);

这里的 token 对象是 uniswap sdk 中的内部对象,其创建方式对应以下代码:

new uniswap.Token(uniswap.ChainId.MAINNET, myCoin1.address, 18);

交易池的数据通过 pair.tokenAmounts 获得,但请注意:交易对中内部两个 token 的顺序并不是与传入的 token 顺序一致。以上例来讲,虽然先传 token1 再传 token2 ,但内部的第一个 token 和第二个 token 未必是如此。因此,在实际时需要自行判断比对。

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

const route = new uniswap.Route([pair], token1);
console.log("Midprice 1 token1 -> N token2: ", route.midPrice.toSignificant(6));
console.log("Midprice 1 token2 -> N token1: ", route.midPrice.invert().toSignificant(6));

v2 的交易过程与 v1 的差别不大:

  1. 获取市场数据
  2. 获得执行价格以及接受的最低价格
  3. 调用合约方法
const pair = await uniswap.Fetcher.fetchPairData(token1, token2, provider);
// 市场数据
const route = new uniswap.Route([pair], token1);
// 执行价格
const trade = new uniswap.Trade(route, tokenAmount, uniswap.TradeType.EXACT_INPUT);
// 可接受的最低价格
const slippageTolerance = new uniswap.Percent("50", "10000");
const amountOutMin = trade.minimumAmountOut(slippageTolerance).raw.toString();
// 实际交易
await route02.swapExactTokensForTokens(
  trade.inputAmount.raw.toString(),
  amountOutMin,
  [token1.address, token2.address],
  accounts[2],
  deadline()
);

流动性

前文类似,对于流动性的增减,同样提供了两个函数来负责计算实际调用合约函数时需要传入的参数。

增加 liquidity

// 增加流动性时所需各个参数,注意这里对于 token 的判断
// tokenAmount 对应 token1 要添加的数量,这里计算出对应需要 token2 的数量
const getAddLiquidityArgument = async (token1, token2, tokenAmount) => {
  const pairData = await uniswap.Fetcher.fetchPairData(token1, token2, provider);
  const tokenReserves = pairData.tokenAmounts;
  let tokenAmountB;
  // 由于在交易对合约中 token1 和 token2 的位置并不固定,故需要显式判断
  if (tokenReserves[0].token.equals(tokenAmount.token)) {
    const amountB = await route02.quote(
      tokenAmount.raw.toString(),
      tokenReserves[0].raw.toString(),
      tokenReserves[1].raw.toString()
    );
    tokenAmountB = new uniswap.TokenAmount(tokenReserves[1].token, amountB.toString());
  } else {
    const amountB = await route02.quote(
      tokenAmount.raw.toString(),
      tokenReserves[1].raw.toString(),
      tokenReserves[0].raw.toString()
    );
    tokenAmountB = new uniswap.TokenAmount(tokenReserves[0].token, amountB.toString());
  }

  const tolerance = new uniswap.Percent("50", "10000");
  const amountAMin = new uniswap.TokenAmount(
    tokenAmount.token,
    new uniswap.Fraction(uniswap.JSBI.BigInt(1))
      .add(tolerance)
      .invert()
      .multiply(tokenAmount.raw).quotient
  );
  const amountBMin = new uniswap.TokenAmount(
    tokenAmountB.token,
    new uniswap.Fraction(uniswap.JSBI.BigInt(1))
      .add(tolerance)
      .invert()
      .multiply(tokenAmountB.raw).quotient
  );

  return {
    tokenA: token1.address,
    tokenB: token2.address,
    amountADesired: bigNumberify(tokenAmount.raw),
    amountBDesired: bigNumberify(tokenAmountB.raw),
    amountAMin: bigNumberify(amountAMin.raw),
    amountBMin: bigNumberify(amountBMin.raw),
    deadline: deadline(),
  };
};

这个函数的实际使用:

let amountForErc20ToErc20 = await getAddLiquidityArgument(token1, token2, tokenAmount);
await myCoin1.approve(ROUTE02_ADDRESS, amountForErc20ToErc20.amountADesired);
await myCoin2.approve(ROUTE02_ADDRESS, amountForErc20ToErc20.amountBDesired);
await route02.addLiquidity(
  amountForErc20ToErc20.tokenA,
  amountForErc20ToErc20.tokenB,
  amountForErc20ToErc20.amountADesired,
  amountForErc20ToErc20.amountBDesired,
  amountForErc20ToErc20.amountAMin,
  amountForErc20ToErc20.amountBMin,
  accounts[0],
  amountForErc20ToErc20.deadline,
  {
    gasLimit: 4000000,
  }
);

移除流动性,对应的参数计算函数如下。

// 基于要移除的 liquidity 数量算出对应的 token1 和 token2 的数量
const getRemoveLiquidityArgumentByLiquidityAmount = async (token1, token2, liquidityAmount) => {
  const pairContract = getPairContract(token1, token2);
  const totalSupply = await pairContract.totalSupply();
  const pair = await uniswap.Fetcher.fetchPairData(token1, token2, provider);
  const tokenReserves = pair.tokenAmounts;

  return {
    tokenA: tokenReserves[0].token.address,
    tokenB: tokenReserves[1].token.address,
    liquidity: liquidityAmount,
    amountAMin: liquidityAmount.mul(bigNumberify(tokenReserves[0].raw)).div(totalSupply),
    amountBMin: liquidityAmount.mul(bigNumberify(tokenReserves[1].raw)).div(totalSupply),
    deadline: deadline(),
  };
};

实际使用算出相应参数并调用合约

amountForErc20ToErc20 = await getRemoveLiquidityArgumentByLiquidityAmount(token2, token1, amount);
// 这个细节是和 v1 最大不同。因为 v1 中实际完成移除的就是交易所合约自己,而 liquidity token 本身就属于它。
// 在 v2 中,完成此动作的是 Router,因此需要交易对合约对其授权,允许其使用自己的 liquidity token。
await pairContractForErc20ToErc20.approve(ROUTE02_ADDRESS, amount);
await route02.removeLiquidity(
  amountForErc20ToErc20.tokenA,
  amountForErc20ToErc20.tokenB,
  amountForErc20ToErc20.liquidity,
  amountForErc20ToErc20.amountAMin,
  amountForErc20ToErc20.amountBMin,
  accounts[0],
  amountForErc20ToErc20.deadline,
  {
    gasLimit: 4000000,
  }
);

元交易

前面说过,uniswap v2 的元交易主要用在省掉 approve 这个环节,即在移除 liquidity 时不需要 pair 对 router 进行显式授权。这种魔法是通过两个步骤完成的:签名和调用后缀为 permit 的移除 liquidity 方法。

首先看一下获得签名的代码:

const approvalDigest = (pairAddress, owner, spender, value, nonce, deadline) => {
  // 见文档,permit 的函数签名
  const permitTypehash = ethers.utils.keccak256(
    ethers.utils.toUtf8Bytes(
      "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
    )
  );

  // 同样来自于文档
  const domainSeparator = ethers.utils.keccak256(
    ethers.utils.defaultAbiCoder.encode(
      ["bytes32", "bytes32", "bytes32", "uint256", "address"],
      [
        ethers.utils.keccak256(
          ethers.utils.toUtf8Bytes(
            "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
          )
        ),
        ethers.utils.keccak256(ethers.utils.toUtf8Bytes("Uniswap V2")),
        ethers.utils.keccak256(ethers.utils.toUtf8Bytes("1")),
        1,
        pairAddress,
      ]
    )
  );

  return ethers.utils.keccak256(
    ethers.utils.solidityPack(
      ["bytes1", "bytes1", "bytes32", "bytes32"],
      [
        "0x19",
        "0x01",
        domainSeparator,
        ethers.utils.keccak256(
          ethers.utils.defaultAbiCoder.encode(
            ["bytes32", "address", "address", "uint256", "uint256", "uint256"],
            [permitTypehash, owner, spender, value, nonce, deadline]
          )
        ),
      ]
    )
  );
};

// pair 合约对于此账户的 nonce
const nonce = await pairContractForErc20ToErc20.nonces(wallet.address);
const digest = approvalDigest(
  pairContractForErc20ToErc20.address,
  wallet.address,
  ROUTE02_ADDRESS,
  amount, // 此数量要与实际 removeLiquidityWithPermit 中相等
  nonce,
  deadline()
);

// remove liquidity 函数中需要这三个值
const { v, r, s } = ethereumjsUtil.ecsign(
  Buffer.from(digest.slice(2), "hex"),
  Buffer.from(wallet.privateKey.slice(2), "hex")
);

最后就是使用 permit 函数完成实际移除操作:

amountForErc20ToErc20 = await getRemoveLiquidityArgumentByLiquidityAmount(token2, token1, amount);
await route02.removeLiquidityWithPermit(
  amountForErc20ToErc20.tokenA,
  amountForErc20ToErc20.tokenB,
  amount,
  amountForErc20ToErc20.amountAMin,
  amountForErc20ToErc20.amountBMin,
  wallet.address,
  deadline(),
  false,
  v,
  r,
  s,
  {
    gasLimit: 4000000,
  }
);

由于本文不是介绍元交易的文章,故上面的代码不再详细解释,一般使用的话只需简单照着上面来就行。并且,个人以为若只是为了省掉一步 approve 弄这么麻烦,其实完全没有必要。这里的代码只是让各位对于元交易一个大致感觉。

FlashSwap

FlashSwap 听上去感觉很复杂,但实际使用起来并不麻烦。这里假设一个场景:

  1. 某个 account 并没有在 uniswap 中存入任何资金。
  2. 但是他知道调用某个合约(GoodRepay)可以获得传入资金两倍的返还。

要实现 FlashSwap,只需两步:实现一个合约(假设是 flashswap ),接下来调用交易对合约的 swap 方法。请注意,这里与之前的例子有一处很大的不同:直接与交易对合约进行交互,而不在通过 Router。

在看实际代码示例之前,需要先解释一下 FlashSwap 的实现过程。

前面已经说过,在整个过程中,uniswap 会先借出 token,并在函数执行完毕之后检查是否收回足够数量的 token。若满足,则交易成立;反之,则回滚整个交易过程。

借出 token 的时机在用户调用交易对合约 swap 方法的时候被触发。这个方法签名如下:

function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data);

其中:

  • amount0Out 和 amount1Out,对应 uniswap 借出的金额。

    • 提醒:不要假设 amount(0/1)Out 就是 token(0/1),它由你指定。
  • to 对应借出方,相应的,uniswap 也会从这个地址将 token 收回。

  • calldata 起到两个作用:

    • 若长度大于 1,则表明是 flashswap;否则就是普通的 swap(此时,请使用 Router)。
    • 向 to 传入的额外参数。

归还 token 在 swap 结束前进行,它通过回调函数在 to 地址上完成,其中 to 需要实现以下接口:

interface IUniswapV2Callee {
    function uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data) external;
}

其中:

  • sender 对应于交易对合约的调用方
    • 此时的 msg.sender 是交易对合约
  • amount(0/1)和 calldata ,对应 swap 的 amount(0/1)Out 和 calldata

于是,聪明如你应该能够猜到 FlashSwap 的实现机制:

  1. 用户调用交易对合约的 swap 方法,传入合适的参数,并且保证此时的 calldata 长度大于 1。
  2. uniswap 调用 to 的 uniswapV2Call,通过 IUniswapV2Callee(to).uniswapV2Call 方式。
  3. to 是一个类型为 IUniswapV2Callee 的智能合约,用户在此获得相应的资金,完成套利,并最后返还。
  4. uniswap 检查返还金额,达标则确认交易;否则,revert 。

那么,接下来看一下实际的例子。首先是合约(假设名字为 flashswap.sol ):

...
function uniswapV2Call(
    address sender,
    uint256 amount0,
    uint256 amount1,
    bytes calldata data
) external {
    // 常规检查,直接照抄就行
    address token0 = IUniswapV2Pair(msg.sender).token0();
    address token1 = IUniswapV2Pair(msg.sender).token1();
    assert(msg.sender == IUniswapV2Factory(_factoryV2).getPair(token0, token1));

    // 确保是我们自己的 token,以及只看一个值
    assert(token0 == _myCoin1 || token1 == _myCoin1);
    assert(amount0 > 0 || amount1 > 0);

    uint256 amount = amount0 > 0 ? amount0 : amount1;
    // 套利,返回 2 * amount
    // GoodPay 是示例合约,repay 方法返回输入参数的 2 倍
    // 在实际中可能是第三方合约
    uint256 payment = GoodRepay(_goodRepay).repay(amount);
    // 计算要返还给 uniswap 的 token 数
    // 按照文档所言,返还的 token 数应该满足:大于等于【借出 / 0.997】
    // 注意这里的处理方式。之所以用 996 是发现若用 997 将无法满足条件,应该是精度问题。
    uint256 returned = amount.mul(1000).div(996);
    assert(payment > returned);

    // 完成实际转账
    IERC20 erc20 = IERC20(_myCoin1);
    assert(erc20.transfer(msg.sender, returned));
    assert(erc20.transfer(sender, payment - returned));
}

然后是实际的 FlashSwap 交易:

const pairContract = new ethers.Contract(
  pairAddressForErc20ToErc20,
  IUniswapV2Pair.abi,
  // 这个账户没有任何 mycoin1,因此也不会往 uniswap 中预存
  new ethers.Wallet(私钥, provider)
);

await pairContract.swap(
  amount,
  0,
  // 借出方合约,见上
  flashswap.address,
  // 这个例子中没有用到它,因此就是简单编码满足长度大于 1 的条件
  ethers.utils.defaultAbiCoder.encode(["uint"], [bigNumberify(1)]),
  {
    gasLimit: 4000000,
  }
);

小节

至此,主要典型使用场景和代码示例都已经给出了,除了元交易和 FlashSwap ,其余的代码与 v1 大同小异。对于之前有过 v1 开发经验的同学来讲,转换思路并不难。

至于测试,这部分处理与前文相比并没有什么变化。

写在最后

终于,长文结束了。希望通过本文,你可以了解到 uniswap v2 的变化和新的使用场景。同时,文中主要场景的代码示例也能在日后的开发过程中对你有所帮助。

付费内容

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