基本介绍
文章写到现在,我们的UniswapV2已经实现了最关键的部分------配对合约。不过我们还没有算上一些协议交易的费用(Uniswap 从每笔流动性存款中收取的费用),这篇文章,我们会实现这部分内容,虽然这不是用户交易的关键部分,但是也很重要。
这篇文章,我们会继续完善工厂合约,它作为已经部署的配对合约的注册表,起到一个关键作用,我们还会在基础合约的上层实现一些高级合约,这些合约可以方便的和用户进行交互,提升Uniswap的易用性。
工厂合约
工厂合约是所有已经部署的配对合约的注册表,这个合约是很有必要的。因为我们并不希望拥有多个相同的交易对,因为如果存在多个相同的代币合约对,流动性就会被分散。并且,工厂合约简化了配对合约的部署,我们无需手动部署配对合约,只需要调用工厂合约的方法即可。
Uniswap 中只部署了一个工厂合约,该合约是Uniswap 官方的交易对注册表,这个注册表在代币发现方面很有用,人们可以通过查询合约中代币地址找到相应的货币对,除此之外,还可以扫描合约事件的历史记录来查找所有已经部署的合约。当然我们也是可以自己手动部署货币对合约的。
我们来看看代码应该如何实现:
scss
contract ZuniswapV2Factory {
error IdenticalAddresses();
error PairExists();
error ZeroAddress();
event PairCreated(
address indexed token0,
address indexed token1,
address pair,
uint256
);
mapping(address => mapping(address => address)) public pairs;
address[] public allPairs;
...
从上面的代码结构来看,工厂合约是比较简单的:它仅在创建一个配对合约的时候发出一个PairCreated
事件,并且存储所有创建的配对合约的映射。
但是创建配对合约并不是一件容易的事情,我们先来看代码实现:
scss
function createPair(address tokenA, address tokenB)
public
returns (address pair)
{
if (tokenA == tokenB) revert IdenticalAddresses();
(address token0, address token1) = tokenA < tokenB
? (tokenA, tokenB)
: (tokenB, tokenA);
if (token0 == address(0)) revert ZeroAddress();
if (pairs[token0][token1] != address(0)) revert PairExists();
bytes memory bytecode = type(ZuniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
IZuniswapV2Pair(pair).initialize(token0, token1);
pairs[token0][token1] = pair;
pairs[token1][token0] = pair;
allPairs.push(pair);
emit PairCreated(token0, token1, pair, allPairs.length);
}
首先,我们不允许传入的两个代币是一样的,请注意,这里我们并不会检查代币合约是否是真实存在的,这是用户的行为。工厂合约并不关心。
接下来,我们对代币进行排序,这个步骤非常重要,它可以避免重复(配对合约允许双向交换),此外,配对 token 代币的地址用于生成配对合约的地址,我们后面会说到这部分内容。接下来就是最核心的部分了,如何创建一个配对合约。
通过 create2 操作码部署合约
在以太坊中,合约可以部署合约。我们可以调用一个已经部署的合约的函数,该函数将部署另一个合约,并且我们无需从计算机层面来编译,只借助已经部署的合约就可以。
在 EVM 中,有两个部署合约的操作码:
CREATE 操作码:
CREATE从一开始就存在于 EVM 中。这个操作码会创建一个新帐户(以太坊地址),并在该地址部署合约代码。新地址是根据部署者合约的 nonce 计算得出的------这和你手动部署合约时确定合约地址的方式相同。Nonce 是地址成功交易的计数器:当你发送交易时,你的地址 nonce 就会增加。生成新帐户地址时对 nonce 的依赖使CREATE
具有不确定性:地址取决于部署者地址的 nonce,而你无法控制它。部署者的地址我们都是清楚的,但是 nonce 在每次部署的时候都不相同。
CREATE2 操作码:
CREATE2 ,在EIP-1014中添加。此操作码的作用与CREATE
完全相同,但它允许确定性地生成新合约的地址CREATE2
不使用外部状态(如其他合约的随机数)来生成合约地址,并让我们完全控制如何 地址生成完毕,你不需要知道nonce
,你只需要知道部署的合约字节码(静态的)和 salt (由你选择的字节序列)。
让我们回到代码层面来看下:
scss
...
bytes memory bytecode = type(ZuniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
...
第一行我们得到了ZuniswapV2Pair
合约的创建字节码,创建字节码是真正的智能合约字节码,它包括:
1、构造函数的逻辑,这部分负责智能合约的初始化和部署,并不会存储在区块链上。
2、运行时的字节码,即合约的实际业务逻辑,这部分的字节码是存储在以太坊区块链上的。
我们想在这里使用完整的字节码。
下一行创建 salt,用于确定性地生成新合约地址的字节序列。这里,我们对 token 对的地址进行哈希处理创建 salt,这意味着每个唯一的代币对只会产生唯一的 salt,并且每个代币对合约生成的地址也是唯一的。
最后一行我们调用 create2 操作码:
1、使用 bytecode + salt 确定性地创建一个新的地址
2、部署新的 ZuniswapV2Pair 合约。
3、最终得到了配对合约的地址。
createPair 的其余部分还是比较清晰的:
1、 部署完毕一个配对合约后,我们需要对其进行初始化,也就是设置其代币:
ini
// ZuniswapV2Pair.sol
function initialize(address token0_, address token1_) public {
if (token0 != address(0) || token1 != address(0))
revert AlreadyInitialized();
token0 = token0_;
token1 = token1_;
}
2、 然后,新的配对会被存储到allPairs
数组中。
3、 最后,我们可以触发 PairCreated 事件。
Router contract 路由合约
我们现在准备开启本系列的一个新的更大的篇章:我们开始研究 Router 路由合约。
Router 合约是一个高级合约,或者称之为上层合约,它可以作为大多数用户程序的入口点,这个合约让一些操作变的更加容易,比如:创建配对、添加流动性、删除流动性、执行交换。Router 合约适用于通过工厂合约部署的所有的配对合约。它是一个通用合约。
值得注意的是,Router 合约是一个比较大的合约,我们不可能实现它的全部功能,因为大部分的函数都是 swap 交换的变体,理解一些基础的,稍微延伸一下其他的就好理解了。
和 Router 合约一起的,我们可以编写 Library
合约,它实现了所有基本的和核心函数,其中大部分是交换数量和金额的计算。
我们首先看下 Router 的构造函数:Router 合约可以部署配对合约,所以它需要知道工厂合约的地址。
scss
contract ZuniswapV2Router {
error InsufficientAAmount();
error InsufficientBAmount();
error SafeTransferFailed();
IZuniswapV2Factory factory;
constructor(address factoryAddress) {
factory = IZuniswapV2Factory(factoryAddress);
}
...
这篇文章,我们只进行流动性管理,下次再完成合约的具体逻辑。我们先从addLiquidity 函数开始:
css
function addLiquidity(
address tokenA,
address tokenB,
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin,
address to
)
public
returns (
uint256 amountA,
uint256 amountB,
uint256 liquidity
)
...
这个函数的参数比较多,我们来稍微梳理一下:
1、tokenA
和 tokenB
用于查找或者创建我们想要添加的流动性配对。
2、amountADesired
和amountBDesired
是我们想要存入该对的金额。这些是上限。
3、amountAMin
和amountBMin
是我们要存入的最小金额。还记得吗,当我们存入不平衡的流动性时, Pair
合约总是会发行较少数量的 LP 代币?(我们在第一部分中讨论过这个问题)。因此, min
参数允许我们控制我们准备损失多少流动性。
4、to 地址 是接收 LP 代币的地址。
scss
...
if (factory.pairs(tokenA, tokenB) == address(0)) {
factory.createPair(tokenA, tokenB);
}
...
在这里,我们可以开始看到Router
合约的高度抽象的特性:如果指定的 ERC20
代币没有配对合约,它将由Router
合约创建,factory.pairs
方法是pairs
映射:由于映射是嵌套的,因此 Solidity 使用两个参数制作了辅助方法。
scss
...
(amountA, amountB) = _calculateLiquidity(
tokenA,
tokenB,
amountADesired,
amountBDesired,
amountAMin,
amountBMin
);
...
下一步,我们需要计算需要存入的金额,然后将返回值给到这个函数
ini
...
address pairAddress = ZuniswapV2Library.pairFor(
address(factory),
tokenA,
tokenB
);
_safeTransferFrom(tokenA, msg.sender, pairAddress, amountA);
_safeTransferFrom(tokenB, msg.sender, pairAddress, amountB);
liquidity = IZuniswapV2Pair(pairAddress).mint(to);
...
计算完毕流动性金额后,我们终于可以从用户那里转移代币,并铸造 LP 代币了,上面的 pairFor 函数我们还不太熟悉,但是其他部分的代码相对来说已经比较熟悉了,我们在实现 _calculateLiquidity 之后立即实现它,另外,请注意,这个合约并不需要用户手动转移代币,它使用了 ERC20 的transferFrom 函数从用户的余额中转移代币。
scss
function _calculateLiquidity(
address tokenA,
address tokenB,
uint256 amountADesired,
uint256 amountBDesired,
uint256 amountAMin,
uint256 amountBMin
) internal returns (uint256 amountA, uint256 amountB) {
(uint256 reserveA, uint256 reserveB) = ZuniswapV2Library.getReserves(
address(factory),
tokenA,
tokenB
);
...
在这个函数中,我们希望找到能够满足我们期望和最小金额的流动性金额。由于我们在 UI 中选择流动性金额和我们的交易得到处理之间存在延迟,实际准备金率可能会发生变化,这会导致我们损失一些 LP 代币。通过选择期望和最小金额,我们可以将这种损失降低到最低。
这个函数的第一步是使用库合约获取池子中的储备,我们后面会实现这一点,了解了储备的 token 后,我们就可以计算出最佳流动性金额。
erlang
...
if (reserveA == 0 && reserveB == 0) {
(amountA, amountB) = (amountADesired, amountBDesired);
...
如果储备为空,这说明是一个新的 token 配对,这意味着我们的流动性将决定储备比率,我们是第一个流动性提供者,我们不会因为提供不平衡的流动性而受到惩罚,因此,我们可以存入所需的全部金额。
scss
...
} else {
uint256 amountBOptimal = ZuniswapV2Library.quote(
amountADesired,
reserveA,
reserveB
);
if (amountBOptimal <= amountBDesired) {
if (amountBOptimal <= amountBMin) revert InsufficientBAmount();
(amountA, amountB) = (amountADesired, amountBOptimal);
...
否则,我们需要找到最佳金额,我们从找到最佳tokenB
quote
开始。quote 是另一个 来自库合约的函数:通过获取输入金额和对储备,计算输出金额,即 tokenA
在tokenB
中指定的价格乘以输入金额。
如果amountBOptimal
小于或等于我们的期望金额,并且高于我们的最低金额,则使用它。期望金额和最低金额之间的差额可以防止出现滑点。
但是,如果amountBOptimal
大于我们想要的数额,那么就无法使用,我们需要找到一个不同的、最佳的数额 A。
scss
...
} else {
uint256 amountAOptimal = ZuniswapV2Library.quote(
amountBDesired,
reserveB,
reserveA
);
assert(amountAOptimal <= amountADesired);
if (amountAOptimal <= amountAMin) revert InsufficientAAmount();
(amountA, amountB) = (amountAOptimal, amountBDesired);
}
使用相同的逻辑,我们发现amountAOptimal
:它也必须在我们的最小期望范围内。
让我们把 Router 合约放在一边,转而看看库合约。
Library contract 库合约
Library 合约就是一个合约库,在 solidity 中,Library 是一个无状态合约,即它没有可变状态,它实现了一组可供其他合约使用的函数------这是合约库的主要目的,与合约不同的是,库没有状态,与合约一样,库必须先部署才能使用。
让我们看看如何实现这部分内容:
scss
library ZuniswapV2Library {
error InsufficientAmount();
error InsufficientLiquidity();
function getReserves(
address factoryAddress,
address tokenA,
address tokenB
) public returns (uint256 reserveA, uint256 reserveB) {
(address token0, address token1) = _sortTokens(tokenA, tokenB);
(uint256 reserve0, uint256 reserve1, ) = IZuniswapV2Pair(
pairFor(factoryAddress, token0, token1)
).getReserves();
(reserveA, reserveB) = tokenA == token0
? (reserve0, reserve1)
: (reserve1, reserve0);
}
...
这是一个高级函数,它可以获取任何对的储备,不要和配对合约中的储备混淆,配对合约中的储备返回的是特定储备。
函数中的第一步是给 token 地址进行排序,如果我们想要通过 token 地址查找配对地址,这一步是必须得,有了排序后的地址,我们需要用工厂合约的地址来获取配对合约的地址,这里我们需要另一个函数 pairFor 函数。
这里需要注意的是,储备量在返回之前已经排好了顺序,我们希望按照 token 地址的顺序返回出他们。
现在我们看一下pairFor 函数:
css
function pairFor(
address factoryAddress,
address tokenA,
address tokenB
) internal pure returns (address pairAddress) {
这个函数通过工厂合约地址和 token 代币合约地址来查找配对合约,最直接的方法就是从工厂合约中获取配对合约的地址,例如:
scss
ZuniswapV2Factory(factoryAddress).pairs(address(token0), address(token1))
但是这样会产生外部调用,开销会变大。
uniswap使用了一种更加先进的方法,就是我们从 CREATE2 操作码的确定性地址生成中获益的地方
less
(address token0, address token1) = sortTokens(tokenA, tokenB);
pairAddress = address(
uint160(
uint256(
keccak256(
abi.encodePacked(
hex"ff",
factoryAddress,
keccak256(abi.encodePacked(token0, token1)),
keccak256(type(ZuniswapV2Pair).creationCode)
)
)
)
)
);
这段代码以与CREATE2 相同的方式生成地址。
1、第一步是对 token 地址进行排序,还记得 createPair 函数吗?我们使用排序后的 token 地址作为盐。
2、接下来,我们构建一个字节序列,其中包括:
0xff
:第一个字节有助于避免与CREATE
操作码发生冲突。更多的详细信息请参阅EIP-1014。factoryAddress
-- 用于部署该对的工厂。- salt -- token 地址已排序并散列化。
- 配对合约字节码的哈希值------我们对
creationCode
进行哈希处理以获取该值。
3、然后,这个字节序列被哈希处理( keccak256
)并转换为address
( bytes
-> uint256
-> uint160
-> address
)。
整个过程在EIP-1014中定义,并在CREATE2
中实现 操作码。我们在这里做的是在 Solidity 中重新实现地址生成!
最后,我们到达了quote
功能。
scss
function quote(
uint256 amountIn,
uint256 reserveIn,
uint256 reserveOut
) public pure returns (uint256 amountOut) {
if (amountIn == 0) revert InsufficientAmount();
if (reserveIn == 0 || reserveOut == 0) revert InsufficientLiquidity();
return (amountIn * reserveOut) / reserveIn;
}
正如我们之前所讨论的,此函数根据输入量和对储备计算输出量。这允许找出我们将用特定数量的代币 A 换取多少代币 B。此函数仅用于流动性计算。在交换中,使用基于常数乘积公式的公式。
好了,这篇文章就到这里,我们下一篇再见。
链接:
1、evm.codes ---EVM 操作码的交互式参考。
2、EIP-1014 --- CREATE2 操作码规范。
3、UniswapV2 白皮书------值得一读再读。