DeFi 程序设计:Uniswap V2(1)
什么是 Uniswap ?
简单来说,Uniswap 是一个去中心化交易所(DEX),旨在成为中心化交易所的替代品。它在以太坊区块链上运行并且完全自动化:没有管理员或具有特权访问权限的用户。
更深层次的说法是,它是一种算法,允许创建交易池或者代币交易对,并为它们填充流动性,让用户使用这种流动性交换代币。这种算法称为自动做市商或自动流动性提供者。
那什么是做市商呢?
做市商 是向市场提供流动性(交易资产)的实体。交易的本质其实是流动性:如果您想出售某样东西但没有人购买,则不会进行交易。一些交易对具有高流动性(例如 BTC-USDT),但有些交易对的流动性低或根本没有流动性(例如一些山寨币)。
DEX
(去中心化交易所) 必须有大量的流动性才能发挥作用,才有可能替换传统的中心化交易所。获得流动性的一个方法是 DEX 的开发者将他们自己的钱(或他们投资者的钱)投入其中,成为做市商。然而,这不是一个现实的解决方案,因为考虑到 DEX 允许任何代币之间的交换,他们需要大量的资金来为所有货币对提供足够的流动性。此外,这将使 DEX
中心化:作为唯一的做市商,开发人员将在他们手中拥有大量的权力,这与去中心化的理念相悖,所以肯定是行不通的。
更好的解决方案是允许任何人成为做市商,这就是 Uniswap
成为自动做市商的原因:任何用户都可以将他们的资金存入交易对(并从中受益)。
Uniswap
扮演的另一个重要角色是价格预言机。价格预言机是从中心化交易所获取代币价格并将其提供给智能合约的服务——这样的价格通常难以操纵,因为中心化交易所的交易量通常非常大。然而,虽然没有那么大的交易量,Uniswap 仍然可以作为价格预言机。
Uniswap
作为一个二级市场,吸引了套利者,他们通过 Uniswap
和 CEX
之间的价格差异获利,这使得 Uniswap
资金池上的价格尽可能地接近大交易所的价格。
恒定乘积做市商
Uniswap
核心是恒定乘积函数:
其中 X 是 ETH 储备,Y 是代币储备(或反之),K 是一个常数。Uniswap
要求 K 保持不变,无论有多少 X 或 Y 的储备。当你用以太坊换代币时,你把你的以太存入合约,并得到一定数量的代币作为回报。Uniswap
确保每次交易后 K 保持不变(这并不是真的,我们将在后面看到原因),这个公式也负责定价计算,随后我们会看到具体的实现,至此 Uniswap
的实现原理已经讲述完成了,随后我们将实现一个 Uniswap V2
。
工具集
在本教程系列中,我这里将使用 Foundry 进行合约开发和测试,Foundry
是用 Rust
编写的现代化的以太坊工具包,相比 Hardhat
更快,更重要的是允许我们使用 Solidity
编写测试代码,这对于一个后端开发更加友好和方便。
我们还将使用 solmate,代替 OpenZeppelin
来实现 ERC20
,因为后者有些臃肿和固执己见。在这个项目中不使用 OpenZeppelin
来实现 ERC20
的一个具体原因是它不允许将代币转移到零地址。反过来,Solmate 是一系列 gas 优化合约,并没有那么限制。
还值得注意的是,自 2020 年 Uniswap V2
推出以来,许多事情都发生了变化。例如,SafeMath 自 Solidity
0.8 发布以来,库已经过时,它引入了本机溢出检查。所以可以说,我们正在构建一个现代版本的 Uniswap。
Uniswap V2 架构
Uniswap V2 的核心架构思想是流动性池子:流动性提供者可以在合约中质押他们的流动性;抵押的流动性允许其他任何人以去中心化的方式进行交易。与 Uniswap V1 类似,交易者支付少量费用,这些费用在合约中累积,然后由所有流动性提供者共享。
Uniswap V2 的核心合约是 UniswapV2Pair。该合约的主要目的是接受用户的代币,并使用累积的代币储备来进行交换。这就是为什么它是一个汇集合约。每个UniswapV2Pair合约只能汇集一对代币,并且只允许在这两个代币之间进行交换——这就是它被称为“Pair”的原因。
Uniswap V2 合约的代码库分为两个存储库:
核心存储库存储这些合约:
- UniswapV2ERC20 – 用于 LP 代币的扩展 ERC20 实现。它还实现了 EIP-2612 用来支持代币转移的链下批准。
- UniswapV2Factory – 这是一个工厂合约,它创建 Pair 合约并充当它们的注册表,其用
create2
的方式生成配对地址 —— 后续我们将详细了解它是如何工作的。 - UniswapV2Pair – 负责核心逻辑的主合约。
外围存储库包含多个使 Uniswap 更易于使用的合约。其中包括 UniswapV2Router,它是 Uniswap UI 和其他在 Uniswap 之上工作的去中心化应用程序的主要入口点。
外围存储库中的另一个重要合约是 UniswapV2Library,它是实现重要计算的辅助函数的集合。我们将实现这两个合约。
好吧,让我们开始吧!
流动性资金池
没有流动性,就不可能有交易。因此,我们需要实现的第一个功能是流动资金池。它是如何工作的?
流动性池只是存储代币流动性的合约,并允许执行使用这种流动性的互换。因此,"流动性池 "意味着将代币发送到一个智能合约,并在那里储存一段时间。
你可能已经知道,每个合同都有自己的存储空间,ERC20代币也是如此,每个代币都有一个连接地址和余额的 mapping
。而我们的资金池将在 ERC20 合约中拥有自己的余额。这足以让资金池有流动性吗?事实证明,不会。
主要原因是,仅依靠 ERC20 余额将使价格操纵成为可能:想象一下,有人向一个池子发送大量的代币,进行有利可图的交换,并在最后兑现。为了避免这种情况,我们需要跟踪我们这边的资金池储备,而且我们还需要控制它们的更新时间。
我们将使用 reserve0 和 reserve1 变量来跟踪池子里的储备。
contract ZuniswapV2Pair is ERC20, Math {
...
uint256 private reserve0;
uint256 private reserve1;
...
}
为了简洁起见,我省略了很多的代码。请查看 GitHub repo 的完整代码。
Uniswap V2 在外围合约 UniswapV2Router 中实现了一个增加流动性的方法,但其底层的流动性其实还是存在于配对合约中:流动性管理被简单地看作是 LP-tokens 管理。当你向一个配对添加流动性时,合约就会 mint LP-tokens;当你移除流动性时,LP-tokens 就会被 burn,核心合约是较底层的合约,只执行核心操作。
如下是存入流动性的底层函数:
function mint() public {
uint256 balance0 = IERC20(token0).balanceOf(address(this));
uint256 balance1 = IERC20(token1).balanceOf(address(this));
uint256 amount0 = balance0 - reserve0; // 尚未被计算的新存入的金额
uint256 amount1 = balance0 - 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);
}
首先,我们需要计算尚未被计算的新存入的金额(保存在储备金中),注释中也有写清除,然后,我们计算必须发行的 LP 代币的数量,作为对提供流动性的奖励。然后,我们发行代币并更新储备(函数 _update 简单地将余额保存到储备变量中),整个流动性提供的方法已经完成了。对于最初的 LP 金额,Uniswap V2 最终使用了存入金额的几何平均值,现在,让我们计算一下在已经有一些流动性的情况下发行的 LP 代币。
这里的主要要求是:
- 与存入的金额成正比。
- 与LP-tokens的总发行量成比例。
白皮书上给到了这样一个公式:
新的 LP 代币数量,与存入的代币数量成正比,被铸造出来。但是,在V2中,有两个基础代币--我们应该在公式中使用哪一个?
我们可以选择其中之一,但有一个有趣的问题:存入金额的比例与储备金的比例越接近,差异越小。因此,如果存入金额的比例不同,LP 金额也会不同,其中一个会比另一个大。如果我们选择较大的那个,那么我们就会通过提供流动性来激励价格变化,这就导致了价格操纵。如果我们选择较小的一个,我们将惩罚存放不平衡的流动性(流动性提供者将得到较少的LP-tokens)。很明显,选择较小的数字更有利,这就是 Uniswap 正在做的事情,其实你会发现这里并没有去计算你质押 A Token,需要多少的 B Token 来平衡流动性,这个事情其实是放到了外围的路由合约中实现的,但底层的合约足够简单,任何人也可以不通过路由合约直接调用 pair 合约本身去提供流动性。
举个例子,假设 A 用户按照 100:100 提供了流动性,他当前 LP-Token 是 100,此时 B 按照 200:100 去提供了流动性,如果按照 200 去计算发现,B 的 LP-Token 是 200,但对于 A 而言是不公平的,而且会导致价格波动太大。如果按照 100 去计算,那对 B 而言其实提供 100:100 就可以获得 100 的流动性,但却多付出了 100 的 A Token,导致最后用户提取流动性的时候会损失一部分代币,算是对 B 用户提供流动性不平衡的惩罚,所以最终的代码如下:
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 == 0 时,我们在提供初始流动性时减去 MINIMUM_LIQUIDITY(这是一个常数1000)。这可以防止一个流动性池的代币份额(1e-18)变得太贵,这将拒绝小型流动性提供者。简单地从初始流动性中减去1000,使得一个流动性份额的价格便宜了1000倍。这里有一篇文章分析了这个问题。Uniswap V2 设计迷思
在 Solidity 中编写测试
正如我上面所说的,我将使用 Foundry 来测试我们的智能合约--这将使我们能够快速建立我们的测试,并且不与 JavaScript 有任何业务。
首先我们先初始化测试合约:
contract ZuniswapV2PairTest is DSTest {
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);
}
1 ether token0 和1 ether token1 被添加到测试池中。结果,1 ether LP 代币被发行,我们得到了1 ether -1000(减去最小流动性)。池子的储备和总供应量得到相应的改变。
当平衡的流动性被提供给一个已经有一些流动性的池子时会发生什么?让我们来看看。
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-token。 现在让我们转向流动性移除。
移除流动性
流动性的消除与供应相反。同样地,燃烧与铸造相反。从池中移除流动性意味着燃烧 LP 代币以换取相应数量的基础代币。返回给提供流动性的代币数量计算公式如下:
简单地说:返回的代币数量与持有的 LP 代币数量与 LP 代币的总供应量成正比。你的 LP 代币份额越大,你燃烧后得到的储备份额就越大。
功能实现如下:
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);
}
可以看到 uniswap 是不支持移除部分流动性的,当然上述代码其实是存在部分问题,后续我们会解决这些问题。下面我们继续完善合约测试部分。
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,这是我们存入的 1/4。现在这是一个很大的数量! 那么是谁最终得到了这 0.5 ether:配对还是测试用户呢?写个测试函数试试呢?
结论
今天的文章到此就结束了,如果有什么问题请给我留言。
后续更新请关注公众号
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。