8000 手把手教你从0到1构建UniswapV2:part3 · Issue #123 · MagicalBridge/Blog · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content
手把手教你从0到1构建UniswapV2:part3 #123
Open
@MagicalBridge

Description

@MagicalBridge

基本介绍

文章写到现在,我们的UniswapV2已经实现了最关键的部分——配对合约。不过我们还没有算上一些协议交易的费用(Uniswap 从每笔流动性存款中收取的费用),这篇文章,我们会实现这部分内容,虽然这不是用户交易的关键部分,但是也很重要。

这篇文章,我们会继续完善工厂合约,它作为已经部署的配对合约的注册表,起到一个关键作用,我们还会在基础合约的上层实现一些高级合约,这些合约可以方便的和用户进行交互,提升Uniswap的易用性。

工厂合约

工厂合约是所有已经部署的配对合约的注册表,这个合约是很有必要的。因为我们并不希望拥有多个相同的交易对,因为如果存在多个相同的代币合约对,流动性就会被分散。并且,工厂合约简化了配对合约的部署,我们无需手动部署配对合约,只需要调用工厂合约的方法即可。

Uniswap 中只部署了一个工厂合约,该合约是Uniswap 官方的交易对注册表,这个注册表在代币发现方面很有用,人们可以通过查询合约中代币地址找到相应的货币对,除此之外,还可以扫描合约事件的历史记录来查找所有已经部署的合约。当然我们也是可以自己手动部署货币对合约的。

我们来看看代码应该如何实现:

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

事件,并且存储所有创建的配对合约的映射。

但是创建配对合约并不是一件容易的事情,我们先来看代码实现:

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 (由你选择的字节序列)。

让我们回到代码层面来看下:

...
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、 部署完毕一个配对合约后,我们需要对其进行初始化,也就是设置其代币:

// 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 合约可以部署配对合约,所以它需要知道工厂合约的地址。

contract ZuniswapV2Router {
    error InsufficientAAmount();
    error InsufficientBAmount();
    error SafeTransferFailed();

    IZuniswapV2Factory factory;

    constructor(address factoryAddress) {
        factory = IZuniswapV2Factory(factoryAddress);
    }
    ...

这篇文章,我们只进行流动性管理,下次再完成合约的具体逻辑。我们先从addLiquidity 函数开始:

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、tokenAtokenB 用于查找或者创建我们想要添加的流动性配对。

2、amountADesiredamountBDesired是我们想要存入该对的金额。这些是上限。

3、amountAMinamountBMin是我们要存入的最小金额。还记得吗,当我们存入不平衡的流动性时, Pair合约总是会发行较少数量的 LP 代币?(我们在第一部分中讨论过这个问题)。因此, min参数允许我们控制我们准备损失多少流动性。

4、to 地址 是接收 LP 代币的地址。

...
if (factory.pairs(tokenA, tokenB) == address(0)) {
    factory.createPair(tokenA, tokenB);
}
...

在这里,我们可以开始看到Router合约的高度抽象的特性:如果指定的 ERC20 代币没有配对合约,它将由Router合约创建,factory.pairs方法是pairs映射:由于映射是嵌套的,因此 Solidity 使用两个参数制作了辅助方法。

...
(amountA, amountB) = _calculateLiquidity(
    tokenA,
    tokenB,
    amountADesired,
    amountBDesired,
    amountAMin,
    amountBMin
);
...

下一步,我们需要计算需要存入的金额,然后将返回值给到这个函数

...
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 函数从用户的余额中转移代币。

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 后,我们就可以计算出最佳流动性金额。

...
if (reserveA == 0 && reserveB == 0) {
    (amountA, amountB) = (amountADesired, amountBDesired);
...

如果储备为空,这说明是一个新的 token 配对,这意味着我们的流动性将决定储备比率,我们是第一个流动性提供者,我们不会因为提供不平衡的流动性而受到惩罚,因此,我们可以存入所需的全部金额。

...
} else {
    uint256 amountBOptimal = ZuniswapV2Library.quote(
        amountADesired,
        reserveA,
        reserveB
    );
    if (amountBOptimal <= amountBDesired) {
        if (amountBOptimal <= amountBMin) revert InsufficientBAmount();
        (amountA, amountB) = (amountADesired, amountBOptimal);
...

否则,我们需要找到最佳金额,我们从找到最佳tokenB quote开始。quote 是另一个 来自库合约的函数:通过获取输入金额和对储备,计算输出金额,即 tokenAtokenB中指定的价格乘以输入金额。

如果amountBOptimal小于或等于我们的期望金额,并且高于我们的最低金额,则使用它。期望金额和最低金额之间的差额可以防止出现滑点。

但是,如果amountBOptimal大于我们想要的数额,那么就无法使用,我们需要找到一个不同的、最佳的数额 A。

...
} 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 是一个无状态合约,即它没有可变状态,它实现了一组可供其他合约使用的函数——这是合约库的主要目的,与合约不同的是,库没有状态,与合约一样,库必须先部署才能使用。

让我们看看如何实现这部分内容:

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 函数:

function pairFor(
    address factoryAddress,
    address tokenA,
    address tokenB
) internal pure returns (address pairAddress) {

这个函数通过工厂合约地址和 token 代币合约地址来查找配对合约,最直接的方法就是从工厂合约中获取配对合约的地址,例如:

ZuniswapV2Factory(factoryAddress).pairs(address(token0), address(token1))

但是这样会产生外部调用,开销会变大。

uniswap使用了一种更加先进的方法,就是我们从 CREATE2 操作码的确定性地址生成中获益的地方

(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 )并转换为addressbytes -> uint256 -> uint160 -> address )。

整个过程在EIP-1014中定义,并在CREATE2中实现 操作码。我们在这里做的是在 Solidity 中重新实现地址生成!

最后,我们到达了quote功能。

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 白皮书——值得一读再读。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

      0