Uniswap V2核心合约:技术细节与风险

  • hacken
  • 发布于 2025-06-27 22:27
  • 阅读 19

本文深入分析了 Uniswap V2 的核心合约(v2-core),包括 UniswapV2ERC20, UniswapV2Factory, UniswapV2Pair。文章着重强调了在集成和审计过程中需要关注的技术细节,例如跨链重放保护的缺失、uint112储备限制以及预期的整数溢出等问题。此外,还讨论了流动性存入的三明治攻击和首次流动性存入抢跑等安全问题。

许多文章描述了该协议的主要思想,并且由于合约的长期存在以及在生态系统中的广泛采用,这些合约被认为是安全的。这篇以太坊博客文章为初步了解提供了一个很好的代码概述。

在本博客中,我们将特别关注 v2-core 合约,重点介绍在集成和审计期间需要注意的技术细节。

UniswapV2ERC20

该合约提供了由 UniswapV2Pair 继承的基本 ERC-20 功能,用于表示流动性提供者 (LP) 的份额。它还包括具有顺序 nonce 的 permit 函数。

值得注意的点:

  • 缺乏跨链分叉的重放保护

该合约实现了 ERC-20 Permit 功能,该功能允许使用签名来授权批准。该功能遵循 EIP-712 标准;但是,DOMAIN_SEPARATOR 值仅计算一次,并且以后不检查是否符合标准。如果链发生分叉并且链 ID 发生更改,则合约仍将处理为另一条链签署的 Permit 批准。

  • 所有流动池使用单一名称和符号

该合约将 token 名称和符号硬编码为 Uniswap V2 和 UNI-V2。该合约由 UniswapV2Pair 合约继承,因此在所有已部署的 pair 合约中共享名称和符号。虽然这可能会令人困惑,但不会构成风险。为了确定合约与哪些 token 一起使用,可以使用 UniswapV2Pair 合约的 token0token1 方法。

  • 在 Permit 功能中接受可延展的签名

permit 功能是一个自定义实现,它不包含常见的反延展签名保护 s <= secp256k1n / 2。虽然每个操作的唯一元组(用户和 nonce)可以完全防止重放攻击,但意外的修改可能会产生破坏重放保护的风险。例如,下面的代码尝试使用一种不依赖于智能合约状态的确定性 nonce,这破坏了安全控制。

function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {

  require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');

  // This nonce does not make any sense in terms of security
  // 从安全性角度来看,此 nonce 没有任何意义
  uint nonce = uint(keccak256(abi.encode(owner, spender, value, deadline));

  // This state variable is not included in the signed data
  // 此状态变量未包含在签名数据中
  nonces[owner]++;

  bytes32 digest = keccak256(

abi.encodePacked(

   '\x19\x01',

   DOMAIN_SEPARATOR,

   keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonce, deadline))

)

  ;

  address recoveredAddress = ecrecover(digest, v, r, s);

  require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');

  _approve(owner, spender, value);

}

UniswapV2Factory

UniswapV2Pair 合约的工厂合约。存储所有已创建的 pair,使用 CREATE2 进行确定性的 pair 地址计算,并按升序为 pair 初始化提供 token 地址。

值得注意的点:

  • 单步管理器更新

根据现代最佳实践,合约管理器更改过程应分两步进行:首先,提出待定的管理器,然后新管理器声明该角色。这样可以防止将管理器角色转移到不存在的帐户,并允许待定的管理器在原始管理器丢失其私钥的情况下保留访问权限。但是,UniswapV2Factory 合约通过 setFeeToSetter 函数单步实现此过程。

address public feeToSetter;

function setFeeToSetter(address _feeToSetter) external {

  require(msg.sender == feeToSetter, 'UniswapV2: FORBIDDEN');

  feeToSetter = _feeToSetter;

}

UniswapV2Pair

一个支持 token 交换的流动池合约。需要额外的包装器来实现无缝的 EOA 交互。

值得注意的点:

  • EOA 对 Token 余额隔离和使用

UniswapV2Pair 合约使用不同的 token 隔离每个 pair,将流动池储备与实际 token 余额同步,并期望资金预先转移到合约,而不是触发 transferFrom。此功能不应在支持单个合约中多个流动池的实现中使用。

Uniswap 团队选择的设计可确保高度的安全性,并防止 token 被锁定在 pair 合约中。

从另一个角度来看,UniswapV2Pair 合约不提供无缝的 EOA 集成,因为它期望以多个步骤执行操作:token 转移和合约调用。使用 skim 函数可以立即提取来自 EOA 的任何 token 转移。通常的交互策略是使用包装功能,例如 UniswapV2Router,它在单个交易中对 pair 执行 token 转移和操作。

function swap(...) ... {

  ...

  (uint112 _reserve0, uint112 _reserve1,) = getReserves();

  ...

  balance0 = IERC20(_token0).balanceOf(address(this));

  balance1 = IERC20(_token1).balanceOf(address(this));

  ...

  uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;

  uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;

  ...

  _update(balance0, balance1, _reserve0, _reserve1);

  ...

}

function _update(uint balance0, uint balance1, ...) ... {

  ...

  reserve0 = uint112(balance0);

  reserve1 = uint112(balance1);

  ...

}
  • 超出 uint112 储备限制

UniswapV2Pair 合约旨在处理高达 $2 * 112 - 1$ 的 token 储备(和余额)。超过此限制会导致 Uniswap 操作持续恢复。在这种情况下,可以使用 skim* 函数提取过多的资金,以使合约再次运行。

function _update(uint balance0, uint balance1, ...) ... {

  require(balance0 &lt;= uint112(-1) && balance1 &lt;= uint112(-1), 'UniswapV2: OVERFLOW');

  ...

  reserve0 = uint112(balance0);

  reserve1 = uint112(balance1);

  ...

}

function mint(...) ... {

  ...

  _update(balance0, balance1, _reserve0, _reserve1);

  ...

}

function burn(...) ... {

  ...

  _update(balance0, balance1, _reserve0, _reserve1);

  ...

}

function swap(...) ... {

  ...

  _update(balance0, balance1, _reserve0, _reserve1);

  ...

}

由于合约提供的相对于交换量的流动性较低,因此具有无限总供应量或总供应量超过 $2 ** 112$ 的 token 可能不适合 Uniswap 操作。

重要的是要注意,将储备状态变量更改为 uint256 不会绕过此限制。如果储备超过 uint112,则合约会执行各种操作,这些操作可能会由于意外溢出而恢复。例如,swap 函数中的表达式 uint(_reserve0).mul(_reserve1).mul(1000**2) 在储备存储为 uint112 时不会恢复,因为 $2 112 * 2 * 112 1000 2$ 仍在限制范围内。但是,将储备声明为 uint256 可能会导致失败。

此外,累积价格值计算使用 UQ112x112 分数,需要分子和分母的类型为 uint112

  • 所需的整数溢出

UniswapV2Pair 合约有几个需要整数溢出的情况。

uint32 blockTimestamp = uint32(block.timestamp % 2**32);

uint32 timeElapsed = blockTimestamp - blockTimestampLast;

price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;

price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;

强制更新到 Solidity 0.8.0+ 将导致 2106 年出现拒绝服务 (DoS) 情况,锁定交换、流动性铸造和销毁功能。

重要的是要注意,此功能依赖于以秒为单位提供的 block.timestamp,并假定流动池至少每 136 年更新一次。如果区块链不完全与 EVM 兼容并且以毫秒或纳秒为单位存储 block.timestamp,则溢出可能会更早发生,从而可能破坏值的连续性。

  • 传输时收费 Token 的处理

UniswapV2Pair 合约支持处理传输时收费的 token,因为它期望资金在回调中或在执行之前转移到 pair 合约。该合约验证通用 oldReserve0 * oldReserve1 <= newReserve0 * newReserve1 不变量以保持资金一致性,并根据实际存入的资金铸造流动性。虽然这在技术上是可能的,但由于缺乏传输时收费的 token 标准化,因此准确计算交换进出金额对于 UniswapV2Router 等中介机构来说仍然是一个挑战。

为了缓解这一挑战,建议将 token 包装到标准 ERC-20 实现中,并将包装后的 token 用于 Uniswap V2 操作。

  • Rebasing 和 Reflection Token 的处理

UniswapV2Pair 合约并非旨在处理 rebasing 和 reflection token。由于 rebasing 余额收入导致的余额增加可以使用 skim 函数从合约中提取,或者被视为下一次交换或增加流动性的额外资金。

function skim(address to) external lock {

  address _token0 = token0;

  address _token1 = token1;

  _safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));

  _safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));

}

如上所述,流动性提供者将不会收到预期的 rebasing 余额收入。如果在任何 pair 操作之前强制更新储备并删除 skim 功能等修改,如果合约余额超过 $2 ** 112$ 的最大储备大小,可能会导致合约无法运行。

应创建一个符合 EIP-4626 的 vault,以将 reflection token 包装到标准 ERC-20 token 中,并将包装后的 token 用于 Uniswap V2 操作。

  • 使用累积价格作为预言机

UniswapV2Pair 合约提供可用于 TWAP(时间加权平均价格)预言机的累积价格。可以在初始时间和最终时间点记录累积价格。累积增量除以经过的时间,并以这种方式获得 TWAP 价格。参考实现可以在 v2-periphery 示例 中找到。

uint256 public price0CumulativeLast;

uint256 public price1CumulativeLast;

uint32 public blockTimestampLast;

FixedPoint.uq112x112 public price0Average;

FixedPoint.uq112x112 public price1Average;

function update() external {

  uint256 price0Cumulative = IUniswapV2Pair(pair).price0CumulativeLast();

  uint256 price1Cumulative = IUniswapV2Pair(pair).price1CumulativeLast();

  uint32 blockTimestamp = IUniswapV2Pair(pair).blockTimestampLast();

  uint32 timeElapsed = blockTimestamp - blockTimestampLast;

  require(timeElapsed >= PERIOD, 'Oracle: PERIOD_NOT_ELAPSED');

  price0Average = FixedPoint.uq112x112(uint224((price0Cumulative - price0CumulativeLast) / timeElapsed));

  price1Average = FixedPoint.uq112x112(uint224((price1Cumulative - price1CumulativeLast) / timeElapsed));

  price0CumulativeLast = price0Cumulative;

  price1CumulativeLast = price1Cumulative;

  blockTimestampLast = blockTimestamp;

}

虽然此功能提供了一种为给定 token pair 获取时间加权平均价格的优雅方式,但它有几个限制:

该功能保证指定时间段或更长时间的 TWAP。这意味着预言机可能会因刷新率低而提供过时的价格。

对于某些 TWAP 期间,可以通过顺序区块三明治攻击来操纵该功能:区块中的最后一笔交易执行大型交换,下一个区块中的第一笔交易将储备恢复到原始比率。价格影响与点之间的时间差除以 TWAP 期间成正比。由于 block.timestamp 值可以由区块创建者操纵,因此即使是 5 分钟的 TWAP 也可能受到 20% 的操纵。在具有自定义交易排序器的 L2 解决方案上部署时,这种洞察尤为重要。

  • 流动性存款夹击

UniswapV2Pair 合约根据公式:amount * totalSupply / reserve 计算要为存款铸造的流动性。但是,由于需要将两个 token 存入 pair 中,因此取流动性值的最小值。

这会导致一种情况,即流动性存款可能会被大型交换“夹击”,从而改变储备比率,并导致用户收到的流动性低于预期,而流动性 token 的价值会增加。

通常的缓解策略是使用包装功能,例如 UniswapV2Router,它会验证合约是否铸造了预期的流动性量。

function mint(address to) (...) {

  ...

  if (_totalSupply == 0) {

...

  } else {

   liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);

  }

  ...

  _mint(to, liquidity);

  ...

}

这种洞察在 UniswapV2Pair 合约的早期阶段尤为重要,因为 MINIMUM_LIQUIDITY 是从初始铸造的流动性中扣除的。

  • 初始流动性存款抢跑

UniswapV2Pair 合约的 mint 函数行为取决于合约流动性是否已初始化。虽然在首次存款期间,铸造的 LP 数量计算为 sqrt(amount0 * amount1),但在下次存款期间,数量计算为 amount * totalSupply / reserve 比例的最小值。

function mint(address to) external lock returns (uint liquidity) {

  ...

  if (_totalSupply == 0) {

    liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);

    _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
    // 永久锁定第一个MINIMUM_LIQUIDITY token
  } else {

    liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);

  }

  ...

}

这样,初始存款甚至流动池创建抢跑者允许攻击者在 pair 中设置任意价格,并完全耗尽即将到来的存款。为了降低这种可能性,建议使用包装器,例如 UniswapV2Router,它提供了一个函数,如果该 pair 不存在,则自动部署该 pair,并根据当前的储备比率向该 pair 提供资金。

应该提到的是,由于 UniswapV2Pair 联系人地址是确定性计算的,因此可以在部署之前为其提供资金,这可能会使期望在初始流动性存款后立即在 pair 中获得特定价格的智能合约感到困惑。可以使用skim函数从合约中提取过多的资金。

总结

Uniswap V2 于 2020 年 5 月部署,仍然是最广泛采用的去中心化交易所协议之一。由于广泛的使用和经过时间考验的可靠性,其核心智能合约被认为是安全的。但是,从 permit 签名处理和 uint112 限制到余额检查机制和预言机属性,它们的特定实现细节为安全集成创建了关键考虑因素。

  • 原文链接: hacken.io/discover/unisw...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
hacken
hacken
江湖只有他的大名,没有他的介绍。