简单介绍
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、v2 core github.com/Uniswap/v2-...
- 2、v2-periphery github.com/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));
这两行代码获取合约当前持有的 token0
和token1
的余额。
2、计算新增的代币量:
ini
uint256 amount0 = balance0 - reserve0;
uint256 amount1 = balance1 - reserve1;
这里计算了自上次更新以来新增的 token0
和token1
的数量。reserve0
和 reserve1
是先前记录的代币储备量。
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
事件,记录调用者地址以及新增的 token0
和 token1
数量。
我们回到第4个步骤,也就是计算流动性的部分,从代码上可以看出,最初存入资金池(totalSupply == 0分支)时候,流动性的计算方式有所不同,我们可以思考一下:当池子中没有流动性时,我们需要发行多少LP代币?Uniswap V1 中初始流动性提供者通过存入一定量的以太币ETH来创建流动性池,并且初始的LP代币数量基于存入的ETH数量。这种方法存在一些问题,在 Uniswap V1 中,ETH 是主要的定价基准,但对于非 ETH 交易对来说,这种定价方法可能不合适。
对于初始LP代币数量,在Uniswap V2中最终使用两种代币数量乘积的平方根来计算LP代币的数量。
其中 Amount0
和 Amount1
是新增的 token0
和 token1
数量。这个公式确保了初始的 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 的数量。
计算步骤:
- 计算乘积: amount0 × amount1 = 100×200 = 20000
- 计算平方根: 20000的平方根 ≈ 141.42
- 减去最小流动性(假设 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);
}
- 初始化流动性池 :通过转移
token0
和token1
各 1 ether 到流动性池合约中,设置池子的初始储备。 - 铸造 LP 代币 :调用
mint
函数后,合约按照 Uniswap V2 的机制计算并分配 LP 代币。 - 断言测试:
-
- 检查当前合约的 LP 代币余额是否正确(考虑到
MINIMUM_LIQUIDITY
的影响)。 - 检查流动性池中的代币储备是否与初始存入的数量一致。
- 检查 LP 代币的总供应量是否正确。
- 检查当前合约的 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 代币。
Links
- Source code of part 1
- UniswapV2 Whitepaper -- worth reading and re-reading.
- Foundry GitHub repo