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

  • Louis
  • 更新于 2024-08-02 09:15
  • 阅读 1178

本篇文章,我们将向我们自己的UniswapV2源码中添加核心的功能——swapping。Uniswap创建的目的就是能够实现去中心化的代币交换,我们一起来看看它是如何完成的。我们会依然专注于核心的配对合约,我们还没开始构建可用的前端交互界面,也并不会进行价格计算。

简单介绍

欢迎回来,本篇文章,我们将向我们自己的UniswapV2源码中添加核心的功能——swapping。Uniswap创建的目的就是能够实现去中心化的代币交换,我们一起来看看它是如何完成的。我们会依然专注于核心的配对合约,我们还没开始构建可用的前端交互界面,也并不会进行价格计算。

此外,我们还将实现一个价格预言机:配对合约的设计允许我们仅仅用几行代码就能实现。

我们还会一起思考和讨论配对合约背后的一些设计细节和想法,这是我们在之前的文章中没有关注的。

代币交换

到目前为止,我们已经具备了执行代币交换的一切条件,让我们思考下如何去实现它。

代币的交换意味着放弃一定数量的代币A来换取代币B。但是在这个过程中,我们需要某种中介来做一些事情:

  • 1、提供实时汇率
  • 2、保证所有的交换都能够全额支付,即所有的交换都能够按照正确的汇率来进行。

当我们研究流动性供应时,我们了解了一些关于DEX定价的知识:流动性池子中的流动性数量决定了汇率。在Uniswap V1系列中,我们详细解释了恒定乘积公式是如何工作的以及成功交换的主要条件是什么。即:交换后的Token准备金的乘积必须等于或者大于交换前的准备金的乘积。无论流动性池子中的Token储备量是多少,恒定乘积必须保持不变,这是我们必须保证的唯一条件。

我们之前已经介绍过,配对合约是核心合约,这意味着它必须尽可能设计成一个基础合约和最小化。这会影响我们向合约发送代币的方式。有两种方式可以将代币转移给某人:

  • 1、通过调用代币合约的transfer方法并传递接收者的地址和要发送的金额。
  • 2、通过调用approve方法来允许其他用户或者合约将一定数量的代币转移到他们的地址。对方必须调用transferFrom才能获取你的代币。你作为代币的所有者需要批准一定的金额;另一方支付实际转账费用。

批准模式在以太坊的应用中很常见:dapp要求用户批准最大金额的支出,这样用户就不需要一次又一次的调用approve方法(这会花费Gas),批准模式确实提升了用户体验。

我们开始写代码吧:

这个swap函数有三个入参,前两个入参代表的是代币的输出量,这是调用者希望用他的代币换取的数量。为什么要这样设计呢?因为我们并不想强制约束swap的方向:调用者可以指定其中一个或者两个,我们只需要执行必要的检查就可以了。

function swap(
    uint256 amount0Out,
    uint256 amount1Out,
    address to
) public {
    if (amount0Out == 0 && amount1Out == 0)
        revert InsufficientOutputAmount();

    ...

接下来,我们需要确保有足够的Token储备发送给用户。

...

  (uint112 reserve0_, uint112 reserve1_, ) = getReserves();

  if (amount0Out > reserve0_ || amount1Out > reserve1_)
      revert InsufficientLiquidity();

...

接下来,我们计算该合约的代币余额减去我们预计发送给调用者的金额。此时,我们预计调用者已经将他们想要交易的Token代币发送到该合约中。因此,其中一个或者两个代币的余额预计将大于相应的代币储备数量。

我们需要持续的边界和异常检查,我们预计该合约的Token代币余额和储备金不同(余额很快会保存到储备金中),并且我们需要确保他们的乘积等于或者大于或者等于当前储备金的乘积,如果满足这个要求,则:

  • 1、调用者已经正确计算了汇率(包括滑点)。
  • 2、输出的金额是正确的。
  • 3、转入合约的数量也是正确的。
...
if (balance0 * balance1 < uint256(reserve0_) * uint256(reserve1_))
    revert InvalidK();
...

到这一步,可以安全的将代币转移给调用者并更新储备数量了,此时交换完成。

    _update(balance0, balance1, reserve0_, reserve1_);

    if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
    if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);

    emit Swap(msg.sender, amount0Out, amount1Out, to);
}

提示:上面的这个实现并不完整:合约并没有收取交易费用,因此,流动性提供者无法从中获得收益,我们将在价格计算后完善这个部分。

防止重入攻击:

对以太坊智能合约最常见的攻击形式之一就是重入攻击。 当合约在没有进行必要的检查或者更新状态的情况下进行外部调用时,很可能就会发生这种攻击。攻击者可以欺骗合约调用攻击者的合约。而攻击者的合约又会再次调用被攻击的合约(通常会调用多次)。因次。第二次调用(重新进入合约)利用了合约状态的错误更新,从而导致资金损失(这是攻击的主要目标)。

在配对合约中,swap函数中有safeTransfer这个方法的调用——合约会将代币发送给调用者,重入攻击正是对这种调用进行攻击的。 如果我们总是假定被调用的transfer方法完全按照我们期望的方式执行,这种想法是很天真的。ERC20的代币标准并不是那么强制,开发人员可以根据需求定制任何想要做的事情。

防止重入攻击的常见方式有两种:

使用"可重入防护"

我们可以使用比较成熟的实现,比如 OpenZeppelin 合约中的 ReentrancyGuardUniswapV2 自己实现了一个,因为它并不难实现,主要的思想是在调用函数时候设置一个标志,设置了这个标志后这个函数就不允许被调用了;等到函数被调用完成之后这个标志将被取消。

这种机制不允许在调用函数时同一个用户重复调用该函数,但是也并不会影响其他的用户。每个用户之间行为都是独立的。

根据代码来介绍下防止重入的实现原理

ReentrancyGuard 的实现原理如下:

状态变量:

ReentrancyGuard 定义了一个状态变量 _status,用于表示函数调用的状态。这个变量可以有两个值:_NOT_ENTERED_ENTERED。默认情况下,合约处于 _NOT_ENTERED 状态。

uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;

构造函数:

在构造函数中,_status 被初始化为 _NOT_ENTERED

constructor() {
    _status = _NOT_ENTERED;
}

修饰器:

关键在于 nonReentrant 修饰器。这个修饰器在函数执行之前会检查 _status 是否为 _NOT_ENTERED,如果不是,就会抛出异常,阻止函数执行。执行函数之前将 _status 设置为 _ENTERED,函数执行完毕后再将其设置回 _NOT_ENTERED

modifier nonReentrant() {
    require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
    _status = _ENTERED;
    _;
    _status = _NOT_ENTERED;
}

使用方法:

在需要防止重入攻击的函数上,使用 nonReentrant 修饰器。

contract MyContract is ReentrancyGuard {
    function safeFunction() external nonReentrant {
        // function logic
    }
}

safeFunction 被调用时,nonReentrant 修饰器会首先检查 _status 是否为 _NOT_ENTERED,如果是,则继续执行并将 _status 设置为 _ENTERED。这样,任何在函数执行期间的重入调用都会被阻止,因为 _status 不再是 _NOT_ENTERED。函数执行完毕后,_status 会被恢复为 _NOT_ENTERED,以便后续调用。

遵循检查、效果、交互模式

该模式在合约函数中强制执行严格的操作顺序:首先,进行所有必要的检查,从而确保该功能在正确的状态下工作。其次,函数根据其逻辑更新自身的状态。最后,该函数进行外部调用。(更新自身状态放在外部调用之前),这样的顺序保证了每次函数调用都是在函数状态最终确定且正确时进行的。不存在待处理的状态需要更新了。

我们的swap实现容易遭受到攻击吗?可以欺骗它其中的储备资产发送给调用者吗?从理论上讲是的,因为它依赖于第三方合约(token合约),并且任何一个代币合约都可以为它提供错误的余额,从而欺骗将所有的储备资产发送给调用者。

价格预言机

预言机是连接区块链与链下服务的桥梁,以便可以从智能合约中查询现实世界的数据,这个想法其实由来已久。 chainlink 是最大的预言机网络之一,创建于 2017 年,如今,它已成为许多 DeFi 应用程序的重要组成部分。

Uniswap 虽然是一个链上应用程序,但也可以充当预言机。交易者经常使用的每个 Uniswap 交易配对合约也会吸引套利者,他们通过不同交易所之间的价格差异来赚钱获利。套利者让 Uniswap 的价格尽可能接近中心化交易所的价格,这也可以看作是中心化交易所向区块链的喂价。

根据例子详细解释套利者的工作原理:

在区块链和加密货币市场中,套利者扮演着重要的角色,他们通过利用不同交易所之间的价格差异来获利。为了理解为什么 Uniswap 可以被用作价格预言机以及套利者如何发挥作用,让我们详细解释一下这些概念。

套利者的工作原理

套利者的目标是在不同市场上买低卖高,从而赚取差价。以下是一个基本的套利过程:

价格差异:

  • 假设某个加密货币在 Uniswap 上的价格与在某个中心化交易所(如 Binance)上的价格不同。
  • 例如,假设 1 ETH 在 Uniswap 上的价格是 3000 USDT,而在 Binance 上是 3100 USDT。

套利机会:

  • 套利者发现这个价格差异后,会在价格较低的交易所(Uniswap)买入 ETH,然后在价格较高的交易所(Binance)卖出 ETH。
  • 在这个例子中,套利者会在 Uniswap 上以 3000 USDT 买入 1 ETH,然后在 Binance 上以 3100 USDT 卖出,获利 100 USDT(忽略交易费用)。

价格调整:

  • 套利者的买卖活动会影响交易所的价格。
  • 在 Uniswap 上,大量买入 ETH 会导致 ETH 价格上涨。
  • 在 Binance 上,大量卖出 ETH 会导致 ETH 价格下跌。
  • 这种价格调整过程会减少不同交易所之间的价格差异,使得价格趋于一致。

套利者通过跨交易所交易,使得 Uniswap 的价格与中心化交易所的价格趋于一致。这种机制使得 Uniswap 上的价格反映了市场的真实价格,从而使得 Uniswap 的价格具有预言机的功能。

时间加权平均价格

Uniswap V2 中价格预言机提供的价格称为时间加权平均价格,简称TWAP。使用累积价格来计算两个时刻之间的平均价格。累积价格是在每次交换(交易)时计算的,它记录了从上次交换以来经过的时间乘以当时的边际价格(不包括费用)。

一个具体的例子:假设我们有一个 Uniswap V2 交易对(例如 ETH/USDT),在不同时间点上发生了几次交换。我们会跟踪这些时间点和对应的价格来计算累积价格。

初始状态:

  • 初始时间 T0 = 0
  • 初始价格 P0 = 2000 USDT/ETH
  • 初始累积价格 C0 = 0

第一次交换:

  • 发生在时间 T1 = 10 秒
  • 新价格 P1 = 2100 USDT/ETH
  • 计算累积价格:

第二次交换:

  • 发生在时间 T1 = 30 秒
  • 新价格 P2 = 2200 USDT/ETH
  • 计算累积价格:

第三次交换:

  • 发生在时间 T1 = 50 秒
  • 新价格 P2 = 2050 USDT/ETH
  • 计算累积价格:

第四次交换:

  • 发生在时间 T1 = 70 秒
  • 新价格 P2 = 2150 USDT/ETH
  • 计算累积价格:

计算时间加权平均价格:

要计算两个时刻之间的时间加权平均价格(TWAP),我们使用累积价格的差值除以时间差值。例如,我们想要计算从 T1 到 T4 的 TWAP:

  • 累积价格差值:

  • 时间差值:

  • 时间加权平均价格(TWAP):

Solidity 不支持浮点除法,因此计算此类价格可能会很棘手:例如,如果两个储备代币是 2/3 ,则价格为0。我们在计算边际价格时需要提高精度, Unsiwap V2 使用UQ112.112

UQ112.112 是一个数字,小数部分使用 112 位,整数部分使用 112 位。选择 112 位是为了使保留状态变量的存储更加优化(下一节将详细介绍)——这就是变量使用uint112类型的原因。另一方面,储备金存储为 UQ112.112 数字的整数部分 - 这就是为什么在计算价格之前将其乘以2**112 。查看UQ112x112.sol了解更多详细信息,非常简单。

我希望您能从代码中更清楚地了解这一切,所以让我们实现价格累积。我们只需要添加一个状态变量:

uint32 private blockTimestampLast;

它将存储上次交易(或者实际上是保留更新)时间戳。然后我们需要修改储备更新函数:

function _update(
  uint256 balance0,
  uint256 balance1,
  uint112 reserve0_,
  uint112 reserve1_
) private {
  ...
  unchecked {
    uint32 timeElapsed = uint32(block.timestamp) - blockTimestampLast;

    if (timeElapsed > 0 && reserve0_ > 0 && reserve1_ > 0) {
      price0CumulativeLast +=
      uint256(UQ112x112.encode(reserve1_).uqdiv(reserve0_)) *
      timeElapsed;
      price1CumulativeLast +=
      uint256(UQ112x112.encode(reserve0_).uqdiv(reserve1_)) *
      timeElapsed;
    }
  }

  reserve0 = uint112(balance0);
  reserve1 = uint112(balance1);
  blockTimestampLast = uint32(block.timestamp);

  ...
}

UQ112x112.encode将uint112值乘以2**112 ,使其成为uint224值。然后,将其除以其他储备并乘以timeElapsed 。结果被添加到当前存储的结果中——这使得它累积起来。请注意unchecked块——我们将很快讨论它。

存储优化

那个奇怪的uint112类型是什么?为什么不使用uint256 ?答案是:gas 优化。

每个 EVM 操作都会消耗一定量的 Gas。简单的操作,比如算术操作,消耗很少的gas,但有些操作消耗大量的gas。最昂贵的是SSTORE为合约存储节省花费。它的对应SLOAD也很昂贵。因此,如果智能合约开发人员尝试优化其合约的 Gas 消耗,这对用户是有利的。使用uuint112作为保留变量正是达到这个目的。

看看我们如何设置变量:

address public token0;
address public token1;

uint112 private reserve0;
uint112 private reserve1;
uint32 private blockTimestampLast;

uint256 public price0CumulativeLast;
uint256 public price1CumulativeLast;

在EVM中,每个状态变量对应某个存储槽,而EVM使用32字节存储槽(每个存储槽正好是32字节),每个状态变量分配到一个或多个存储槽中。一个存储槽只能存储一个变量(如果变量小于32字节,多个变量可以共享一个存储槽)。

SLOAD 操作:从存储槽中读取 32 字节的数据。SSTORE 操作:将 32 字节的数据写入存储槽。这些操作在 EVM 中是成本高昂的,特别是写操作(SSTORE),因为它需要消耗大量的 gas。

为了提高合约的效率,我们可以通过优化状态变量的布局来减少存储读写的次数。以下是一些关键点:

紧凑布局:将几个小于 32 字节的变量打包到一个存储槽中,可以减少存储槽的使用次数。例如,将多个 uint8 类型的变量打包到一个存储槽中,而不是每个变量都使用一个独立的存储槽。

回到上面的代码:

address 类型占用 20 字节。每个 address 变量会占用一个独立的存储槽,因为它们不能与其他变量共享一个 32 字节的存储槽(EVM 的存储槽最小单位是 32 字节)。

uint112 private reserve0;uint112 private reserve1;uint32 private blockTimestampLast;这些变量分别占用 14 字节、14 字节和 4 字节,总共 32 字节。由于这些变量总共正好是 32 字节,它们可以紧凑地打包到一个存储槽中,从而减少存储槽的使用次数。

uint256 类型占用 32 字节。每个 uint256 变量会占用一个独立的存储槽。

整数的溢出问题

我们将累计价格计算置于unchecked - 为什么?

智能合约的另一个常见漏洞是整数溢出或下溢。 uint256的最大值 整数是 最小值为0。整数溢出是指整型变量的值增加 所以它大于最大值,这将导致溢出:该值换行并从 0 开始。

在 0.8.0 版本之前,Solidity 还没有检查溢出和下溢,开发人员提出了一个库: SafeMath 。如今,不再需要这个库了,因为 Solidity 现在在检测到上溢或下溢时会抛出异常。

Solidity 0.8.0 还引入了unchecked块,顾名思义,它禁用其边界内的上溢/下溢检测。

让我们回到我们的代码。

在计算timeElapsed和累计价格时,我们使用unchecked块。这似乎对合约的安全性不利,但预计时间戳和累计价格会溢出:当它们中的任何一个溢出时都不会发生任何不好的事情。我们希望它们在溢出时不会抛出错误,以便它们能够正常运行。

这种情况很少见,并且几乎不应该禁用上溢/下溢检测。

Safe transfer

您可能已经注意到我们发送Token使用了奇怪的方式:

function _safeTransfer(
  address token,
  address to,
  uint256 value
) private {
  (bool success, bytes memory data) = token.call(
    abi.encodeWithSignature("transfer(address,uint256)", to, value)
  );
  if (!success || (data.length != 0 && !abi.decode(data, (bool))))
  revert TransferFailed();
}

为什么不在ERC20接口上直接调用transfer方法呢?

在配对合约中,当进行代币转账时,我们总是希望确保它们成功。根据ERC20, transfer方法必须返回一个布尔值: true 表示成功; 大多数Token都正确实现了这一点,但有些Token却没有——它们只是不返回任何内容。当然,我们无法检查代币合约的执行情况,也无法确定代币转账是否确实进行,但我们至少可以检查转账结果。如果传输失败,我们不想继续。

这里的call是一个address方法——这是一个低级函数,可以让我们对合约调用进行更细粒度的控制。在这种特定情况下,它允许我们获得传输结果,无论transfer方法是否返回一个结果。

Links

  1. Source code of part 2
  2. UniswapV2 Whitepaper – worth reading and re-reading.
  3. Layout of State Variables in Storage
  4. Q (number format)
  5. Check Effects Interactions Pattern
  6. Checked or Unchecked Arithmetic
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Louis
Louis
web3 developer,技术交流或者有工作机会可加VX: magicalLouis