Defi编程——Uniswap V1(2)

519 阅读6分钟

计算价格

接着考虑如何计算兑换的价格。

通常会想到按照以下公式计算价格:

Pe=交易对中token的数量交易对中eth的数量P_e = \frac{交易对中token的数量}{交易对中eth的数量}

或:

Pt=交易对中eth的数量交易对中token的数量P_t = \frac{交易对中eth的数量}{交易对中token的数量}

尝试在Exchange合约中使用上面的公式编写计算价格的方法,代码如下:

function getPrice(
  uint256 inputCurrencyBalance, // 要卖的代币在交易对中的余额
  uint256 outputCurrencyBalance // 要买的代币在交易对中的余额
) public pure returns (uint256) {
  require(
    inputCurrencyBalance > 0 && outputCurrencyBalance > 0,
    "invalid balance"
  );

  return inputCurrencyBalance / outputCurrencyBalance;
}

接着测试这个方法:

describe("getPrice", async () => {
  it("returns correct prices", async () => {
    const { token, exchange, liquidityProvider } = await loadFixture(
      deploymentFixture
    );

    await token.approve(exchange.address, toWei(2000));
    await exchange.addLiquidity(toWei(2000), { value: toWei(1000) });

    const tokenReserve = await exchange.getTokenBalance();
    const etherReserve = await getEthBalance(exchange.address);

    // ETH per token
    expect(
      (await exchange.getPrice(etherReserve, tokenReserve)).toString()
    ).to.eq("0.5");

    // token per ETH
    expect(await exchange.getPrice(tokenReserve, etherReserve)).to.eq(
      2
    );
  });
})

在上述测试代码中,我们添加了2000代币和1000ETH的流动性,然后我们期望:代币价格是0.5 ETH/代币,ETH价格是2代币/ETH。

但测试会失败,原因是 Solidity 仅支持整数除法,浮点数结果会舍弃掉小数位。因此上面的测试代码中价格0.5会被舍入为0。对此我们可以通过提高精度的方式解决。

更新getPrice方法代码如下:

function getPrice(uint256 inputReserve, uint256 outputReserve) public pure returns (uint256) {
  // ...
  return (inputReserve * 1000) / outputReserve;
}

更新测试代码如下:

// ETH per token
expect(await exchange.getPrice(etherReserve, tokenReserve)).to.eq(500);

// token per ETH
expect(await exchange.getPrice(tokenReserve, etherReserve)).to.eq(2000);

再次测试,测试通过。

修改价格计算公式

假设我们支付2000个token,可以换完池子内的所有ETH。这是我们不乐见的,我们不希望池子中某一种币被掏空导致兑换该币的交易无法进行。

这是因为当前的价格函数是一次函数,其中y和x是交易对中ETH和token的余额,代表的币种互换亦成立。这样的价格函数允许其中一种币的余额到0。

前面我们说过Uniswap的核心原理是一条恒定乘积的公式:

xy=kx*y=k

下面我们使用看看这条公式进行价格计算的效果。

由于无论如何变化,交易对中两种代币的乘积始终为常数,可得下式,其中Δx\Delta xΔx\Delta x分别为池子中token余额的改变量和ETH余额的改变量,代表的币种互换亦成立,均为正数。

(x+Δx)(yΔy)=k(x+\Delta x)(y-\Delta y)=k

与上一个式子合并可得下式。

(x+Δx)(yΔy)=k(x+\Delta x)(y-\Delta y)=k

再由上式变形可得:

Δy=Δxyx+y\Delta y = \Delta x\cdot \frac{y}{x+y}

这样我们就得到了一个由交易前的token余额xx、交易前的ETH余额yy、准备支付的token数量Δx\Delta x计算得出可以收到的ETH数量Δy\Delta y的公式(代表的币种互换亦成立)。

下面我们编码实现基于该公式计算兑换价格。

Exchange合约中添加getOutput方法获取能够兑换到的币的数量,代码如下:

/**
  * 获取能够兑换到的币的数量
  * @param inputAmount 进入池子的代币的数量,即支付的代币的数量
  * @param inputCurrencyBalance 进入池子的代币在池子中的余额,即支付的代币在池子中的余额
  * @param outputCurrencyBalance 从池子出去的代币在池子中的余额,即收到的代币在池子中的余额
  */
function getOutput(
  uint256 inputAmount,
  uint256 inputCurrencyBalance,
  uint256 outputCurrencyBalance
) private pure returns (uint256) {
  require(
    inputCurrencyBalance > 0 && outputCurrencyBalance > 0,
    "invalid balance"
  );

  return
  (inputAmount * outputCurrencyBalance) /
  (inputCurrencyBalance + inputAmount);
}

这是一个底层方法,所以我们给它加上private关键字。

下面分别编写获取换得的ETH数量和换得的token数量的方法,代码如下:

/**
  * 获取支付ETH能够购买到的token的数量
  * @param _ethToPay 准备支付的ETH的数量
  */
function getTokenOutput(uint256 _ethToPay) public view returns (uint256) {
  require(_ethToPay > 0, "_ethToPay is too small");

  uint256 tokenBalance = getTokenBalance();
  uint256 ethBalance = address(this).balance;

  return getOutput(_ethToPay, ethBalance, tokenBalance);
}

/**
  * 获取支付token能够购买到的ETH的数量
  * @param _tokenToPay 准备支付的token的数量
  */
function getEthOutput(uint256 _tokenToPay) public view returns (uint256) {
  require(_tokenToPay > 0, "tokenToPay is too small");

  uint256 tokenBalance = getTokenBalance();
  uint256 ethBalance = address(this).balance;

  return getOutput(_tokenToPay, tokenBalance, ethBalance);
}

测试

接着编写上面两个方法的测试代码,测试是否能够正确地获取换得的代币数量。

先增加一个添加流动性的fixture,代码如下:

const addLiquidityFixture = async () => {
  const {
    tokenFactory,
    token,
    exchangeFactory,
    exchange,
    liquidityProvider,
  } = await deploymentFixture();

  await token.approve(exchange.address, toWei(2000));
  await exchange.addLiquidity(toWei(2000), { value: toWei(1000) });

  return {
    tokenFactory,
    token,
    exchangeFactory,
    exchange,
    liquidityProvider,
  };
};

接着编写支付1个ETH和支付2个token的测试代码,代码如下:

describe("getTokenOutput", async () => {
  it("returns correct token output", async () => {
    const { exchange } = await loadFixture(addLiquidityFixture);

    let tokenOutput = await exchange.getTokenOutput(toWei(1));
    expect(fromWei(tokenOutput)).to.equal("1.998001998001998001");
  });
});

describe("getEthOutput", async () => {
  it("returns correct eth output", async () => {
    const { exchange } = await loadFixture(addLiquidityFixture);

    let ethOutput = await exchange.getEthOutput(toWei(2));
    expect(fromWei(ethOutput)).to.equal("0.999000999000999");
  });
});

执行yarn hardhat test命令允许测试,顺利的话能看到以下提示:

  Exchange
    addLiquidity
      ✔ adds liquidity (366ms)
    getPrice
      ✔ returns correct prices (67ms)
    getTokenOutput
      ✔ returns correct token output (139ms)
    getEthOutput
      ✔ returns correct eth output


  4 passing (598ms)

通过测试我们可以看到:支付1个ETH能够换得1.998个token,支付2个token能够换得0.999个ETH。这个兑换比例和添加的流动性的比例很接近了,但还是少了一点,这是为什么?

因为我们为了避免流动性被掏空而采用的恒定乘积模型是一个双曲线函数,其在正数范围内的图像如下图所示:

可以看到无论输入的x是多少,y永远都不会达到0,说明这个公式确实能够达到避免流动性被掏空的目的。同时,这个公式也会带来价格滑动(price slippage),想要购买的代币数量越大,需要支付的代币数量越大。

这看起来是恒定乘积做市商模型的缺点,实则瑕不掩瑜。首先,恒定乘积做市商模型能够保证流动性不被掏空,使得交易始终能够进行。其次,这个价格变化规律也符合供求关系,需求(想要购买的代币数量)越大,价格(需要支付的代币数量)越高。并且这个交易对池子余额的影响越小,价格滑动会越小,越接近使用一次函数计算出的价格。

下面我们增加一些测试来观察在不同出价时,收到的代币的变化,代码如下:

describe("getTokenOutput", async () => {
  it("returns correct token output", async () => {
    // ...

    tokenOutput = await exchange.getTokenOutput(toWei(100));
    expect(fromWei(tokenOutput)).to.equal("181.818181818181818181");

    tokenOutput = await exchange.getTokenOutput(toWei(1000));
    expect(fromWei(tokenOutput)).to.equal("1000.0");
  });
});

describe("getEthOutput", async () => {
  it("returns correct eth output", async () => {
    // ...

    ethOutput = await exchange.getEthOutput(toWei(100));
    expect(fromWei(ethOutput)).to.equal("47.619047619047619047");

    ethOutput = await exchange.getEthOutput(toWei(2000));
    expect(fromWei(ethOutput)).to.equal("500.0");
  });
});

可以看到,当我们尝试一次性掏空流动性的时候,我们只能得到一半的应收代币。