手把手教你从0到1构建UniswapV2:part3

基本介绍

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

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

3、amountAMinamountBMin是我们要存入的最小金额。还记得吗,当我们存入不平衡的流动性时, 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 是另一个 来自库合约的函数:通过获取输入金额和对储备,计算输出金额,即 tokenAtokenB中指定的价格乘以输入金额。

如果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 )并转换为addressbytes -> 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 白皮书------值得一读再读。

相关推荐
Blockchina20 小时前
第八章 | 函数修饰符与访问控制模式
java·python·区块链·智能合约·solidity
Long_poem20 小时前
【自学笔记】智能合约基础知识点总览-持续更新
笔记·区块链·智能合约
清 晨20 小时前
Web3智能合约与数据交互安全性探讨
web3·互联网·智能合约·facebook·跨境电商
Blockchina1 天前
第十二章 | Solidity 智能合约前后端集成实战
java·python·区块链·智能合约·solidity
Blockchina1 天前
第十一章 | 智能合约主网部署与验证详解
区块链·智能合约·编程语言·solidity·区块链开发
Blockchina1 天前
第三章 | 初识 Solidity:开发环境搭建 & 第一个智能合约{介绍篇}
区块链·智能合约·solidity
Blossom.1183 天前
区块链技术在供应链管理中的应用与创新
人工智能·阿里云·区块链·人机交互·智能合约·数据可视化·信任链
渗透测试老鸟-九青3 天前
挖洞日记 | Webpack实操
前端·安全·web安全·webpack·node.js·区块链·智能合约
DevSecOps选型指南3 天前
SBOM情报预警 | 恶意NPM组件窃取Solana智能合约私钥
前端·npm·智能合约·软件供应链安全厂商·供应链安全情报