加密日记【015】foundry-uniswapV3 (1)
April 11th, 2023

0.前言

Uniswap是一个去中心化的加密货币交易平台,采用AMM模型,通过算法实现资产交换,而不是传统的订单簿。这使得交易速度更快,流动性更高,同时降低了交易成本。uniswap几乎是各条链上的dex龙头。中心化的交易所需要做市商为市场提供流动性,做市商也通过向交易所提供流动性来赚钱。而去中心化交易所同样需要做市商,区别中心化交易所直接把流动性交给某个做市商团队,去中心的流动性应该是分散的,而不是集中的方式。它是去中心化和无需许可的,不受单一实体的管辖,所有资产不存放在同一个账户,任何人都可以在任何地方使用它。AMM就是自动做市商,是一组定义流动性管理方式的智能合约。每个交易对都是一个单独的合约。核心思想是liquidity pools(LP,流动资金池)。

每个合约都是一个存储流动性的池子,让不同的用户(包括其他智能合约)以许可的方式进行交易。有两个角色,流动性提供者和交易者,这些角色通过流动性池相互交互,他们与池交互的方式是程序化的和不可变的。

1.理论介绍

1.1 V2的理论知识

常数函数做市商

x*y=k

xy 是矿池合约储备——它目前持有的代币数量。 k 只是他们的乘积,实际值并不重要。每次交易后,k 必须保持不变。

交易功能

(x+rΔx)(y−Δy)=k

  • Δx表示我们用代币x购买代币y提供的一定数量的代币x

  • Δy表示通过提供Δx置换出的代币y

  • r=1−swap fee 表示扣除手续费剩余的比例

从上面的公式可以推导出:

我们总是可以使用 Δy 公式找到输出量(当我们想出售已知数量的代币时),我们总是可以使用 Δx 公式找到输入量(当我们想购买已知数量的代币时)。x由y定价,y由x定价,代币价格由池中储备量决定

                                                               Px​=y/x,Py​=x/y​

曲线

其中轴是池储备。每笔交易都从曲线上对应于当前储备比率的点开始。为了计算输出量,我们需要在曲线上找到一个新的点,它的 x 坐标为 xx ,即代币0的当前储备+我们出售的数量。 y 中的变化是我们将获得的令牌 1 的数量。

1.2 V3的理论知识

v2满足当需求高(供应不变)时,价格也高,当需求低时,价格也会降低。实现原理是Uniswap V2 池中的流动性是无限分布的——池中的流动性允许以从 0 到无穷大的任何价格进行交易。但是稳定币交易不符合这个情况,它需要价格稳定,也就是说需要高流动性以减少对大额交易的需求效应。其他没有铆钉的代币,在一段时间内也是小范围波动,并不需要无穷大的范围去提供流动性。因此V3引入了集中的流动性。

集中流动性

流动性提供者现在可以选择他们想要提供流动性的价格范围。这通过允许将更多流动性放入狭窄的价格范围内来提高资本效率,这使得 Uniswap 更加多样化:它现在可以为具有不同波动率的货币对配置池。简而言之,一个 Uniswap V3 对是许多小的 Uniswap V2 对。在较短的价格范围内,它与 Uniswap V2 完全一样。我们在点 ab 处切割它,并称这些是曲线的边界。此外,我们移动曲线,使边界位于轴上。

在上图中,我们假设起始价位于曲线的中间。要到达点 a ,我们需要购买所有可用的 y 并在范围内最大化 x ;要到达点 b ,我们需要购买所有可用的 x 并最大化范围内的 y 。此时,范围内只有一个令牌。这允许使用 Uniswap V3 价格范围作为限价订单!

L 是流动性的数量。池中的流动性是代币储备(即两个数字)的组合。我们知道他们的乘积是 k ,我们可以用它来推导出流动性的衡量标准,即 xy​ ——这个数字乘以自身等于 kLxy 的几何平均数

L 也是输出量变化与 P​ 变化之间的关系

因此:

在 V3 中,整个价格范围由均匀分布的离散刻度划定。每个报价单都有一个索引并对应于特定价格。将区间范围离散成刻度线,基准选择1.0001,因为0.0001在金融度量单位里面表示1个基点。就是说为了避免精度过小而使每个人设置的区间都没有相同的,而需要每一个都单独遍历而耗费巨量gas,精度就设置为0.0001,从一个价格变到下一个价格,只能从0.0001到0.0002。即每个可选价格之间的差值为 0.01%

转换为:

p(i) 是在stick i 的价格。i为0时价格为1,i表示tick的索引。

为了减少开根号的计算,Uniswap V3直接存储的根号P,使用Q64.96精度的定点数来存储,价格(等于 根号P ​ 的平方)在范围内:

stick i的范围:

主动流动性

如果市场价格超出 LP指定的价格范围,他们的流动性将有效地从资金池中移除,并且不再赚取费用。在这种状态下,LP的流动性完全由两种资产中价值较低的资产组成,直到市场价格回到其指定的价格范围或他们决定更新其范围以反映当前价格。

范围订单

LP 可以在高于或低于当前价格的自定义价格范围内存入单个代币:如果市场价格进入他们指定的范围,他们将沿着平滑的曲线将一种资产出售为另一种资产,同时在此过程中赚取掉期费。存款到一个狭窄的范围感觉类似于传统的限价订单。例如,如果 DAI 的当前价格低于 1.001 USDC,Alice 可以将价值 1000 万美元的 DAI 添加到 1.001 - 1.002 DAI/USDC 的范围内。

一旦 DAI 交易价格高于 1.002 DAI/USDC,Alice的流动性将完全转换为 USDC。如果 DAI/USDC 开始低于 1.002 交易,Alice 必须撤回她的流动性(或使用第三方服务代表她撤回)以避免自动转换回 DAI。

灵活收费

Uniswap v3 为每对 LP 提供三个独立的费用等级——0.05%、0.30% 和 1.00%。预计同类资产对将集中在 0.05% 的费用等级附近,而像 ETH/DAI 这样的资产对将使用 0.30%,而exotic assets可能会发现 1.00% 的掉期费用更合适。治理可以根据需要添加额外的费用等级。Uniswap v2 引入了协议费用开关,允许治理开启统一的 5 个基点(LP 费用的 16.66%)费用。 Uniswap v3 协议费用要灵活得多。默认情况下,费用将被关闭,但可以通过治理在每个池的基础上开启,并设置在 LP 费用的 10% 到 25% 之间。

改进预言机

V2 预言机通过每秒存储 Uniswap 对价格的累计和来工作。这些价格总和可以在一个时期的开始和结束时检查一次,以计算该时期的准确 TWAP。Uniswap v3 对 TWAP 预言机进行了重大改进,使得可以在单个链上调用中计算过去约 9 天内的任何近期 TWAP。这是通过存储一组累积和而不是仅存储一个来实现的。

2. 开发环境

前端代码

注意:ethers.js刚出了V6版本,和V5比有很多不同的更新,这次可以尝试用V6体验新特性

git clone git@github.com:Wmengti/web3-eth-scaffold-nextjs.git nextjs-uniswapV3
cd nextjs-uniswapV3
yarn
yarn upgrade
yarn list ethers
yarn add ethers@latest

合约代码

forge init <filename> --vscode

3. 硬编码构建ETH/USDC

计算Liquidity

构建一个流动池,将有一个 ETH/USDC 矿池合约。 ETH 将是 x 储备,USDC 将是 y 储备,我们将当前价格设置为每 1 ETH 5000 USDC,将提供流动性的范围是每 1 ETH 4545-5500 USDC。我们将从池中购买一些 ETH。此时,由于我们只有一个价格范围,我们希望交易价格保持在价格范围内。

在以c点为起始点,向上涨到5500时池子里面就没有eth了,向下跌到4500时,池子里面就没有usdc了。要计算价格范围的 L ,让我们看一下我们之前讨论过的一个有趣的事实:价格范围可以被耗尽。绝对有可能从一个价格范围内购买全部数量的一种代币,而只剩下另一种代币。现在,将上图中的曲线分成两段:一段在起点左侧,一段在起点右侧。我们将计算两个 L ,每个段一个。为什么?因为池中的两个令牌中的每一个都对其中一个段有贡献:左边的段完全由令牌 x 组成,右边的段完全由令牌 y 组成。这是因为在交换期间,价格会朝任一方向移动:要么上涨,要么下跌。对于移动的价格,只需要其中一个代币:

  1. 当价格上涨时,交换只需要代币 x (我们正在购买代币 x ,所以我们只想从池中取出代币 x );

  2. 当价格下跌时,只需要令牌 y 进行交换。

分别计算cb和ca得到的L值是略有不同的,因为存在根号就有四舍五入,选择小的L值。

首先:

带入a、b、c三个点:

转化得到L的两种推导公式:

而这三个点的价格开根号得:

这里的数字通过Q64.96(2**96) 来存储得:

另一个值求得:

在这两个中,我们将选择较小的一个。用得到的L再去算存入的Δx和Δy。

代码解析

uniswap部署了多个Pool合约,每个Pool合约都是一对代币的交易市场。uniswap将所有合约分为两类:core contracts和periphery contracts。在core contracts中有两个主要的合约,Pool contact和 Factory contract。Factory用来部署pool合约。

现在首先创建src/UniswapV3Pool.sol,需要存储的数据有:

  • 两个代币地址,部署期间设置一次并永久使用

  • 将pool contract 映射到合约存储中,key是唯一位置标识符

  • 每一个pool contract还需要ticks registry(报价注册表),也需要在storage中映射一个位置

  • tick范围需要一个常数储存

  • L 流动性的数量也需要一个变量存储

// src/lib/Tick.sol 辅助合约
library Tick {
    struct Info {
        bool initialized;
        uint128 liquidity;
    }
    ...
}

// src/lib/Position.sol
library Position {
    struct Info {
        uint128 liquidity;
    }
    ...
}

// src/UniswapV3Pool.sol
contract UniswapV3Pool {
    using Tick for mapping(int24 => Tick.Info);
    using Position for mapping(bytes32 => Position.Info);
    using Position for Position.Info;

    int24 internal constant MIN_TICK = -887272;
    int24 internal constant MAX_TICK = -MIN_TICK;

    // Pool tokens, immutable
    address public immutable token0;
    address public immutable token1;

    // Packing variables that are read together
    struct Slot0 {
        // Current sqrt(P)
        uint160 sqrtPriceX96;
        // Current tick
        int24 tick;
    }
    Slot0 public slot0;

    // Amount of liquidity, L.
    uint128 public liquidity;

    // Ticks info
    mapping(int24 => Tick.Info) public ticks;
    // Positions info
    mapping(bytes32 => Position.Info) public positions;

    ...

minting

在 Uniswap V2 中提供流动性的过程称为minting。原因是 V2 矿池合约铸造代币(LP-代币)以换取流动性。 V3 没有这样做,但它仍然使用相同的函数名称。让我们也使用它:

function mint(
    address owner,
    int24 lowerTick,
    int24 upperTick,
    uint128 amount
) external returns (uint256 amount0, uint256 amount1) {
    ...
  1. 所有者的地址,以跟踪流动性的所有者。

  2. 上限和下限价位,用于设置价格范围的界限。

  3. 我们想要提供的流动性数量L

让我们概述一下铸币如何运作的快速计划:

  1. 用户指定价格范围和流动性数量;

  2. 合约更新 tickspositions 映射;

  3. 合约计算用户必须发送的代币数量(我们将预先计算并对其进行硬编码);

  4. 合同从用户那里获取代币并验证是否设置了正确的金额。

if (
            lowerTick >= upperTick ||
            lowerTick < MIN_TICK ||
            upperTick > MAX_TICK
        ) revert InvalidTickRange(); //tick是否超过范围

        if (amount == 0) revert ZeroLiquidity();//流动性数量是否为0
//条件符合后存储在ticks和positon两个变量里面
        ticks.update(lowerTick, amount);
        ticks.update(upperTick, amount);

        Position.Info storage position = positions.get(
            owner,
            lowerTick,
            upperTick
        ); //通过hash存储在一个32字节中
        position.update(amount);

从用户那里获取令牌,通过callback完成

function mint(...) ... {
    ...

    uint256 balance0Before;
    uint256 balance1Before;
    if (amount0 > 0) balance0Before = balance0();
    if (amount1 > 0) balance1Before = balance1();
    IUniswapV3MintCallback(msg.sender).uniswapV3MintCallback(
        amount0,
        amount1
    );
    if (amount0 > 0 && balance0Before + amount0 > balance0())
        revert InsufficientInputAmount();
    if (amount1 > 0 && balance1Before + amount1 > balance1())
        revert InsufficientInputAmount();

    ...
}

function balance0() internal returns (uint256 balance) {
    balance = IERC20(token0).balanceOf(address(this));
}

function balance1() internal returns (uint256 balance) {
    balance = IERC20(token1).balanceOf(address(this));
}

Test

使用来自 Solmate 的 ERC20 合约,这是一个 gas 优化合约的集合,并制作一个继承自 Solmate 合约并公开铸造(默认情况下是公开的)的 ERC20 合约。

$ forge install rari-capital/solmate

在这里创建一个跟ETH同名的ERC20.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.14;

import "solmate/tokens/ERC20.sol";

contract ERC20Mintable is ERC20 {
    constructor(
        string memory _name,
        string memory _symbol,
        uint8 _decimals
    ) ERC20(_name, _symbol, _decimals) {}

    function mint(address to, uint256 amount) public {
        _mint(to, amount);
    }
}
// test/UniswapV3Pool.t.sol
...
import "./ERC20Mintable.sol";
import "../src/UniswapV3Pool.sol";

contract UniswapV3PoolTest is Test {
    ERC20Mintable token0;
    ERC20Mintable token1;
    UniswapV3Pool pool;

    function setUp() public {
        token0 = new ERC20Mintable("Ether", "ETH", 18);
        token1 = new ERC20Mintable("USDC", "USDC", 18);
    }

    ...

mint 测试

function testMintSuccess() public {
    TestCaseParams memory params = TestCaseParams({
        wethBalance: 1 ether,
        usdcBalance: 5000 ether,
        currentTick: 85176,
        lowerTick: 84222,
        upperTick: 86129,
        liquidity: 1517882343751509868544,
        currentSqrtP: 5602277097478614198912276234240,
        shouldTransferInCallback: true,
        mintLiqudity: true
    });

构建函数setupTestCase 去调用上面的参数,并实例化UniswapV3Pool:

function setupTestCase(TestCaseParams memory params)
    internal
    returns (uint256 poolBalance0, uint256 poolBalance1)
{
    token0.mint(address(this), params.wethBalance);
    token1.mint(address(this), params.usdcBalance);

    pool = new UniswapV3Pool(
        address(token0),
        address(token1),
        params.currentSqrtP,
        params.currentTick
    );

    if (params.mintLiqudity) {
        (poolBalance0, poolBalance1) = pool.mint(
            address(this),
            params.lowerTick,
            params.upperTick,
            params.liquidity
        );
    }

    shouldTransferInCallback = params.shouldTransferInCallback;
}

在这个函数中,我们正在铸造代币并部署一个矿池。此外,当设置 mintLiquidity 标志时,我们会在池中增加流动性。最后,我们设置 shouldTransferInCallback 标志以便在 mint 回调中读取它

function uniswapV3MintCallback(uint256 amount0, uint256 amount1) public {
    if (shouldTransferInCallback) {
        token0.transfer(msg.sender, amount0);
        token1.transfer(msg.sender, amount1);
    }
}

testMintSuccess 中,我们要测试矿池合约:

  1. 正确数量的代币;

    (uint256 poolBalance0, uint256 poolBalance1) = setupTestCase(params);
    
    uint256 expectedAmount0 = 0.998976618347425280 ether;
    uint256 expectedAmount1 = 5000 ether;
    assertEq(
        poolBalance0,
        expectedAmount0,
        "incorrect token0 deposited amount"
    );
    assertEq(
        poolBalance1,
        expectedAmount1,
        "incorrect token1 deposited amount"
    );
    //检查这些金额是否实际转移到池中
    assertEq(token0.balanceOf(address(pool)), expectedAmount0);
    assertEq(token1.balanceOf(address(pool)), expectedAmount1);
    
  2. 创建一个具有Position key和流动性的头寸;

    bytes32 positionKey = keccak256(
        abi.encodePacked(address(this), params.lowerTick, params.upperTick)
    );
    uint128 posLiquidity = pool.positions(positionKey);
    assertEq(posLiquidity, params.liquidity);
    
  3. 初始化我们指定的上限和下限;

    (bool tickInitialized, uint128 tickLiquidity) = pool.ticks(
        params.lowerTick
    );
    assertTrue(tickInitialized);
    assertEq(tickLiquidity, params.liquidity);
    
    (tickInitialized, tickLiquidity) = pool.ticks(params.upperTick);
    assertTrue(tickInitialized);
    assertEq(tickLiquidity, params.liquidity);
    
  4. 有正确的 P​ 和 L

    (uint160 sqrtPriceX96, int24 tick) = pool.slot0();
    assertEq(
        sqrtPriceX96,
        5602277097478614198912276234240,
        "invalid current sqrtP"
    );
    assertEq(tick, 85176, "invalid current tick");
    assertEq(
        pool.liquidity(),
        1517882343751509868544,
        "invalid current liquidity"
    );
    

计算swap

当我们用42USDC去买ETH时,需要计算出返回多少数量的ETH,在这个范围内L是不变的

得到x也就是ETH:0.008396714242162444

代码解析

  function swap(
        address recipient,
        bytes calldata data
    ) public returns (int256 amount0, int256 amount1) {
        int24 nextTick = 85184;
        uint160 nextPrice = 5604469350942327889444743441197;

        amount0 = -0.008396714242162444 ether;
        amount1 = 42 ether;

        (slot0.tick, slot0.sqrtPriceX96) = (nextTick, nextPrice);

        IERC20(token0).transfer(recipient, uint256(-amount0));

        uint256 balance1Before = balance1();
        IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(
            amount0,
            amount1,
            data
        );
        if (balance1Before + uint256(amount1) > balance1())
            revert InsufficientInputAmount();

        emit Swap(
            msg.sender,
            recipient,
            amount0,
            amount1,
            slot0.sqrtPriceX96,
            liquidity,
            slot0.tick
        );
    }

参考

Subscribe to 0x3c
Receive the latest updates directly to your inbox.
Nft graphic
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.
More from 0x3c

Skeleton

Skeleton

Skeleton