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

简单介绍

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变量来跟踪池子中的储备:

csharp 复制代码
contract ZuniswapV2Pair is ERC20, Math {
  ...

  uint256 private reserve0;
  uint256 private reserve1;

  ...
}

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

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

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

ini 复制代码
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、获取当前合约的代币余额:

ini 复制代码
uint256 balance0 = IERC20(token0).balanceOf(address(this));
uint256 balance1 = IERC20(token1).balanceOf(address(this));

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

2、计算新增的代币量:

ini 复制代码
uint256 amount0 = balance0 - reserve0;
uint256 amount1 = balance1 - reserve1;

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

3、初始化流动性变量:

ini 复制代码
uint256 liquidity;

4、计算新增的流动性:

ini 复制代码
if (totalSupply == 0) {
  liquidity = ???
  _mint(address(0), MINIMUM_LIQUIDITY);
} else {
  liquidity = ???
}

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

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

scss 复制代码
if (liquidity <= 0) revert InsufficientLiquidityMinted();

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

6、铸造LP代币:

scss 复制代码
_mint(msg.sender, liquidity);

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

7、更新储备和余额:

scss 复制代码
_update(balance0, balance1);

8、触发事件:

scss 复制代码
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代币的数量。

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

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

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

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

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

ini 复制代码
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 的公式:

这里,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代币数量:

ini 复制代码
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。

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

ini 复制代码
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.
}

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

scss 复制代码
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 代币的总供应量是否正确。

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

scss 复制代码
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);
}

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

scss 复制代码
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 代币以换取一定数量的基础代币。返还给流动性的代币数量计算如下:

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

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

scss 复制代码
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 不支持部分移除流动性。

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

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

perl 复制代码
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);
}

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

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

perl 复制代码
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 !这就是我们上面讲的价格操纵行为的惩罚。但这个数额却小得离谱,看起来根本不是什么大不了的事。这是因为我们当前的用户(测试合约)是唯一的流动性提供者。如果我们向另一个用户初始化的池提供不平衡的流动性怎么办?让我们来看看:

perl 复制代码
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 代币。

  1. Source code of part 1
  2. UniswapV2 Whitepaper -- worth reading and re-reading.
  3. Foundry GitHub repo
相关推荐
电报号dapp11913 小时前
区块链智能合约开发:全面解析与实践指南
区块链·智能合约
程序员 jet_qi5 天前
区块链应用第1讲:基于区块链的智慧货运平台
区块链·智能合约·数字身份·did·货运平台·可验性证明·vc
bigbig猩猩6 天前
深入理解智能合约 ABI
区块链·智能合约
zhuqiyua7 天前
TVM OpcodeTable c++
c++·区块链·智能合约·ton
Antg7 天前
[基础] 003 使用github提交作业
web3·智能合约·move·sui
Blockchina11 天前
Solana链上的Pump狙击机器人与跟单机器人的工作原理及盈利模式
web3·区块链·智能合约·solana·sol机器人
TMDOG66611 天前
微服务架构设计的初次尝试——基于以太坊智能合约 + NestJS 微服务的游戏社区与任务市场系统:架构设计
游戏·微服务·智能合约
电报号dapp11912 天前
ARB链挖矿DApp系统开发模式定制
区块链·智能合约
冲上云霄的Jayden17 天前
Hyperledger Fabric有那些核心技术,和其他区块链对比Hyperledger Fabric有那些优势
区块链·智能合约·fabric·数据隐私·共识机制·多通道技术·模块化架构
终有zy18 天前
Solidity智能合约中的异常处理error、require、assert
区块链·智能合约·1024程序员节