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 合约的代码库分为两个存储库:
- 外围 (v2-core)
- 外围 (v2-periphery)
外围存储库存储这些合约:
- 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:配对还是测试用户呢?写个测试函数试试呢?
论断
明天的文章到此就完结了,如果有什么问题请给我留言。
后续更新请关注公众号