Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

手把手教你从0到1构建UniswapV2:part1 #97

Open
MagicalBridge opened this issue Jul 31, 2024 · 0 comments
Open

手把手教你从0到1构建UniswapV2:part1 #97

MagicalBridge opened this issue Jul 31, 2024 · 0 comments

Comments

@MagicalBridge
Copy link
Owner

简单介绍

Uniswap是一个运行在以太坊区块链上的去中心化交易所。它完全是自动化的、非托管的、去中心化的。它经历了多次的迭代开发。目前线上稳定运行的是第三个版本。

之前关于 Uniswap V1 的系列文章中,我展示了如何从头开始构建它并解释了它的核心机制。从这篇开始,我们会花一些时间专门讨论 Uniswap V2 的相关内容:同样,我们将从头开始构建它的复刻版本,并将学习去中心化交易所的核心原理。与之前的系列不同,这里不会过多详细介绍 Uniswap 的常数乘积公式和相关核心数学——如果您想了解,请阅读 V1 系列。

工具的介绍

在这个系列的文章中,我们将使用一种全新的工具:Foundry,我们将使用它进行合约的开发和测试,Foundry 是由 Georgios Konstantopoulos 用 Rust 编写的现代以太坊工具包。它比 Hardhat 快得多,而且它还有一个优势就是可以使用solidity编写测试代码,这会让一些不会写JavaScript的朋友少一些心智负担。

我们还将使用Solmate来实现ERC20, 而不是OpenZeppelin,因为后者有些臃肿和固执己见。使用OpenZeppelin的ERC20不允许将代币转移到零地址,这个限制并不是我们想要的。反过来Solmate是一个Gas优化合约的集合,并且没有那么多限制。

还值得注意的是,自 2020 年 Uniswap V2 推出以来,很多事情都发生了变化。例如,自 Solidity 0.8 发布以来, SafeMath 库已过时,该版本引入了本机溢出检查。所以,可以这么说,我们正在构建 Uniswap 的现代版本。

Uniswap V2 架构

Uniswap V2 的核心架构思想是 “池化” :流动性提供者可以将其流动性质押在合约中;质押的流动性允许其他任何人以去中心化的方式进行交易。与 Uniswap V1 类似,交易者支付少量费用,该费用会累积在合约中,然后由所有流动性提供者共享。

Uniswap V2的核心合约是UniswapV2Pair,“pool”和“pair”是可互换的术语,它们所代表的意思是相同的,都是代表UniswapV2Pair合约。该合约的主要的功能是接收用户的Token代币,并使用累积的代币储备来执行交换。这也是为什么我们叫它“池子”的原因。每个UniswapV2Pair合约只能包含一对代币,并且只能允许这两个代币之间进行交换,这就是它叫做“对”的原因。

Uniswap V2 的合约代码库分为两个存储仓库:

核心的仓库存储这些合约:

1、UniswapV2ERC20 – 用于生成 LP 代币的 ERC20合约。它还实现了 EIP-2612标准用于支持链下签名授权。

2、UniswapV2Factory - 和V1版本类似,这是一个工厂合约,用于创建配对合约并充当他们的注册表。注册表使用 create2 生成地址,我们会详细讲解它是如何工作的。

3、UniswapV2Pair - 符合核心逻辑的主合约。值得注意的是,工厂只允许创建独特的没有被创建过的交易对,这样做可以避免稀释流动性。

v2-periphery 这个仓库中包含多个合约,这些合约使得使用Uniswap更加容易,其中包含了UniswapV2Router,它是Uniswap UI 以及在Uniswap之上工作的其他web和去中心化应用程序的主要入口点。该合约的接口和Uniswap V1中的Exchange合约中的接口非常接近。

v2-periphery 这个仓库中的另一个重要合约是UniswapV2Library,它是实现重要计算的辅助函数集合,我们会继承这两个合约。

我们将从核心合约开始我们的实现,首先关注最重要的机制。我们会看到这些合约很通用,颗粒度很细,这种设计让整个架构更加细化。

让我们正式开始!

集中汇聚流动性

没有流动性是不可能进行交易的。因此,我们需要实现的第一个功能是创建一个流动性池子,看看它是如何工作的。

流动性池子是一个合约,这个合约可以存储代币,并允许使用这些代币进行交换,因此“汇集流动性”意味着将Token代币发送到智能合约并将其存储一段时间。

你应该已经知道,每个合约都拥有自己的存储空间,就像ERC20 tokens一样,每个合约都拥有一个mapping,这个mapping是从用户地址到其余额的映射。我们的“池子”合约中会存放ERC20的余额,这显然还不足够。

主要原因是,仅仅依赖ERC20的余额很可能会被价格操纵,想象一下:有人将大量的代币发送到流动性池子中,这会暂时增加池子中的代币供应量,增加供应量之后,池子中的代币价格会下跌,因为流动性是根据恒定乘积做市商算法工作的。这个时候恶意用户可以用比较低的价格购买目标代币,进行有利可图的交换,并最终兑现,恶意用户可以从池子中移走代币完成获利。为了避免这种情况,我们需要跟踪池子中的储备,并且我们还需要控制它们的更新时间。

我们将使用reserve0和reserve1变量来跟踪池子中的储备:

contract ZuniswapV2Pair is ERC20, Math {
  ...

  uint256 private reserve0;
  uint256 private reserve1;

  ...
}

为了简洁起见,我省略了很多代码,可以去github仓库获取完整代码。

如果你看过我的UniswapV1系列你可能还记得,我们实现了一个addLiquidity函数,这个函数的作用是计算新流动性并发行LP代币。其实,Uniswap V2 在 UniswapV2Router 合约中实现了相同的功能。并且在配对合约中,这个功能是基础功能:流动性管理被简单的视为LP代币管理。当我们为池子添加流动性时,合约会铸造LP代币;当你移除流动性时,LP代币会被销毁。我们在上面的架构层面已经解释过了,核心的合约是仅执行核心操作的低级别的合约,UniswapV2Router负责高级别的业务逻辑和用户交互。

让我们看下这个基础函数:

function mint() public {
   uint256 balance0 = IERC20(token0).balanceOf(address(this));
   uint256 balance1 = IERC20(token1).balanceOf(address(this));
   uint256 amount0 = balance0 - reserve0;
   uint256 amount1 = balance1 - reserve1;

   uint256 liquidity;

   if (totalSupply == 0) {
      liquidity = ???
      _mint(address(0), MINIMUM_LIQUIDITY);
   } else {
      liquidity = ???
   }

   if (liquidity <= 0) revert InsufficientLiquidityMinted();

   _mint(msg.sender, liquidity);

   _update(balance0, balance1);

   emit Mint(msg.sender, amount0, amount1);
}

让我们来稍微梳理下这个函数:

1、获取当前合约的代币余额:

uint256 balance0 = IERC20(token0).balanceOf(address(this));
uint256 balance1 = IERC20(token1).balanceOf(address(this));

这两行代码获取合约当前持有的 token0token1的余额。

2、计算新增的代币量:

uint256 amount0 = balance0 - reserve0;
uint256 amount1 = balance1 - reserve1;

这里计算了自上次更新以来新增的 token0token1的数量。reserve0reserve1 是先前记录的代币储备量。

3、初始化流动性变量:

uint256 liquidity;

4、计算新增的流动性:

if (totalSupply == 0) {
  liquidity = ???
  _mint(address(0), MINIMUM_LIQUIDITY);
} else {
  liquidity = ???
}

这里我故意将liquidity的计算方法打了问号,我们后面会专门的去分析这里的流动性计算规则。

5、检查流动性是否足够:

if (liquidity <= 0) revert InsufficientLiquidityMinted();

如果计算出的流动性小于或等于零,则抛出异常,表示新增的流动性不足。

6、铸造LP代币:

_mint(msg.sender, liquidity);

向调用者铸造相应数量的 LP 代币。

7、更新储备和余额:

_update(balance0, balance1);

8、触发事件:

emit Mint(msg.sender, amount0, amount1);

触发 Mint 事件,记录调用者地址以及新增的 token0token1 数量。

我们回到第4个步骤,也就是计算流动性的部分,从代码上可以看出,最初存入资金池(totalSupply == 0分支)时候,流动性的计算方式有所不同,我们可以思考一下:当池子中没有流动性时,我们需要发行多少LP代币?Uniswap V1 中初始流动性提供者通过存入一定量的以太币ETH来创建流动性池,并且初始的LP代币数量基于存入的ETH数量。这种方法存在一些问题,在 Uniswap V1 中,ETH 是主要的定价基准,但对于非 ETH 交易对来说,这种定价方法可能不合适。

对于初始LP代币数量,在Uniswap V2中最终使用两种代币数量乘积的平方根来计算LP代币的数量。

Xnip2024-07-27_22-41-40.png

其中 Amount0Amount1是新增的 token0token1 数量。这个公式确保了初始的 LP 代币数量反映了流动性池中两种代币的实际数量比例,而不是简单地基于某一种代币的数量。

另一种情况,让我们计算一下池子中已经有一定流动性时发行的LP代币。这里的计算需要注意:

  • 1、与存款的金额成正比。
  • 2、与LP代币的发行总量成正比。

回想一下v1系列中的这个公式:

Xnip2024-07-27_22-42-16.png

但是这里有一个问题,在V2中,有两个底层代币——我们应该在公式中使用哪一个代币呢?我们先补上代码中的空白再慢慢解释:

if (totalSupply == 0) {
   liquidity = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
   _mint(address(0), MINIMUM_LIQUIDITY);
} else {
   liquidity = Math.min(
      (amount0 * totalSupply) / _reserve0,
      (amount1 * totalSupply) / _reserve1
   );
}

初始流动性: 如果 totalSupply 为零,表示这是第一次添加流动性。在这种情况下,流动性是根据新增的 token0 和 token1 数量的乘积的平方根来计算的,并减去一个最小流动性值。随后铸造一个最小流动性值给零地址,以防止流动性被抽干。

这里稍微解释一下,当初始流动性提供者第一次向池子中添加流动性时,系统会铸造一个最小的流动性(比如1000单位)给零地址。这样做的目的是确保即使所有的流动性提供者都提取他们的流动性,池子中仍然会保留一些流动性,从而防止池子被完全抽干。

让我们举个实际例子方便理解:

假设我们有两个代币,Token A 和 Token B。初始流动性提供者(Alice)希望向 Uniswap V2 池中添加初始流动性。


假设的市场价格:1个Token A = 2个Token B

Alice准备存入:100个Token A和200个Token B

根据 Uniswap V2 的公式:

Xnip2024-07-27_22-41-40.png

这里,amount0 是 Token A 的数量,amount1 是 Token B 的数量。

计算步骤:

  1. 计算乘积: amount0 × amount1 = 100×200 = 20000
  2. 计算平方根: 20000的平方根 ≈ 141.42
  3. 减去最小流动性(假设 MINIMUM_LIQUIDITY = 10,通常这是一个小常数,用于防止流动性池被完全抽干): liquidity=141.42−10=131.42。

Alice 将会收到大约 131.42 个初始 LP 代币。

在池子已经存在流动性的时候,后续流动性提供者可以通过我们上面提供的计算公式来计算新增的LP代币数量:

liquidity = Math.min(
      (amount0 * totalSupply) / _reserve0,
      (amount1 * totalSupply) / _reserve1
);
  • totalSupply 是当前LP代币的总供应量
  • _reserve0 和 _reserve1 是池子中的当前代币储备量

计算示例:

假设池子已经有 1000 个 LP 代币,储备量为 500 Token A 和 1000 Token B,Bob 想要添加流动性:

  • Bob 添加 50 Token A 和 100 Token B

计算新增 LP 代币数量:带入公式计算为100个,因此,Bob 会获得 100 个新增的 LP 代币。

用solidity编写测试

我们在文章开头已经说过,我们将使用Foundry这个工具来测试我们的智能合约——我们可以快速编写测试,而不需要去学习JavaScript。

这就是我们设置配对合约测试所需的全部内容:

contract ZuniswapV2PairTest is Test {
  ERC20Mintable token0;
  ERC20Mintable token1;
  ZuniswapV2Pair pair;

  function setUp() public {
    token0 = new ERC20Mintable("Token A", "TKNA");
    token1 = new ERC20Mintable("Token B", "TKNB");
    pair = new ZuniswapV2Pair(address(token0), address(token1));

    token0.mint(10 ether);
    token1.mint(10 ether);
  }

  // Any function starting with "test" is a test case.
}

让我们编写一个函数测试提供初始流动性的场景:

function testMintBootstrap() public {
  token0.transfer(address(pair), 1 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint();

  assertEq(pair.balanceOf(address(this)), 1 ether - 1000);
  assertReserves(1 ether, 1 ether);
  assertEq(pair.totalSupply(), 1 ether);
}
  • 初始化流动性池:通过转移 token0token1 各 1 ether 到流动性池合约中,设置池子的初始储备。
  • 铸造 LP 代币:调用 mint 函数后,合约按照 Uniswap V2 的机制计算并分配 LP 代币。
  • 断言测试
    • 检查当前合约的 LP 代币余额是否正确(考虑到 MINIMUM_LIQUIDITY 的影响)。
    • 检查流动性池中的代币储备是否与初始存入的数量一致。
    • 检查 LP 代币的总供应量是否正确。

当向已经拥有一定流动性的资金池提供平衡的流动性时会发生什么?让我们来看看

function testMintWhenTheresLiquidity() public {
  token0.transfer(address(pair), 1 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint(); // + 1 LP

  token0.transfer(address(pair), 2 ether);
  token1.transfer(address(pair), 2 ether);

  pair.mint(); // + 2 LP

  assertEq(pair.balanceOf(address(this)), 3 ether - 1000);
  assertEq(pair.totalSupply(), 3 ether);
  assertReserves(3 ether, 3 ether);
}

这里一切看起来都是正确的。让我们看看当提供不平衡的流动性时会发生什么:

function testMintUnbalanced() public {
  token0.transfer(address(pair), 1 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint(); // + 1 LP
  assertEq(pair.balanceOf(address(this)), 1 ether - 1000);
  assertReserves(1 ether, 1 ether);

  token0.transfer(address(pair), 2 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint(); // + 1 LP
  assertEq(pair.balanceOf(address(this)), 2 ether - 1000);
  assertReserves(3 ether, 2 ether);
}

这就是我们所讨论的:即使用户提供的token0流动性多于token1流动性,他们仍然只获得 1 个 LP 代币。

流动性供应看起来不错。现在让我们转向流动性消除。

移除流动性

流动性的移除和提供流动性相反,同样的,燃烧LP代币和铸造LP代币相反。从资金池中移除流动性意味着燃烧 LP 代币以换取一定数量的基础代币。返还给流动性的代币数量计算如下:

Xnip2024-07-27_22-42-58.png

简而言之:返还的代币数量与持有的 LP 代币数量与 LP 总供应量成正比。你的 LP 代币份额越大,燃烧后获得的储备份额就越大。

这就是我们实现burn功能所需要知道的一切:

function burn() public {
  uint256 balance0 = IERC20(token0).balanceOf(address(this));
  uint256 balance1 = IERC20(token1).balanceOf(address(this));
  uint256 liquidity = balanceOf[msg.sender];

  uint256 amount0 = (liquidity * balance0) / totalSupply;
  uint256 amount1 = (liquidity * balance1) / totalSupply;

  if (amount0 <= 0 || amount1 <= 0) revert InsufficientLiquidityBurned();

  _burn(msg.sender, liquidity);

  _safeTransfer(token0, msg.sender, amount0);
  _safeTransfer(token1, msg.sender, amount1);

  balance0 = IERC20(token0).balanceOf(address(this));
  balance1 = IERC20(token1).balanceOf(address(this));

  _update(balance0, balance1);

  emit Burn(msg.sender, amount0, amount1);
}

如您所见,UniswapV2 不支持部分移除流动性。

注意:上面的这个说法是错误的,我在这个函数的实现中犯了一个错误,你能发现它吗?如果没有,我们会在第四篇文章中重新说明下这个问题

让我们对这个函数做个测试:

function testBurn() public {
  token0.transfer(address(pair), 1 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint();
  pair.burn();

  assertEq(pair.balanceOf(address(this)), 0);
  assertReserves(1000, 1000);
  assertEq(pair.totalSupply(), 1000);
  assertEq(token0.balanceOf(address(this)), 10 ether - 1000);
  assertEq(token1.balanceOf(address(this)), 10 ether - 1000);
}

我们看到池返回到其未初始化状态,除了发送到零地址的最小流动性之外——它无法被认领。

现在,让我们看看当我们提供不平衡的流动性后进行销毁时会发生什么:

function testBurnUnbalanced() public {
  token0.transfer(address(pair), 1 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint();

  token0.transfer(address(pair), 2 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint(); // + 1 LP

  pair.burn();

  assertEq(pair.balanceOf(address(this)), 0);
  assertReserves(1500, 1000);
  assertEq(pair.totalSupply(), 1000);
  assertEq(token0.balanceOf(address(this)), 10 ether - 1500);
  assertEq(token1.balanceOf(address(this)), 10 ether - 1000);
}

我们在这里看到的是,我们丢失了 500 wei 的token0 !这就是我们上面讲的价格操纵行为的惩罚。但这个数额却小得离谱,看起来根本不是什么大不了的事。这是因为我们当前的用户(测试合约)是唯一的流动性提供者。如果我们向另一个用户初始化的池提供不平衡的流动性怎么办?让我们来看看:

function testBurnUnbalancedDifferentUsers() public {
  testUser.provideLiquidity(
    address(pair),
    address(token0),
    address(token1),
    1 ether,
    1 ether
  );

  assertEq(pair.balanceOf(address(this)), 0);
  assertEq(pair.balanceOf(address(testUser)), 1 ether - 1000);
  assertEq(pair.totalSupply(), 1 ether);

  token0.transfer(address(pair), 2 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint(); // + 1 LP

  assertEq(pair.balanceOf(address(this)), 1);

  pair.burn();

  assertEq(pair.balanceOf(address(this)), 0);
  assertReserves(1.5 ether, 1 ether);
  assertEq(pair.totalSupply(), 1 ether);
  assertEq(token0.balanceOf(address(this)), 10 ether - 0.5 ether);
  assertEq(token1.balanceOf(address(this)), 10 ether);
}

这看起来完全不同!我们现在已经损失了0.5 ether的token0 ,这是我们存入的 1/4。现在这是一个很大的数额!

总结:

好了,本篇文章就到这里。请随意尝试代码,例如,在向池中添加流动性时选择更大数量的 LP 代币。

Links

  1. Source code of part 1
  2. UniswapV2 Whitepaper – worth reading and re-reading.
  3. Foundry GitHub repo
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant