## 概述 本文是笔者为 Uniswap 系列课程编写的讲义,笔者在此前的博客内介绍了 [Uniswap v3](https://blog.wssh.dev/posts/uniswap-v3/) 和 [Uniswap v4](https://blog.wssh.dev/posts/uniswap-v4/),但从来没有介绍过 Uniswap v2 的实现,本文就是对 Uniswap v2 实现的介绍。本文仅要求读者熟悉基础的 solidity 开发。另外,本文不是一个代码解析文章,相反,我们的目标是使用较为现代的方法重新实现一个 Uniswap v2 协议。本文不会涉及 Uniswap v2 的通识与概念性的介绍,在课程中,这部分内容由我的朋友 [Jeff](https://x.com/jeffishjeff) 介绍,但理论上即使不了解任何前置的关于 Uniswap v2 的知识,本文依旧是可以阅读的。另外,限于篇幅,本文无法对所有的细节内容展开介绍,但笔者会给出所有扩展材料的链接,读者可以选择性阅读。 本文有关的代码可以在 [otherswap-v2](https://github.com/wangshouh/otherswap-v2) 内找到,但目前还没有进行测试工作,可能隐含安全风险和实现问题。 ## 创建项目 我们可以使用 `mkdir otherswap-v2` 创建项目所在的目录,然后 `cd otherswap-v2` 进入目录后,使用 `forge soldeer init` 初始化项目。此处的 `soldeer` 是现代的 solidity 包管理工具,具体内容可以参考 [Foundry 文档](https://www.getfoundry.sh/projects/soldeer)。 然后,本文会参考以下项目的代码,读者可以选择将这些代码库 clone 到本地进行代码阅读或者直接在 Github 前端页面内阅读代码: 1. [uniswap-v2 core](https://github.com/Uniswap/v2-core/tree/master/contracts) 2. [openzeppelin-contracts](https://github.com/OpenZeppelin/openzeppelin-contracts/tree/master/contracts) 3. [solady](https://github.com/Vectorized/solady/tree/main/src) ## ERC20 代币 在构建 Uniswap v2 协议时,我们的第一步是编写一个 ERC20 代币合约,该代币合约在 Uniswap v2 协议内作为 LP 代币凭证存在。此处需要注意,在 Uniswap v3 和 Uniswap v4 内,由于区间流动性提供机制,LP 凭证一般使用 NFT 表示。注意,Uniswap LP NFT 是由 uniswap 团队在外围合约内定义的,而不涉及核心合约,所以很多协议会选择直接与 core 合约交互控制 LP 而不采用 NFT 作为中间凭证进行权限控制。我们首先给出 `IUniswapV2ERC20` 的接口定义: ```solidity /// SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.0; interface IUniswapV2ERC20 { event Approval(address indexed owner, address indexed spender, uint256 value); event Transfer(address indexed from, address indexed to, uint256 value); function name() external pure returns (string memory); function symbol() external pure returns (string memory); function decimals() external pure returns (uint8); function totalSupply() external view returns (uint256); function balanceOf(address owner) external view returns (uint256); function allowance(address owner, address spender) external view returns (uint256); function approve(address spender, uint256 value) external returns (bool); function transfer(address to, uint256 value) external returns (bool); function transferFrom(address from, address to, uint256 value) external returns (bool); function DOMAIN_SEPARATOR() external view returns (bytes32); function PERMIT_TYPEHASH() external pure returns (bytes32); function nonces(address owner) external view returns (uint256); function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external; } ``` 其中 `DOMAIN_SEPARATOR` / `PERMIT_TYPEHASH` / `nonces` 和 `permit` 都是用于基于 EIP-712 签名机制的 `permit` 机制。该机制允许用户使用签名对自己的代币对外进行 `approve` ,优化了用户体验。在 `permit` 机制诞生前,用户对外授权代币进行交互,必须先执行一次 `approve` 交易,然后在执行具体的其他交互。但在 `permit` 机制诞生后,用户只需要首先进行一次 EIP-712 的签名,然后将签名结果作为参数执行合约交互即可,将原流程内的 2 笔交易优化为了 1 笔交易。 限于篇幅,我们此处无法完整介绍该部分内容的细节,读者可以阅读笔者之前编写的 [基于链下链上双视角深入解析以太坊签名与验证](https://blog.wssh.dev/posts/ecsda-sign-chain/) 和 [EIP712的扩展使用](https://blog.wssh.dev/posts/eip712-extend/) 两部分内容。值得注意的,permit 机制的始终笼罩在可能增加安全风险的质疑中,因为 permit 机制不会产生任何链上交易,且只需要签名即可,在一些情况下,用户可能被诱骗进行 permit 签名,当用户完成签名后,黑客会立即发起攻击转移用户账户内的 ERC20 代币。 另外,此处可以拓展一下 Uniswap 对于 Permit 机制是非常喜欢的。Uniswap 后续开发了 [permit2](https://github.com/Uniswap/permit2) 合约,该合约允许对不支持 permit 机制代币进行 permit 操作。具体原理大致为用户首先发起交易将代币全部授权给 permit2 合约,在与其他合约交互时,用户给出 permit 签名,其他合约使用签名向 permit2 申请代币转移。对于 Permit 2 的具体实现机制和细节,用户可以阅读 [Uniswap Permit2 實作與設計](https://medium.com/taipei-ethereum-meetup/uniswap-permit2-introduction-858ae3dddf18) 一文。目前 Uniswap v3 和 Uniswap v4 的 router 都默认使用了 permit 2 作为核心机制。 接下来,我们开始实现具体的合约。第一步是定义合约内的和 ERC20 有关的核心状态变量: ```solidity contract UniswapV2ERC20 is IUniswapV2ERC20 { string public constant name = "Uniswap V2"; string public constant symbol = "UNI-V2"; uint8 public constant decimals = 18; uint256 public totalSupply; mapping(address => uint256) public balanceOf; mapping(address => mapping(address => uint256)) public allowance; ``` 此处额外注意的是 `public` 实际上会自动生成同名的 `name()` / `symbol()` 和 `decimals()` 函数,所以我们不需要后续对这些函数进行实现。当然,这种自动的同名函数生成会导致合约字节码体积较大,所以默认情况下,还是建议优先考虑将存储变量设置为 `internal` 状态。 然后,我们会编写与 EIP-712 以及合约初始化有关的内容: ```solidity bytes32 public immutable DOMAIN_SEPARATOR; // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; mapping(address => uint256) public nonces; constructor() { // EIP-712 domain separator DOMAIN_SEPARATOR = keccak256( abi.encode( keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), keccak256(bytes(name)), keccak256(bytes("1")), block.chainid, address(this) ) ); } ``` 此处我们可以看到 `DOMAIN_SEPARATOR` 和 `PERMIT_TYPEHASH` 两个常量的初始化。关于此处的初始化的具体原理,读者可以阅读上文给出的我的博客,或者直接阅读 [EIP-712 文档](https://eips.ethereum.org/EIPS/eip-712)。此处的 `PERMIT_TYPEHASH` 可以使用如下命令获得: ```bash cast keccak "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" ``` 另外,为了更加现代化,我们将 `DOMAIN_SEPARATOR` 设置为了 `immutable` 类型,熟悉 solidity 语法的读者一定知道 `immutable` 是一个允许在 `constructor` 进行一次初始化构造,然后就无法在运行过程中修改的变量。限于篇幅,在此处我们就不再展开介绍构造器与 `immutable` 的底层原理,这些原理与合约部署时的 creation code 和 runtime code 概念有关,读者可以阅读我之前编写的 [自底向上学习以太坊(三):智能合约开发中的构造器、函数定义与存储布局](https://hackmd.io/@4seasstack/learneth03) 一文。该文在字节码级别介绍了这些概念。 然后就是简单的 ERC20 基础函数实现,原始的 Uniswap v2 版本使用了 `SafeMath` 库,但在 [Solidity 0.8.0](https://www.soliditylang.org/blog/2020/12/16/solidity-v0.8.0-release-announcement/) 后,默认情况下所有的计算都是安全的,solidity 编译出的产物内将包含溢出检测有关的字节码。除非使用 `unchecked` 关闭溢出检测。 ```solidity function _mint(address to, uint256 value) internal { totalSupply += value; balanceOf[to] += value; emit Transfer(address(0), to, value); } function _burn(address from, uint256 value) internal { balanceOf[from] -= value; totalSupply -= value; emit Transfer(from, address(0), value); } function _approve(address owner, address spender, uint256 value) private { allowance[owner][spender] = value; emit Approval(owner, spender, value); } function _transfer(address from, address to, uint256 value) private { balanceOf[from] -= value; balanceOf[to] += value; emit Transfer(from, to, value); } function approve(address spender, uint256 value) external returns (bool) { _approve(msg.sender, spender, value); return true; } function transfer(address to, uint256 value) external returns (bool) { _transfer(msg.sender, to, value); return true; } function transferFrom(address from, address to, uint256 value) external returns (bool) { if (allowance[from][msg.sender] != type(uint256).max) { allowance[from][msg.sender] -= value; } _transfer(from, to, value); return true; } ``` 最后,我们实现 ERC20 的扩展功能 Permit 函数: ```solidity function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external { require(deadline >= block.timestamp, "UniswapV2: EXPIRED"); // `"\x19\x01"` is suffix for EIP-712 encoding, `eth_signTypedData_v4` bytes32 digest = keccak256( abi.encodePacked( "\x19\x01", DOMAIN_SEPARATOR, keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)) ) ); address recoveredAddress = ecrecover(digest, v, r, s); // lowS rule is important, but this implementation is not requiring it for simplicity require(recoveredAddress != address(0) && recoveredAddress == owner, "UniswapV2: INVALID_SIGNATURE"); _approve(owner, spender, value); } ``` 此处的 `digest` 计算使用了 EIP-712 内的规定的 `keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message))` 格式。此处的 `\x19\x01` 是一个特殊的标识符,实际上来自 [ERC-191](https://eips.ethereum.org/EIPS/eip-191) 的约定。该前缀的目标是使签名内容与以太坊交易的签名区分,避免用户的签名可以被用于构建以太坊交易。 此处使用了 `ecrecover` 对签名进行校验。但是 Uniswap v2 给出的代码并不普世。这是因为 Uniswap v2 没有处理交易的延展性问题。由于 secp256k1 椭圆曲线签名的特殊性,每一个签名实际上都有两个正确的版本,一般认为 lowS 是正确的。但是 `ecrecover` 默认不会检查 `lowS` 规则。在 Openzepplin 的 `ECDSA.sol`,我们可以看到如下更加安全的版本: ```solidity if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { return (address(0), RecoverError.InvalidSignatureS, s); } // If the signature is valid (and not malleable), return the signer address address signer = ecrecover(hash, v, r, s); if (signer == address(0)) { return (address(0), RecoverError.InvalidSignature, bytes32(0)); } return (signer, RecoverError.NoError, bytes32(0)); ``` 其中第一步检查就是用来避免签名延展性问题的。对于 ERC20 Permit 而言,延展性不会造成严重的安全问题,但是对于其他应用(特别是交易所冲提币系统)而言,交易延展性可能造成严重的问题,特别是 bitcoin 就受到了交易延展性影响,曾导致了 [MtGOX(门头沟)](https://en.wikipedia.org/wiki/Mt._Gox) 巨额损失。假如读者希望进一步了解背后的密码学原理,可以阅读笔者之前编写的几篇博客: 1. [自底向上学习以太坊(一):从助记词到Calldata](https://hackmd.io/@4seasstack/learneth01) 2. [椭圆曲线密码学与 Typescript 实现](https://blog.wssh.dev/posts/secp256k1-ts/) 至此,我们就完成了 ERC20 代币的构造和介绍,限于篇幅,此处我们不再给出相关的测试代码。 ## Pair Pair 合约是 Uniswap v2 内最核心的合约,该合约是流动性管理和 swap 交易真实发生的地方,也是 `x * y = k` 发挥作用的地方。与刚刚介绍的 ERC20 代币合约不同,该合约稍微复杂一些。我们首先定义 `IUniswapV2Pair` 接口: ```solidity /// SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.0; import {IUniswapV2ERC20} from "./IUniswapV2ERC20.sol"; interface IUniswapV2Pair is IUniswapV2ERC20 { event Mint(address indexed sender, uint256 amount0, uint256 amount1); event Burn(address indexed sender, uint256 amount0, uint256 amount1, address indexed to); event Swap( address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to ); event Sync(uint112 reserve0, uint112 reserve1); function MINIMUM_LIQUIDITY() external pure returns (uint256); function factory() external view returns (address); function token0() external view returns (address); function token1() external view returns (address); function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast); function price0CumulativeLast() external view returns (uint256); function price1CumulativeLast() external view returns (uint256); function kLast() external view returns (uint256); function mint(address to) external returns (uint256 liquidity); function burn(address to) external returns (uint256 amount0, uint256 amount1); function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external; function skim(address to) external; function sync() external; function initialize(address, address) external; } ``` 另外,在 Uniswap v2 Pair 合约内,我们会使用到 `IUniswapV2Factory` 接口,所以此处也一并创建该文件: ```solidity /// SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.0; interface IUniswapV2Factory { event PairCreated(address indexed token0, address indexed token1, address pair, uint256); function feeTo() external view returns (address); function feeToSetter() external view returns (address); function getPair(address tokenA, address tokenB) external view returns (address pair); function allPairs(uint256) external view returns (address pair); function allPairsLength() external view returns (uint256); function createPair(address tokenA, address tokenB) external returns (address pair); function setFeeTo(address) external; function setFeeToSetter(address) external; } ``` 上述接口内的 `feeTo` 就是 Pair 合约需要调用 factory 的函数。Pair 合约通过调用该函数获取协议手续费的发送地址。此处我们需要区分协议手续费和 Swap 手续费。其中 Swap 手续费会完全流向协议内的 LP 提供者,而协议手续费会流向协议的创建者,比如我创建了 Uniswap v2 的 fork 版本,虽然我不提供任何流动性,但仍可以通过在 Factory 内利用 `setFeeTo` 设置协议手续费的接受地址。对于具体的手续费机制,我们会在后文介绍。 在本节中,我们会划分三个小节介绍 Pair 合约: 1. 基础部分,主要涉及一些最基础的功能,比如状态变量定义和更新,本节将涉及 TWAP 机制 2. 流动性管理部分,主要涉及用于增加流动性的 `mint` 函数和用于取回流动性的 `burn` 函数 3. 交易部分,主要是 `swap` 函数 4. 其他辅助函数,比如 `sync` 和 `skim` 函数,我们还会在此处介绍内部函数 `_mintFee` 以解释 Uniswap 如何划分协议手续费 ## 基础部分 首先,我们定义一些需要的类型,在原始版本的 Uniswap v2 内,为了实现变量打包,`reserve0` 和 `reserve1` 都被设置为 `uint112`。在现代合约开发中,我们往往会使用 [User Defined Value Types](https://www.soliditylang.org/blog/2021/09/27/user-defined-value-types/) 进行定义。创建 `src/types/UQ112.sol` 并写入以下内容: ```solidity /// SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.0; type UQ112 is uint112; ``` 使用这种自定义类型 `UQ112` 可以避免一些因为精度误用产生的问题,在 Uniswap v4 的代码库内,开发者大量使用了自定义类型。在后续课程中,我们会进行简单介绍。 最初,我们需要定义一系列状态变量: ```solidity uint256 public constant MINIMUM_LIQUIDITY = 10 ** 3; // cast sig "transfer(address,uint256)" // bytes4 private constant SELECTOR = bytes4(0xa9059cbb); address public factory; address public token0; address public token1; UQ112 private reserve0; // uses single storage slot, accessible via getReserves UQ112 private reserve1; // uses single storage slot, accessible via getReserves uint32 private blockTimestampLast; // uses single storage slot, accessible via getReserves uint256 public price0CumulativeLast; uint256 public price1CumulativeLast; uint256 public kLast; // reserve0 * reserve1, as of immediately after the most recent liquidity event ``` 此处的 `MINIMUM_LIQUIDITY` 是为了抵抗一种攻击而设置的,我们会在后文介绍用于添加流动性的 `mint` 函数分析 `MINIMUM_LIQUIDITY` 的安全作用。这里直接使用了 `UQ112` 类型,在编译过程中,所有的自定义类型,比如此处的 `UQ112` 都会被还原为原始类型,所以使用自定义类型不会增加合约的字节码体积。额外的 `price0CumulativeLast` 和 `price1CumulativeLast` 用于 TWAP Oracle 进行 TWAP 计算。而 `kLast` 则用于跟踪 k 值,该变量被用于 **协议手续费**,假如未设置协议手续费,`kLast` 将始终保持为 0。 在原始的 Uniswap v2 代码内,我们此处存在一个常量 `SELECTOR` 代表 `transfer` 函数的选择器,我们可以直接使用 `cast sig "transfer(address,uint256)"` 获得该值。函数选择器也是一个智能合约的底层概念,简单来说,函数选择器决定了用户在交易中调用合约的函数名。但在我们的现代版本中,我们不需要存储该常量。 接下来,我们编写一个较为现代的重入锁机制: ```solidity bool transient locked; modifier lock() { require(!locked, "UniswapV2: LOCKED"); locked = true; _; locked = false; } ``` 此处我们使用新机制 transient storage。 transient storage 是一种在交易过程中会始终存在的状态空间。众所周知,一笔交易可能包含对不同合约的多次 call 调用。对于重入锁而言,我们需要保证调用离开当前合约后,在当前交易中,重入锁的状态保持。在过去,我们只能利用 storage 空间。而 transient storage 为重入锁的设计打开了新的空间。使用 transient storage 机制构造的重入锁 gas 消耗更低。在 Uniswap v4 中,我们会更加频繁的使用 transient storage 构建 balance delta 等功能。具体内容可以阅读 [EIP-1153: Transient storage opcodes 簡介](https://medium.com/taipei-ethereum-meetup/eip-1153-transient-storage-opcodes-%E7%B0%A1%E4%BB%8B-be13439e7bae) 一文。 特别注意,solidity 编译器最近被发现 `transient` 存在严重语义安全问题,具体参考 [Transient Storage Clearing Helper Collision Bug ](https://www.soliditylang.org/blog/2026/02/18/transient-storage-clearing-helper-collision-bug/),但在本文中,我们不会遇到该问题。 我们接下来编写几个无聊的函数: ```solidity constructor() { factory = msg.sender; } // called once by the factory at time of deployment function initialize(address _token0, address _token1) external { require(msg.sender == factory, "UniswapV2: FORBIDDEN"); // sufficient check token0 = _token0; token1 = _token1; } function getReserves() public view returns (uint112 _reserve0, uint112 _reserve1, uint32 _blockTimestampLast) { _reserve0 = UQ112.unwrap(reserve0); _reserve1 = UQ112.unwrap(reserve1); _blockTimestampLast = blockTimestampLast; } ``` 此处用到了 `UQ112.unwrap` 函数,对于所有的自定义类型,solidity 编译器都会自动生成两个语法糖 `wrap` 和 `unwrap`,其中前者用于将底层类型转化为自定义类型,比如 `UQ112.wrap` 可以将 `uint112` 转化为 `UQ112` 类型,注意你不能将 `uint256` 类型通过 `UQ112.wrap` 进行转化。而 `unwrap` 函数的功能是将自定义类型转化为底层类型,比如此处的 `UQ112.unwrap(reserve0)` 就将 `reserve0` 转化为了底层的 `uint112` 类型。 接下来,我们要编写 `_safeTransfer` 内部函数用于向外发送代币。需要该函数的原因是存在很多怪异代币(weired tokens),具体可以参考此 [Github 仓库](https://github.com/d-xo/weird-erc20)。标准的 ERC20 代币的 `transfer` 定义如下: ```solidity function transfer(address _to, uint256 _value) public returns (bool success) ``` 但比如 USDT 代币的 `transfer` 函数定义如下: ```solidity function transfer(address to, uint value) public; ``` USDT 并没有返回值,实际上在以太坊主网内部署的 [BNB](https://etherscan.io/token/0xB8c77482e45F1F44dE1745F52C74426C631bDD52#code) 也属于该情况。为了兼容这些奇怪的代币,我们需要编写一个特殊的代码,原始版本的 Uniswap v2 使用了如下代码: ```solidity (bool success, bytes memory data) = token.call(abi.encodeWithSelector(SELECTOR, to, value)); require(success && (data.length == 0 || abi.decode(data, (bool))), 'UniswapV2: TRANSFER_FAILED'); ``` 首先,我们检测 `transfer` 调用是否成功,即 `bool success`,然后存在两种情况: 1. 不存在任何返回值,这种情况会被视为交易成功 2. 存在返回值,我们需要解码返回值,判断返回值是否为 true 其实就是上文中的 `data.length == 0 || abi.decode(data, (bool))` 部分的代码。在更加现代的写法中,我们会使用 `solady` 内的如下代码片段: ```solidity assembly ("memory-safe") { mstore(0x14, to) // Store the `to` argument. mstore(0x34, value) // Store the `value` argument. mstore(0x00, 0xa9059cbb000000000000000000000000) // `transfer(address,uint256)`. // Perform the transfer, reverting upon failure. let success := call(gas(), token, 0, 0x10, 0x44, 0x00, 0x20) if iszero(and(eq(mload(0x00), 1), success)) { // `extcodesize` check token code exists if iszero(lt(or(iszero(extcodesize(token)), returndatasize()), success)) { mstore(0x00, 0x90b8ec18) // `TransferFailed()`. revert(0x1c, 0x04) } } mstore(0x34, 0) // Restore the part of the free memory pointer that was overwritten. } ``` 这段代码其实约等于 Uniswap v2 版本的内联汇编版本。对于这段代码的解读可能超过了本次课程的范围,我们不再分析。上述代码相较于 Unisawp v2 的版本额外增加了 `token` 是否存在的检测,因为对一个不存在任何合约的地址调用 `transfer` 函数使用 Uniswap v2 版本的代码仍可以通过测试。 肯定有读者抛出疑问,很多教程都不提倡使用内联汇编,内联汇编是否不是常规合约工程师需要掌握的技能?答案是否。实际上现代智能合约伴随着大量内联汇编的使用,比如在 Uniswap v4 内,工程师对 `transfer` 函数的实现如下: ```solidity assembly ("memory-safe") { // Get a pointer to some free memory. let fmp := mload(0x40) // Write the abi-encoded calldata into memory, beginning with the function selector. mstore(fmp, 0xa9059cbb00000000000000000000000000000000000000000000000000000000) mstore(add(fmp, 4), and(to, 0xffffffffffffffffffffffffffffffffffffffff)) // Append and mask the "to" argument. mstore(add(fmp, 36), amount) // Append the "amount" argument. Masking not required as it's a full 32 byte type. success := and( // Set success to whether the call reverted, if not we check it either // returned exactly 1 (can't just be non-zero data), or had no return data. or(and(eq(mload(0), 1), gt(returndatasize(), 31)), iszero(returndatasize())), // We use 68 because the length of our calldata totals up like so: 4 + 32 * 2. // We use 0 and 32 to copy up to 32 bytes of return data into the scratch space. // Counterintuitively, this call must be positioned second to the or() call in the // surrounding and() call or else returndatasize() will be zero during the computation. call(gas(), currency, 0, fmp, 68, 0, 32) ) // Now clean the memory we used mstore(fmp, 0) // 4 byte `selector` and 28 bytes of `to` were stored here mstore(add(fmp, 0x20), 0) // 4 bytes of `to` and 28 bytes of `amount` were stored here mstore(add(fmp, 0x40), 0) // 4 bytes of `amount` were stored here } ``` 当然,Uniswap v4 内使用内联汇编的地方非常非常多,上述其中一个案例。所以虽然本课程不会介绍内联汇编的具体内容,但是建议读者阅读笔者之前的博客 [从零开始的聚合器开发: Lotus Router 合约解析](https://blog.wssh.dev/posts/lotus-router/) 了解内联汇编的使用。该博客内包含了对上述 solady 版本的 `safeTransfer` 的分析。 然后,我们介绍 `_update` 函数,该函数主要负责更新 `reserve0` / `reserve1` / `blockTimestampLast` 三个变量: ```solidity function _update(uint256 balance0, uint256 balance1, UQ112 _reserve0, UQ112 _reserve1) private { // require(balance0 <= UQ112Lib.MAX_VALUE && balance1 <= UQ112Lib.MAX_VALUE, 'UniswapV2: OVERFLOW'); unchecked { uint32 blockTimestamp = uint32(block.timestamp % 2 ** 32); uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired if (timeElapsed > 0 && _reserve0.isNotZero() && _reserve1.isNotZero()) { // * never overflows, and + overflow is desired price0CumulativeLast += UQ112x112.unwrap(UQ112x112Lib.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed; price1CumulativeLast += UQ112x112.unwrap(UQ112x112Lib.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed; } blockTimestampLast = blockTimestamp; } reserve0 = UQ112Lib.encode(balance0); reserve1 = UQ112Lib.encode(balance1); emit Sync(UQ112.unwrap(reserve0), UQ112.unwrap(reserve1)); } ``` 这段代码中,我们额外注意 `overflow is desired` 注释。实际上 `_update` 代码在溢出后仍可以正常运转。我们首先考虑 `blockTimestamp` 的计算,计算方法是将 `block.timestamp` 与 `2**32` 之间取余数,假如 `block.timestamp > 2 ** 32` ,那么此时计算结果为: ``` solidity blockTimestamp = block.timestamp - 2 ** 32 ``` 然后考虑 `timeElapsed` 算法,此时 `timeElapsed = blockTimestamp - blockTimestampLast;` 也会溢出,但是: ``` timeElapsed = block.timestamp - 2 ** 32 - blockTimestampLast + 2 ** 32 = block.timestamp - blockTimestampLast ``` 我们发现溢出造成的影响刚好消失。当然,假如 `blockTimestamp` 与 `blockTimestampLast` 差距过大,溢出仍会产生影响,比如 `blockTimestamp = block.timestamp - 2 * 2 ** 32` 的时候。当然,间隔这么久的更新显然不符合实际情况,这意味着该池子已经 272 年没有产生交易。 对于 `price0CumulativeLast` 和 `price1CumulativeLast` 而言,我们第一步是需要了解 solidity 内对于小数的特殊表示。由于 solidity 没有提供原生的浮点数计算,所以我们需要使用一种特殊的方法定义小数,这种方法就是定点小数表示法。比如上文内出现的 `UQ112x112` 实际上就是代表存在 112 位整数精度和 112 位小数精度的数字,`UQ112x112` 与我们常见的小数的转化方法为将已有的 `UQ112x112` 与 $\frac{1}{2^{112}}$ 相乘即可。 此处使用了 `UQ112x112` 的 `encode` 和 `uqdiv` 函数,这些函数定义如下: ```solidity /// SPDX-License-Identifier: GPL-3.0 pragma solidity ^0.8.0; import {UQ112} from "./UQ112.sol"; type UQ112x112 is uint224; using UQ112x112Lib for UQ112x112 global; library UQ112x112Lib { uint224 private constant Q112 = 2 ** 112; function encode(UQ112 y) internal pure returns (UQ112x112) { return UQ112x112.wrap(uint224(UQ112.unwrap(y)) * Q112); } function uqdiv(UQ112x112 self, UQ112 y) internal pure returns (UQ112x112) { return UQ112x112.wrap(UQ112x112.unwrap(self) / uint224(UQ112.unwrap(y))); } } ``` 我们可以看到 `encode` 的作用就是将 `UQ112` 类型与 $2^{112}$ 相乘为其增加小数精度,而 `uqdiv` 则是将 `self / y` 相乘,最终获得带有 112 bit 精度的 `self / y` 的结果。关于为什么此处的 `price0CumulativeLast` 和 `price1CumulativeLast` 都被允许 overflow,这与 TWAP 机制有关。 TWAP 是一种预言机 Oracle 的基础实现方法。我们可以利用 TWAP 机制计算简单移动平均数或者加权移动平均数,但限于篇幅,我们无法在本课程内继续展开相关内容,建议读者阅读这部分的 [Uniswap 文档](https://docs.uniswap.org/contracts/v2/concepts/core-concepts/oracles)。下图就是来自文档的示例图: ![TWAP](https://docs.uniswap.org/assets/images/v2_twap-5ded1210c7fa1480286507b4b186fbe2.png) 最后,我们使用输入的 `balance0` 和 `balance1` 更新 `reserve0` 和 `reserve1`。此处使用 `UQ112Lib` 内的 `encode` 函数,该函数实现如下: ```solidity type UQ112 is uint112; using UQ112Lib for UQ112 global; library UQ112Lib { uint112 internal constant MAX_VALUE = type(uint112).max; function encode(uint256 y) internal pure returns (UQ112) { require(y <= MAX_VALUE, "UniswapV2: OVERFLOW"); return UQ112.wrap(uint112(y)); } function isNotZero(UQ112 self) internal pure returns (bool) { return UQ112.unwrap(self) != 0; } } ``` 因为 `balance0` 和 `balance1` 都属于 `uint256` 类型,而 `reserve` 系列都是 112 bit 类型,所以此处在 `encode` 函数内需要进行溢出检测。 在原始版本的 Uniswap v2 中,我们要介绍 `_mintFee` 函数,但在本节中,我们暂时跳过该函数,因为理解 `_mintFee` 函数的前提是理解 Uniswap v2 内 LP 的常规手续费收取机制。 ## 流动性管理 此处我们回顾一下流动性的计算方法: $$ \text{liquidity} = \frac{\text{amount}}{\text{reserve}} \times \text{totalSupply} $$ 但是,在首次流动性提供时,我们会使用如下方法计算: $$ \text{liquidity} = \sqrt{\text{amount0} \times \text{amount1}} $$ 那么设首次流动性提供的 liquidity 为 $\text{liquidity}_0$ ,然后黑客直接向 Pair 合约内转入大量代币,之后调用 `sync` 函数将 Pair 内的 $\text{reserve}$ 的数值大幅度提高为 $\text{reserve}'$。随后,受害者调用 `mint` 函数铸造 LP 代币,获得的代币数量 $\text{liquidity}_1$ 为: $$ \text{liquidity}_1 = \frac{\text{amount}}{\text{reserve}'} \times \text{liquidity}_0 $$ 由于此时 $\text{reserve}'$ 非常大,由于除法的下溢导致 $\frac{\text{amount}}{\text{reserve}'} = 0$,这使得受害者支付了资产却没有得到 LP 代币。当然,在实际中,我们使用的是先乘后除的 `amount0 * _totalSupply / UQ112.unwrap(_reserve0)` 逻辑,但是由于黑客可以在首次 `mint` 时,将 `amount0 = amount1 = 1 wei` 换取 1 wei 的 LP 代币,所以上述攻击仍然成立。 通过上述分析可以看出,抵御这种攻击的最佳方法就是将初次铸造的数量 $\text{liquidity}_0$ 充分的大,所以存在 `MINIMUM_LIQUIDITY` 的限制。在 `MINIMUM_LIQUIDITY` 存在后,$\text{liquidity}_0$ 至少数值为 1000,这可以有效避免攻击。我们可以推导一下修改后的 $\text{liquidity}_1$ 的计算: $$ \text{liquidity}_1 = \frac{\text{amount}}{\text{reserve}'} \times (1000 + \text{liquidity}_0) $$ 通过上述计算,我们可以看到相比于最初无保护的版本,使用 `MINIMUM_LIQUIDITY` 限制后,黑客的攻击成本扩大了 1000 倍。另外,由于 `MINIMUM_LIQUIDITY` 会被铸造给零地址,黑客无法控制,所以即使完成攻击,黑客也无法取出所有的资金。 > 实际上是在基于 share 机制的 ERC4626 内也存在这种首次 deposit 问题,具体可以参考 Openzepplin 的 [A Novel Defense Against ERC4626 Inflation Attacks](https://www.openzeppelin.com/news/a-novel-defense-against-erc4626-inflation-attacks) 博客 在编写代码前,我们需要区分 `reserve` 和 `balance` 概念。`reserve` 是 Pair 合约内跟踪的代币余额情况,每次 `mint` / `burn` 和 `swap` 后都会使用 `_update` 函数更新,当然也可以使用 `sync` 强制将 `reserve` 的数值更新为与 `balance` 相同的情况。似乎 `reserve == balance` 时始终存在的,那么我们为什么需要两个变量?这实际与 Uniswap v2 的代币转账机制有关。 阅读 Pair 合约的所有代码,读者会发现我们没有使用任何 `transferFrom` 函数,这是因为使用 `transferFrom` 函数从用户侧将资产进行转移会因为某些包含 transfer fee 的代币而导致预期转移数量和实际转移数量不符。比如预期转账 100 个代币,但代币转移存在 0.5% 的税,那么实际上到账为 99.5。这种差异会导致计算错误造成安全风险。所以在 Uniswap v2 内,Uniswap v2 pair 要求用户直接将足够数量的代币发送到合约内,此时会出现 `balance > reserve` 的情况,而用户实际的转移数量 `amount = balance - reserve`。与 Uniswap v2 交互的交易往往包含这种结构: ``` transcation: call #0: transfer token0 call #1: transfer token1 call #3: mint LP token ``` 比如如图所示的 [一笔交易](https://app.blocksec.com/phalcon/explorer/tx/eth/0xec15c00b93d2f8c4e7c6fcb2d1185873901de4f26b1399a0157fb638ba9609b6) 就是通过 Uniswap v2 router 发起的 mint lp token 的交易: ![Mint Trace](https://img.gopic.xyz/UniswapV2MintLPTrace.png) 第二个问题是为什么计算 `liquidity` 的时候用的是 `reserve` 而不是 `balance` 变量? 这是因为用户的转入的资产增加了 balance 的数值,假如使用 balance 计算会低估用户获得的 shares。 最后,我们不难获得如下代码: ```solidity function mint(address to) external lock returns (uint256 liquidity) { UQ112 _reserve0 = reserve0; UQ112 _reserve1 = reserve1; uint256 balance0 = IERC20(token0).balanceOf(address(this)); uint256 balance1 = IERC20(token1).balanceOf(address(this)); uint256 amount0 = balance0 - UQ112.unwrap(_reserve0); uint256 amount1 = balance1 - UQ112.unwrap(_reserve1); bool feeOn = _mintFee(_reserve0, _reserve1); uint256 _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee if (_totalSupply == 0) { liquidity = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY; _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens } else { liquidity = Math.min( amount0 * _totalSupply / UQ112.unwrap(_reserve0), amount1 * _totalSupply / UQ112.unwrap(_reserve1) ); } require(liquidity > 0, "UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED"); _mint(to, liquidity); _update(balance0, balance1, _reserve0, _reserve1); if (feeOn) kLast = reserve0.mul(reserve1); // reserve0 and reserve1 are up-to-date emit Mint(msg.sender, amount0, amount1); } ``` 此处有一个有趣的 gas 优化技巧,即 `UQ112 _reserve0 = reserve0;` 类似语句,这些语句将位于存储槽内的变量读取缓存到栈内部,这是因为在栈内获得变量的成本大大低于读取存储的成本,笔者在之前的 [Solidity Gas 优化清单及其原理:存储、内存与操作符](https://blog.wssh.dev/posts/gas-optimize-part1/) 内详细介绍了该技巧和 gas 成本分析。 此处可以暂不考虑 `_mintFee` 逻辑。其他部分就本就是计算 `amount` 然后代入公式计算。我可以注意到到在 `_totalSupply != 0` 时,用户获得的 `liquidity` 数量为: ```solidity liquidity = Math.min( amount0 * _totalSupply / UQ112.unwrap(_reserve0), amount1 * _totalSupply / UQ112.unwrap(_reserve1) ); ``` 这意味着假如用户输入时 `amount0` 数量与 `amount1` 数量的比值与当前的 `_reserve0` 与 `_reserve1` 的比值不符合,那么用户实际上亏损了一部分资产,这部分资产可以被套利者拿走。在实际情况下,在 `mint` 函数调用前,我们一般都会使用数学库计算合理的 `amount0` 和 `amount1` 以避免上述亏损。 对于其他部分的代码,我们需要额外关注 `if (feeOn) kLast = reserve0.mul(reserve1);`。此处计算了协议手续费所需的 `kLast` 参数。简单来说,协议手续费通过两次更新之间的 `k` 数值的差值来确定征收手续费的数量。我们会在后文介绍具体的协议手续费机制。 在上文介绍完成了添加流动性的 `mint` 函数后,接下来我们介绍用于移除流动性的 `burn` 函数,该函数依赖于以下公式: $$ \text{amount} = \frac{\text{burn liquidity}}{\text{totalSupply}} \times \text{balance} $$ `burn` 函数最有趣的一点是其内部隐含了交易手续费的获取机制。在 `swap` 函数内,我们会要求用户多支付一部分资产进入 Pair 合约,这意味着 `balance` 本身就已经包含了交易手续费。在用户 `burn` 时,用户获得的资产中已经包含了交易手续费部分。这带来的额外好处是 Uniswap v2 的 LP 本身是复利的,即手续费也参与了做市过程,而在 Uniswap v3 和 Uniswap v4 内,手续费复利就不存在了。 相比于 `mint` 函数,`burn` 函数的实现也非常简单: ```solidity function burn(address to) external lock returns (uint amount0, uint amount1) { UQ112 _reserve0 = reserve0; UQ112 _reserve1 = reserve1; uint256 balance0 = IERC20(token0).balanceOf(address(this)); uint256 balance1 = IERC20(token1).balanceOf(address(this)); uint256 liquidity = balanceOf[address(this)]; bool feeOn = _mintFee(_reserve0, _reserve1); uint256 _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee amount0 = liquidity * balance0 / _totalSupply; // using balances ensures pro-rata distribution amount1 = liquidity * balance1 / _totalSupply; // using balances ensures pro-rata distribution require(amount0 > 0 && amount1 > 0, "UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED"); _burn(address(this), liquidity); _safeTransfer(token0, to, amount0); _safeTransfer(token1, to, amount1); balance0 = IERC20(token0).balanceOf(address(this)); balance1 = IERC20(token1).balanceOf(address(this)); _update(balance0, balance1, _reserve0, _reserve1); if (feeOn) kLast = reserve0.mul(reserve1); // reserve0 and reserve1 are up-to-date emit Burn(msg.sender, amount0, amount1, to); } ``` 此处不再赘述。额外注意的是 `burn` 函数也存在 `_mintFee` 的内部调用,用于计算协议手续费。实际上只有 `mint` 和 `burn` 函数内包含 `_mintFee` 调用,这是因为协议手续费计算本质上是通过为手续费接受者铸造额外的 LP token 实现的手续费征收。我们只需要在常规的 LP 管理之前增加相关的手续费机制即可。 ## Swap `swap` 是 Pair 合约内最核心的函数,该函数核心依赖于: $$ K_{\text{afterSwap}} > K $$ 只要用户可以保证 Swap 完成后,代币余额 $x \times y = k$ 大于 Swap 发生前的 k 值即可。所以与上述介绍的 `mint` 函数一致,理论上存在用户向 Pair 多发送资产的情况。但是这种情况一般不会发生,用户可以在交易前套用数学公式计算出正确的结果。 `swap` 函数的第一部分是发送 `amountOut` 代币并使用 `uniswapV2Call` 调用通知 `swap` 发起者将 `amountIn` 代币转移进入 Pair 合约: ```solidity function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external lock { require(amount0Out > 0 || amount1Out > 0, "UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT"); UQ112 _reserve0 = reserve0; UQ112 _reserve1 = reserve1; require(amount0Out < UQ112.unwrap(_reserve0) && amount1Out < UQ112.unwrap(_reserve1), "UniswapV2: INSUFFICIENT_LIQUIDITY"); uint256 balance0; uint256 balance1; { // scope for _token{0,1}, avoids stack too deep errors address _token0 = token0; address _token1 = token1; require(to != _token0 && to != _token1, "UniswapV2: INVALID_TO"); if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data); balance0 = IERC20(_token0).balanceOf(address(this)); balance1 = IERC20(_token1).balanceOf(address(this)); } ``` 我们可以发现 `_safeTransfer` 发生在 `uniswapV2Call` 之前,所以此处存在一种被称为 flashSwap 的闪电贷方法,用户可以直接从 Pair 内要求输出所有资产,然后在自己的接受合约的 `uniswapV2Call` 内部编写资金利用程序,使用资金完成交易后,将资金再次返回给 Pair 合约。但是该闪电贷方法会被征收手续费,`swap` 函数内存在如下代码: ```solidity uint256 amount0In = balance0 > UQ112.unwrap(_reserve0) - amount0Out ? balance0 - (UQ112.unwrap(_reserve0) - amount0Out) : 0; uint256 amount1In = balance1 > UQ112.unwrap(_reserve1) - amount1Out ? balance1 - (UQ112.unwrap(_reserve1) - amount1Out) : 0; require(amount0In > 0 || amount1In > 0, "UniswapV2: INSUFFICIENT_INPUT_AMOUNT"); { // scope for reserve{0,1}Adjusted, avoids stack too deep errors uint256 balance0Adjusted = balance0 * 10000 - amount0In * 25; uint256 balance1Adjusted = balance1 * 10000 - amount1In * 25; require(balance0Adjusted * balance1Adjusted >= uint256(UQ112.unwrap(_reserve0)) * uint256(UQ112.unwrap(_reserve1)) * 10000 ** 2, "UniswapV2: K"); } _update(balance0, balance1, _reserve0, _reserve1); emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to); ``` 我们可以看到利用 `balance0` 和 `balance1`,我们可以计算出在 `uniswapV2Call` 内,用户转入的代币数量 `amount0In` 和 `amount1In`。此处的 `balance0Adjusted` 和 `balance1Adjusted` 是进行流动性手续费的调整,用户输入的一部分代币会被分配到 Pair 内的 LP。所以假如开发者使用上文介绍的 flash swap 技巧,那么在这一步骤内需要缴纳 0.25% 手续费。目前存在一些不征收闪电贷手续费的协议,比如 Morpho 就不征收闪电贷环节的手续费。 在获得手续费调整的 `balance0Adjusted` 和 `balance1Adjusted` 后,我们计算最终的 K 值约束。此处需要注意的是在计算 `balance0Adjusted` 等变量时,我们将 `balance0 * 10000`,所以此处需要将 `_reserve0` 和 `_reserve1` 与 `10000 ** 2` 相乘来抵消额外的放大。 在编程细节上,我们可以看到 `swap` 函数内存在两部分代码位于 `{}` 内部,这是一种常见的避免 `stack too deep` 的方法。由于 EVM 内对栈操作的 opcode 只能处理最多 16 个栈顶元素(`SWAP1` - `SWAP16`),所以存在代码内变量的数量限制。当然,这种限制只针对位于栈内的变量,假如我们使用位于 memory 的 `struct`,那么就不受限制,所以另一种避免 `stack too deep` 的方法是将多余的变量包裹为结构体置于内存中,在 Uniswap v3 / v4 中多见此技巧。 ## 其他功能 我们在上文忽略了 `_mintFee` 的作用,在此处,我们首先介绍 `_mintFee` 的原理。通过上文的 `swap` 函数,我们可以看到手续费被直接留在 Pair 中,这意味着手续费实际上与 $\sqrt{k}$ 是挂钩的。那么我们自然可以想到从交易手续费内划分一部分作为协议手续费的方法应该是: 通过铸造 LP 代币给手续费接受者实现。 我们首先计算协议总体获得的手续费占当前流动性的百分比 $f_{1,2}$。设在 $t_1$ 和 $t_2$ 时间段内: $$ f_{1,2} = \frac{\sqrt{k_2} - \sqrt{k_1}}{\sqrt{k_2}} = 1 - \frac{\sqrt{k_1}}{\sqrt{k_2}} $$ 我们将一部分手续费 $\phi$ (实际数值为 $\frac{1}{6}$)分配给协议,作为协议手续费。这部分协议手续费以 LP 代币的形式发送给 `feeTo`,该部分数量为 $s_m$。由于协议手续费本质上也是交易手续费内划分出的一部分,所以设 $s_1$ 则代表 $t_1$ 时刻时的 LP 代币的 `totalSupply`,那么存在如下关系: $$ \frac{s_m}{s_m + s_1} = \phi f_{1,2} $$ 上述数学公式的含义是将 $s_m$ 占据流动性的份额应该与交易总手续费的增加 $f_{1,2}$ 存在等量关系。基于上述公式,我们可以推出以下 $s_m$ 的表达式: $$ s_m = \frac{\sqrt{k_2} - \sqrt{k_1}}{(\frac{1}{\phi} - 1)\times \sqrt{k_2} + \sqrt{k_1}} \times s_1 $$ 上述代码会被转化为如下函数: ```solidity function _mintFee(UQ112 _reserve0, UQ112 _reserve1) private returns (bool feeOn) { address feeTo = IUniswapV2Factory(factory).feeTo(); feeOn = feeTo != address(0); uint256 _kLast = kLast; // gas savings if (feeOn) { if (_kLast != 0) { uint256 rootK = Math.sqrt(_reserve0.mul(_reserve1)); uint256 rootKLast = Math.sqrt(_kLast); if (rootK > rootKLast) { uint256 numerator = totalSupply * (rootK - rootKLast); uint256 denominator = rootK * 5 + rootKLast; uint256 liquidity = numerator / denominator; if (liquidity > 0) _mint(feeTo, liquidity); } } } else if (_kLast != 0) { kLast = 0; } } ``` 可能有读者好奇为什么手续费机制如此繁琐? 这其实是 DeFi 协议的普遍特点,大部分协议在手续费方面往往都有着并不简单的实现,有的协议手续费计算会极其复杂,比如 balancer v2 协议(该协议被黑了,但是被黑的原因不是手续费计算的问题,具体原因可以参考笔者的博客 [DeFi 安全观察: Balancer V2 架构与舍入漏洞分析](https://blog.wssh.dev/posts/balancer-v2-hack/))。部分协议为了简化甚至选择放弃在核心代码内增加手续费机制,只在外围合约内使用手续费机制。 我们可以看到此处使用了 `Math.sqrt` 进行开方计算,该计算的具体原理是牛顿法,牛顿法会使用如下迭代不断提高开方计算的精度,且会以平方级速度收敛: $$ g_{n+1} = (g_n + \frac{a}{g_n}) / 2 $$ 上述公式内的 $a$ 就是目标结果 $\sqrt{a}$ 内的 $a$。当计算出的 $g_{n+1} \ge g_n$ 时,计算结果收敛,换言之只要 $g_{n+1} < g_n$,那么我们就需要不断调用上述公式进行迭代求值,这就是数学库内 `sqrt` 函数的原理: ```solidity function sqrt(uint256 y) internal pure returns (uint256 z) { if (y > 3) { z = y; uint256 x = y / 2 + 1; while (x < z) { z = x; x = (y / x + x) / 2; } } else if (y != 0) { z = 1; } } ``` 上述代码内的 `x` 就是公式内的 $g_{n+1}$ 而 `z` 就是 $g_n$。当然,Uniswap v2 使用了最简单方法,更加节约 gas 的方法会涉及到初次的 $g_0$ 取值问题。初次猜测的 $g_0$ 与目标值 $\sqrt{y}$ 越接近,那么迭代的次数越少。一般来说,我们会使用大于或等于 $\sqrt{y}$ 且值最小的 2 的幂(使用前导零计数算法可以实现该目的,具体可以参考笔者的博客 [现代 DeFi: Uniswap V4 数学库分析](https://blog.wssh.dev/posts/uniswap-math/#mostsignificantbit))。在 Openzepplin 的 [版本](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/math/Math.sol#L499) 内,就使用了该优化。 最后,我们介绍两个不常见的函数 `skim` 和 `sync`。这两个函数的用途在 [Uniswap v2 白皮书](https://docs.uniswap.org/whitepaper.pdf) 内有着单独一节的介绍。`sync` 用于一些特殊情况,比如 Pair 内的代币被 burn 的情况,此时 `reserve` 与 `balance` 完全不匹配,这会导致所有的操作都失效,所以此时可能调用 `sync` 强制刷新 `reserve` 使其与 `balance` 匹配。 在一些特殊场景下,`sync` 配合代币的 `burn` 操作可以实现无资金投入的拉盘。比如构建 `TOKEN-BNB` 交易对,理论上推高 `TOKEN` 的价格必须要使用 BNB 买入 TOKEN。但假如我们持有 TOKEN 代币的 `burn` 权限,那么我们可以 `burn` 掉部分属于 Pair 的 TOKEN 代币,然后调用 `sync` 函数直接推高币价。但这种操作的本质是烧毁了 LP 的 TOKEN 代币,使用这种方法前建议对利益相关方的影响进行额外评估。 `skim` 只有一个作用,就是对 Pair 内代币余额大于 `uint112` 的情况进行处理,这种情况几乎从未出现,所以在此处我们不再详细介绍。 ## 工厂合约 `UniswapV2Factory` 是一个工厂合约,用于部署上文介绍的 Pair 合约,该合约最大的特点是使用 `create2` 方法进行确定性地址部署,这使得给定任意 `tokenA` 和 `tokenB`,那么我们可以直接计算出部署 Pair 的合约地址。代码实现也非常简单: ```solidity function createPair(address tokenA, address tokenB) external returns (address pair) { require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES'); (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA); require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS'); require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS'); // single check is sufficient bytes memory bytecode = type(UniswapV2Pair).creationCode; bytes32 salt = keccak256(abi.encodePacked(token0, token1)); assembly { pair := create2(0, add(bytecode, 32), mload(bytecode), salt) } IUniswapV2Pair(pair).initialize(token0, token1); getPair[token0][token1] = pair; getPair[token1][token0] = pair; // populate mapping in the reverse direction allPairs.push(pair); emit PairCreated(token0, token1, pair, allPairs.length); } ``` 此处使用了 `create2` 操作码,`create2` 操作码需要以下参数: 1. `value` 部署过程中发送给部署后合约的 ETH 数量 2. `offset` 部署合约字节码在内存中的起始位置 3. `size` 部署合约的字节码长度,配合 `offset` 就可以获得部署合约在内存中的正确位置 4. `salt` 用于确定部署合约后地址的参数,计算地址的公式为 `address = keccak256(0xff + sender_address + salt + keccak256(initialisation_code))[12:]`,开发者可以在终端内使用 `cast create2` 计算 此处我们需要科普对于 `bytes memory bytecode` 类型,该类型属于指针类型,使用 `mload(bytecode)` 从内存中读出的数值为 `bytecode` 的长度,使用 `add(bytecode, 32)` 可以获得 `bytecode` 在内存中真实的开始位置。如下图所示: ![bytes memory](https://img.gopic.xyz/bytesMemeory.svg) ## 总结 本文介绍了 Uniswap v2 内的大部分核心功能,并且给出了大量的相关阅读材料。读者应该可以在阅读完成本文后熟悉 Uniswap v2 core 包括数学库在内的大部分知识。限于篇幅,本文没有介绍合约的测试、TWAP Oracle 的构造以及外围合约 [v2-periphery](https://github.com/Uniswap/v2-periphery) 的内容,这些内容都不难理解。