MagicalBridge/Blog

手把手教你从0到1构建Uniswap V1:part2

Opened this issue · 0 comments

开篇介绍

这是我们系列文章的的第二部分。在上一篇中,我们了解了 Uniswap 及其核心机制,并开始构建交换合约。该合约可以接受用户的流动性、计算输出相应的代币数量并执行交换。

本篇文章,我们将完成 Uniswap V1 的实现。虽然它不会是 Uniswap V1 的完全复刻,但拥有所有的核心功能。我们直接开始吧。

手把手教你从0到1构建Uniswap V1:part1

添加更多的流动性

在上一部分中,我们讨论了 addLiquidity 的实现,但是并不完整。这是有原因的,这篇文章我们将完善这部分的功能。

到目前为止,addLiquidity函数实现是这样的:

function addLiquidity(uint256 _tokenAmount) public payable {
  IERC20 token = IERC20(tokenAddress);
  token.transferFrom(msg.sender, address(this), _tokenAmount);
}

很显然,这个函数存在一些问题,这个函数允许随时添加任意数量的流动性。我们知道,汇率是按照供应量的比例计算的。

Xnip2024-07-21_15-31-10.png

其中 $Px$$Py$ 是以太币和代币的价格; x 和 y 是以太币和代币的储备。无论是以太币还是Token代币,数量越多,价格越低。

我们还了解到,交换代币会以非线性方式改变储备,从而影响价格,并且套利者通过平衡价格以使其与大型**交易所的价格相匹配来获利。

在我们的实现中是允许在任何时间点去显著的改变价格,换句话说,新的流动性添加进来后,和当前的储备率并不匹配,这确实是个问题,因为这样会导致价格操纵。我们希望去中心化交易所的价格与中心化交易所的价格尽可能的接近。我们希望我们的交易合约可以作为价格预言机。

因此,我们需要确保额外添加的流动性比例和池子中已经建立的比例相同。同时,当储备为空时,即当资金池尚未初始化的时候,我们希望允许任意比例的流动性。这是非常重要的时刻,因为这是最初确定价格的时候。

所以,addLiquidity 函数会有两个分支:

  • 1、如果这是一个新的合约,没有流动性,在池子为空时候允许设置任意的流动性比例。
  • 2、如果合约本身已经存在流动性的时候,执行既定的兑换比率,添加流动性,只能按照既定的规则。

没有流动性的逻辑保持不变:

if (getReserve() == 0) {
    IERC20 token = IERC20(tokenAddress);
    token.transferFrom(msg.sender, address(this), _tokenAmount);

当添加新的流动性时:

} else {
	  // 用当前合约的以太币数量减去本次交易用户发送过来的以太币,就是发送交易之前的储备
    uint256 ethReserve = address(this).balance - msg.value;
    uint256 tokenReserve = getReserve();
    uint256 tokenAmount = (msg.value * tokenReserve) / ethReserve;
    require(_tokenAmount >= tokenAmount, "insufficient token amount");

    IERC20 token = IERC20(tokenAddress);
    token.transferFrom(msg.sender, address(this), tokenAmount);
}

这里的区别在于,我们并不会存入用户提供的所有代币,而只会存入根据当前准备金率计算的金额。为了得到这个金额,我们将现有代币和以太币的比率 (tokenReserve/ethReserve)乘以存入的以太币的数量,然后,如果用户存入的金额少于这个金额,则会抛出错误。

按照这种方式添加流动性,将有效的保护价格。

LP-tokens

在上面的内容中,我们还没有讨论过这个概念,但是它是Uniswap设计的关键部分。

我们需要一种方法来奖励流动性提供者,如果他们没有受到激励,他们就不会提供流动性,因为没有人会无缘无故的将他们的代币放入到第三方的合约中。此外这种奖励不应该由开发程序的人支付。因为开发者必须获得投资或者发行代币来提供资金。

唯一好的解决办法是在每次代币互换中收取少量的费用,并将累积起来的费用分配给流动性提供者。这看起来相当公平:用户(交易者)为其他人提供的服务(流动性)付费。

为了保证奖励公平,我们需要根据流动性提供者的贡献(即他们提供的流动性的数量)按比例奖励他们。如果有人提供了50%的资金流动性,他们就应该获得50%的累积费用奖励。这是合理的。

这看起来比较复杂,然而,我们有一个比优雅的解决方案:LP代币

LP-tokens 是向流动性提供者发行的ERC20代币,用来换取流动性,可以把LP代币理解成股票:

1、流动性提供者可以获得相应的LP代币

2、流动性提供者获得的代币数量与他在池子中储备的流动性份额成正比

3、流动性提供者获得的费用根据持有的代币数量按照比例分配

4、LP代币可以兑换自己提供的回流动性,并且还有额外的累积费用奖励

上面是我们想要实现的能力,如何根据提供的流动性数量来计算发行的LP代币数量呢?这里需要满足一些前提条件。

1、每一份已经发行的权益必须始终正确,当有人在我之后存入或者移除流动性的时候,我的份额必须保持正确。

2、以太坊上的写入操作(例如存储新数据或更新合约中的现有数据)非常昂贵。因此,我们希望降低LP代币的维护成本(即我们不想运行定期重新计算和更新份额这样的定时任务)

想象一下,如果我们发行大量LP代币(比如10亿)并将其分配给所有流动性提供者。如果我们总是分配所有代币(第一个流动性提供者获得 10 亿,第二个流动性提供者获得其中的一部分)我们会被迫重新计算已经发行的股份和权益,这种操作是非常昂贵的。如果我们最初只分配一部分代币,那么我们将面临达到供应限制的风险,这最终迫使人们重新分配现有的份额。

一个比较好的解决方案是没有供应限制,并在添加新的流动性时候铸造新的LP代币,这种方式允许代币无限增长,并且如果我们使用正确的计算公式,当流动性增加或者减少时,所有已经发行的代币权益将保持正确(将按照比例缩放)。幸运的是,通货膨胀不会降低LP代币的价格,因为它们总是有一定数量的流动性支持,而流动性不依赖于代币的发行数量。

这个难题的最后一部分需要解决的是:当存入流动性时,如何计算铸造的LP代币数量

Exchange合约存储了以太币和Token代币的储备金额,因此我们根据两者的储备进行计算……还是根据其中之一进行计算?Uniswap V1 是根据以太储备按比例计算金额。我们继续关注Uniswap V1的做法,稍后我们将看到当有两个ERC20代币时候如何解决这个问题。

该方程显示了如何根据存入的以太币数量计算新 LP 代币的数量:

Xnip2024-07-21_15-31-45.png

每次添加流动性的时候,都会根据以太币储备中存入的以太币比例按比例发行 LP 代币,例如,当有人存入 etherReserve 数量的以太币时, amountMintedtotalAmount 会是多少?

让我们用代码实现一下:

在修改 addLiquidity 之前,我们需要将我们的Exchange合约继承标准的ERC20合约并更改其构造函数:

contract Exchange is ERC20 {
    address public tokenAddress;

    constructor(address _token) ERC20("Suniswap-V1", "SUNI-V1") {
        require(_token != address(0), "invalid token address");

        tokenAddress = _token;
    }
  
......

我们的 LP 代币将有一个常量名称和一个符号,这就是 Uniswap 的做法,在我们的实现中,这个名称是可以修改的。为了方便识别,LP代币通常由池中两种代币的符号组合而成,如ETH-USDT LP

这里还是要强调一下:LP代币和用户提供的资产对中的代币是不同的概念,资产对代币是用户实际存入的流动性池子中的代币,例如, 在ETH/USDT池中,用户提供的是ETH和USDT,这些都是独立的,可以交易的加密货币。LP代币是用户提供流动性后收到的新的代币。代表的是用户在整个流动性池子中的份额。是一种衍生品,其价值与池子中的资产相关联。

现在,让我们更新addLiquidity函数的实现:当添加初始流动性时,发行的LP代币数量等于存入的以太币数量。

function addLiquidity(uint256 _tokenAmount)
    public
    payable
    returns (uint256)
{
    if (getReserve() == 0) {
        ...

        uint256 liquidity = address(this).balance;
        _mint(msg.sender, liquidity);

        return liquidity;

额外添加的流动性会根据存入的以太币的数量按照比例铸造LP代币:

    } else {
        ...

        uint256 liquidity = (totalSupply() * msg.value) / ethReserve;
        _mint(msg.sender, liquidity);

        return liquidity;
    }
}

只需几行,我们现在就有了 LP 代币,并没有想象的那么难!

Fees

我们现在准备收取一些费用,在此之前,我们需要回答几个问题:

1、我们想要以太币还是Token代币的形式收取费用?我们使用以太币还是Token代币向流动性提供者支付奖励?

2、如何从每次交换中收取少量固定费用?

3、如何根据流动性提供者的贡献比例分配累积费用?

上面几个问题看起来是一项艰巨的任务,但是我们已经有了解决办法。

我们来思考一下最后两个问题,我们可能会引入与swap交易一起发送的额外付款,然后,此类付款会累积在一个基金中,任何流动性提供者都可以从该基金中提取与自己持有份额成比例的金额。者听起来是一个比较合理的想法:

1、交易者已经将以太币/Token代币发送到了交易合约。我们可以简单的从发送到合约的以太币/Token代币中直接减去费用,而不是另外收取费用。

2、我们的储备基金会随着时间的推移而增长,因此恒定乘积公式并不是那么恒定,然而这并不意味着它无效:交易的费用和储备金相比很小,而且没有办法操作fees来尝试显著改变储备金。

3、现在我们已经有了第一个问题的答案,费用以交易的代币形式收取。例如,如果用户在 Uniswap V1 上用以太币(ETH)交换 ERC-20 代币(例如 DAI),费用是从 ETH 中扣除的;如果用户用 DAI 交换 ETH,则费用是从 DAI 中扣除的。

4、收取的费用直接加入到流动性池中,从而增加池中的总资产量。这意味着流动性提供者(LP)分享的是整个流动性池中的资产,这些资产包括交易过程中收取的费用,流动性提供者收到的报酬形式与他们提供的流动性成比例。如果一个流动性提供者在 ETH/DAI 池中提供了流动性,那么他们收到的报酬包括池中收取的 ETH 和 DAI。

我们开始编码实现一下:

Uniswap 从每次互换中收取 0.3% 的费用。我们取 1% 只是为了更容易看到测试中的差异。向合约添加费用就像向 getAmount 函数添加几个乘数一样简单:

function getAmount(
  uint256 inputAmount,
  uint256 inputReserve,
  uint256 outputReserve
) private pure returns (uint256) {
  require(inputReserve > 0 && outputReserve > 0, "invalid reserves");

  uint256 inputAmountWithFee = inputAmount * 99;
  uint256 numerator = inputAmountWithFee * outputReserve;
  uint256 denominator = (inputReserve * 100) + inputAmountWithFee;

  return numerator / denominator;
}

由于Solidity不支持浮点除法,所以我们必须使用一个技巧,分子和分母都乘以10的幂次,这样就可以避免计算小数。

uint256 inputAmountWithFee = inputAmount * 99;

将输入的币的数量乘以99,相当于扣除1%的交易费用,即 inputAmount * (1 - 0.01))。

Xnip2024-07-21_15-32-27.png

在 Solidity 中,我们必须这样做:

Xnip2024-07-21_15-32-48.png

效果是一样的。

Removing liquidity 移除流动性

最后,我们列表中的最后一个函数: removeLiquidity 。

为了移除流动性,我们可以再次使用LP代币:我们并不需要记住每个流动性提供者存入的金额,并且可以根据LP代币的份额计算移除流动性金额。

function removeLiquidity(uint256 _amount) public returns (uint256, uint256) {
  require(_amount > 0, "invalid amount");

  uint256 ethAmount = (address(this).balance * _amount) / totalSupply();
  uint256 tokenAmount = (getReserve() * _amount) / totalSupply();

  _burn(msg.sender, _amount);
  payable(msg.sender).transfer(ethAmount);
  IERC20(tokenAddress).transfer(msg.sender, tokenAmount);

  return (ethAmount, tokenAmount);
}

我们来解析下这个函数的具体含义:

函数参数和验证:

function removeLiquidity(uint256 _amount) public returns (uint256, uint256) {
  require(_amount > 0, "invalid amount");
  • _amount 是用户想要移除的流动性数量。
  • require(_amount > 0, "invalid amount"); 确保用户移除的数量大于 0。

计算用户收到的以太币数量

uint256 ethAmount = (address(this).balance * _amount) / totalSupply();
  • address(this).balance 是合约中当前持有的以太币总量。
  • totalSupply() 是流动性池的总供应量。
  • (address(this).balance * _amount) / totalSupply() 计算用户应得的以太币数量。这个公式的原理是根据用户持有的流动性份额来分配池中的以太币。

计算用户将收到的代币数量

uint256 tokenAmount = (getReserve() * _amount) / totalSupply();
  • getReserve() 是流动性池中当前持有的Token代币总量。
  • (getReserve() * _amount) / totalSupply() 计算用户应得的代币数量。同样,这个公式是根据用户持有的流动性份额来分配池中的代币。

销毁用户的流动性代币并转移以太币和代币

_burn(msg.sender, _amount);
payable(msg.sender).transfer(ethAmount);
IERC20(tokenAddress).transfer(msg.sender, tokenAmount);
  • _burn(msg.sender, _amount); 销毁用户持有的 _amount 数量的LP代币。
  • payable(msg.sender).transfer(ethAmount); 将计算出的以太币数量转移给用户。
  • IERC20(tokenAddress).transfer(msg.sender, tokenAmount); 将计算出的代币数量转移给用户。

返回用户收到的以太币和代币数量

return (ethAmount, tokenAmount);

Xnip2024-07-21_15-33-12.png

请注意,每次流动性被消除时,LP 代币都会被烧毁。 LP 代币仅由存入的流动性支持。

可能存在的无常损失:

当流动性被移除时,它会以以太币和代币的形式返回,而且它们的数量是平衡的。这是导致无常损失的时刻:随着美元价格的变化。当流动性被移除时,余额可能与存入流动性时不同。这意味着您将获得不同数量的以太币和代币,并且它们的总价格可能会低于您将它们放在钱包中的价格。

上面说的无常损失(Impermanent Loss)是在用户提供流动性期间,代币价格相对于最初存入时发生变化导致的损失。让我们结合上面的函数来详细解释这个过程。

1、提供流动性:用户最初存入一定数量的以太币和代币。当时,以太币和代币的价格在某一比率下(例如 1 ETH = 200 DAI)。

2、价格变化:随着时间的推移,市场上以太币和代币的价格发生变化(例如 1 ETH = 250 DAI)。流动性池中的以太币和代币的储备量也随之变化,以维持恒定乘积公式 x*y = k

3、移除流动性:当用户决定移除流动性时,他们将根据当前的储备比例获得以太币和代币。例如,上述函数计算用户应得的以太币和代币数量。

4、无常损失的影响:由于价格的变化,用户获得的以太币和代币的数量和价值可能与他们最初存入时不同。用户在移除流动性时,可能发现总价值低于如果他们一直持有这些代币而不提供流动性的情况。这种价值的减少就是无常损失。

LP奖励与无常损失演示:

让我们编写一个测试来重现添加流动性、交换代币、累积费用和消除流动性的完整周期:

1、首先,流动性提供者存入 100 个以太币和 200 个代币。这使得 1 个代币等于 0.5 个以太币,1 个以太币等于 2 个代币。

exchange.addLiquidity(toWei(200), { value: toWei(100) });

2、用户交换 10 个以太币,期望获得至少 18 个代币。事实上,他们得到了 18.0164 个代币。包括滑点(交易量比较大)和1%的费用。

exchange.connect(user).ethToTokenSwap(toWei(18), { value: toWei(10) });

3、然后流动性提供者移除他们的流动性:

exchange.removeLiquidity(toWei(100));

4、流动性提供者获得 109.9 个以太币(包括交易费)和 181.9836 个代币。正如你所看到的,这些数字与存入的数字不同:我们得到了用户交易的 10 个以太币,但必须提供 18.0164 个代币作为交换。但是,该金额包括用户向我们支付的 1% 的费用。由于流动性提供者提供了所有流动性,因此他们获得了所有费用。

总结:

写到这里,我们还没有完成:Exchange合约现在已经完成了,但我们还需要实现工厂合约,它作为交易所的注册表和连接多个交易所并使代币到代币交换成为可能的桥梁。我们将在下一篇文章中实现它!