剖析DeFi交易产品之UniswapV3:头寸管理合约

实现用户层面的流动性头寸管理的合约是NonfungiblePositionManager合约,其实现比较复杂,还继承了很多子合约,限于篇幅,我们无法全都一一讲解,就只能挑一些重点的来讲。

本文首发于公众号:Keegan小钢


实现用户层面的流动性头寸管理的合约是 NonfungiblePositionManager 合约,其实现比较复杂,还继承了很多子合约,限于篇幅,我们无法全都一一讲解,就只能挑一些重点的来讲。

前面我们说过,UniswapV3 的 LP Token 其实是不可互换的 NFT,是 ERC721 Token。实际上,NonfungiblePositionManager 就继承了 ERC721,从代码上来看,继承了 ERC721Permit 抽象合约,所以,所有 LP Token(即头寸)都是在 NonfungiblePositionManager 合约里进行管理的。

我们主要看几个核心流程,包括创建并初始化流动性池、创建新头寸、为头寸增加流动性、减少流动性、提取手续费收益、销毁头寸。

创建并初始化流动性池

创建并初始化流动性池对应的函数为 createAndInitializePoolIfNecessary,其代码实现是在所继承的抽象合约 PoolInitializer 里,实现代码如下:

function createAndInitializePoolIfNecessary(
    address token0,
    address token1,
    uint24 fee,
    uint160 sqrtPriceX96
) external payable override returns (address pool) {
    require(token0 < token1);
    // 从工厂合约查出pool地址
    pool = IUniswapV3Factory(factory).getPool(token0, token1, fee);

    if (pool == address(0)) { // pool合约未创建
        pool = IUniswapV3Factory(factory).createPool(token0, token1, fee);
        IUniswapV3Pool(pool).initialize(sqrtPriceX96);
    } else {
        (uint160 sqrtPriceX96Existing, , , , , , ) = IUniswapV3Pool(pool).slot0();
        if (sqrtPriceX96Existing == 0) {
            IUniswapV3Pool(pool).initialize(sqrtPriceX96);
        }
    }
}

传入参数为 token0、token1、fee 和初始根号价格 sqrtPriceX96。

实现现逻辑很简单,先根据 token0、token1、fee 这三个字段从工厂合约查询 pool 是否已经存在。如果不存在的话,则通过工厂合约的 createPool 函数创建池子,再通过 IUniswapV3Pool (即池子的合约)的 initialize 函数初始化池子的价格和 tick 等状态。如果从工厂合约查询到 pool 已经存在了,那就通过读取池子合约的 slot0 来获取到其当前价格,如果价格为 0 就表示还没初始化,所以再执行一次 initialize

创建头寸

createAndInitializePoolIfNecessary 只是完成了池子的创建和初始化状态,这时候的池子里还没有任何头寸和流动性的。下一步需要调用 NonfungiblePositionManager 合约的 mint 函数来创建头寸并添加流动性。先看其入参,mint 函数的入参是一个结构体 MintParams。当一个函数要传的参数比较多的时候,把这些参数封装到一个结构体对象里是常用的做法。MintParams 具体的参数如下:

struct MintParams {
    address token0; //组成池子的token0
    address token1; //组成池子的token1 
    uint24 fee; //组成池子的费率
    int24 tickLower; //价格区间的下限对应的tick序号 
    int24 tickUpper; //价格区间的上限对应的tick序号
    uint256 amount0Desired; //要添加作为流动性的token0数量(预估值)
    uint256 amount1Desired; //要添加作为流动性的token1数量(预估值)
    uint256 amount0Min; //作为流动性的token0最小数量
    uint256 amount1Min; //作为流动性的tokne1最小数量
    address recipient; //接收头寸的地址
    uint256 deadline; //过期时间
}

其中,amount0Desiredamount1Desired 其实是预估的数量。从用户端发起交易,到实际链上执行交易是存在时延的,这期间可能有其他用户也添加了流动性,所以最终成交时的数量可能会和 Desired 的值不一样。如果期间价格变化比较大,也会导致用户实际成交时的滑点很大,因此在前端页面上其实会有一个滑点设置来保护用户实际成交时不会超过设置的滑点值。amount0Minamount1Min 就是根据设置的滑点值计算出来的。

过期时间则和 UniswapV2 时候的过期时间是一样用法,当实际执行交易时,如果已经过了这个 deadline 时间,就不会继续执行交易。

以下是 mint 函数的代码实现:

function mint(MintParams calldata params)
    external
    payable
    override
    checkDeadline(params.deadline) //检查是否过期
    returns (
        uint256 tokenId, //ERC721的tokenId
        uint128 liquidity, //添加所得的流动性数额
        uint256 amount0, //实际成交的amount0
        uint256 amount1  //实际成交的amount1
    )
{
    IUniswapV3Pool pool;
    //调用内部函数实现流动性的添加
    (liquidity, amount0, amount1, pool) = addLiquidity(
        AddLiquidityParams({
            token0: params.token0,
            token1: params.token1,
            fee: params.fee,
            recipient: address(this), //底层流动性的接收者是当前地址
            tickLower: params.tickLower,
            tickUpper: params.tickUpper,
            amount0Desired: params.amount0Desired,
            amount1Desired: params.amount1Desired,
            amount0Min: params.amount0Min,
            amount1Min: params.amount1Min
        })
    );
        //铸造给用户一个ERC721 tokenId,作为当前流动性头寸的凭证,tokenId是递增的
    _mint(params.recipient, (tokenId = _nextId++));
        //计算出头寸的Key
    bytes32 positionKey = PositionKey.compute(address(this), params.tickLower, params.tickUpper);
    //从头寸数据里获取出最近手续费数据
    (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey);

    //获取poolId,和tokenId一样,也是递增的
    uint80 poolId =
        cachePoolKey(
            address(pool),
            PoolAddress.PoolKey({token0: params.token0, token1: params.token1, fee: params.fee})
        );
        //存储用户的头寸数据
    _positions[tokenId] = Position({
        nonce: 0,
        operator: address(0),
        poolId: poolId,
        tickLower: params.tickLower,
        tickUpper: params.tickUpper,
        liquidity: liquidity,
        feeGrowthInside0LastX128: feeGrowthInside0LastX128,
        feeGrowthInside1LastX128: feeGrowthInside1LastX128,
        tokensOwed0: 0,
        tokensOwed1: 0
    });
        //发送事件
    emit IncreaseLiquidity(tokenId, liquidity, amount0, amount1);
}

内部实现的第一步是调用了内部函数 addLiquidity 来添加流动性,该函数的代码实现是在继承的抽象合约 LiquidityManagement 里,代码实现如下:

function addLiquidity(AddLiquidityParams memory params)
    internal
    returns (
        uint128 liquidity,
        uint256 amount0,
        uint256 amount1,
        IUniswapV3Pool pool
    )
{
    PoolAddress.PoolKey memory poolKey =
        PoolAddress.PoolKey({token0: params.token0, token1: params.token1, fee: params.fee});
    //计算出pool地址,转为IUniswapV3Pool实例    
    pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey));

    {
            //读取出当前价格
        (uint160 sqrtPriceX96, , , , , , ) = pool.slot0();
        //根据tick计算出对应的价格
        uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(params.tickLower);
        uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(params.tickUpper);
                //计算出流动性数额
        liquidity = LiquidityAmounts.getLiquidityForAmounts(
            sqrtPriceX96,
            sqrtRatioAX96,
            sqrtRatioBX96,
            params.amount0Desired,
            params.amount1Desired
        );
    }
        //调用底层池子的mint函数
    (amount0, amount1) = pool.mint(
        params.recipient, //这里的recipient是当前合约地址
        params.tickLower,
        params.tickUpper,
        liquidity,
        abi.encode(MintCallbackData({poolKey: poolKey, payer: msg.sender})) //回调数据
    );
        //检查是否超过滑点值
    require(amount0 >= params.amount0Min && amount1 >= params.amount1Min, 'Price slippage check');
}

其中,pool 地址是通过 PoolAddress 库的 computeAddress 函数计算出来的。具体算法也很简单,其代码如下:

function computeAddress(address factory, PoolKey memory key) internal pure returns (address pool) {
    require(key.token0 < key.token1);
    pool = address(
        uint256(
            keccak256(
                abi.encodePacked(
                    hex'ff',
                    factory,
                    keccak256(abi.encode(key.token0, key.token1, key.fee)),
                    POOL_INIT_CODE_HASH
                )
            )
        )
    );
}

这也是因为使用了 create2 的方式创建 pool 合约,所以才可以使用这种方式来计算合约地址。链下也可以使用同样方式来单独计算 pool 地址。

接着,用大括号括起来的代码段实现了计算 liquidity,即当前加入该头寸中的流动性数量。该流动性的计算可分为三种情况:

  • 当前价格小于价格区间下限时,流动性 L 的计算公式如下:
liquidity = amount0 * (sqrt(upper) * sqrt(lower)) / (sqrt(upper) - sqrt(lower))
  • 当前价格大于价格区间上限时,则使用如下计算公式:
liquidity = amount1 / (sqrt(upper) - sqrt(lower))
  • 当前价格处于价格区间内时,以上两个公式都计算,然后取最小值的那个。

之后,就调用 pool 合约的 mint 函数实现底层的添加流动性操作了,会返回实际成交的 amount0amount1。调用底层 mint 函数的时候,最后一个参数将 poolKeypayer 编码成 data 以供回调函数使用。

至此,LiquidityManagement 抽象合约里的 addLiquidity 函数逻辑就这些了。

回到 NonfungiblePositionManager 合约的 mint 函数,执行完 addLiquidity 函数之后,调用了 _mint 函数,如下:

_mint(params.recipient, (tokenId = _nextId++));

这其实是 ERC721 实现的内部函数,也是铸造新 NFT 的函数,第一个参数是接收该 NFT 的地址,第二个参数是该 NFT 的 tokenId。每个 NFT 就是一个头寸。这里,tokenId = _nextId++,即 tokenId 会被赋值为当前 _nextId 的值,然后 _nextId 自增 1。_nextIdNonfungiblePositionManager 合约的私有状态变量,默认从 1 开始。即是说,代表头寸的 tokenId 其实是根据创建顺序进行递增的。

之后,再计算出 poolId 等数据,创建一个 Position 对象实例,以 tokenId 为 key,存储到 _positions 里。_positons 是一个 mapping 类型的私有状态变量,用于存储所有头寸对象。

最后,发送 IncreaseLiquidity 事件。至此,mint 函数的逻辑就完成了。

在这个 mint 函数里,并没有支付 token 的逻辑。支付是在 uniswapV3MintCallback 回调函数中完成的,该函数封装在了被继承的抽象合约 LiquidityManagement 里,以下是该回调函数的实现:

function uniswapV3MintCallback(
    uint256 amount0Owed,
    uint256 amount1Owed,
    bytes calldata data
) external override {
        //解码出data数据
    MintCallbackData memory decoded = abi.decode(data, (MintCallbackData));
    //校验callback的调用者
    CallbackValidation.verifyCallback(factory, decoded.poolKey);
        //完成支付,其中msg.sender是pool合约
    if (amount0Owed > 0) pay(decoded.poolKey.token0, decoded.payer, msg.sender, amount0Owed);
    if (amount1Owed > 0) pay(decoded.poolKey.token1, decoded.payer, msg.sender, amount1Owed);
}

核心逻辑很简单,先把 data 解码出来,变成 MintCallbackData 结构体数据,从中可读取出具体参数。

verifyCallback 里面会计算出 pool 地址,并有一行校验:

require(msg.sender == address(pool));

即是说,调用当前 uniswapV3MintCallback 函数的需是 pool 合约。

之后就通过 pay 函数完成支付,从 payer 支付给 msg.sender,即 pool 合约。我们看看 pay 函数的实现:

function pay(
    address token,
    address payer,
    address recipient,
    uint256 value
) internal {
    if (token == WETH9 && address(this).balance >= value) {
        // pay with WETH9
        IWETH9(WETH9).deposit{value: value}(); // wrap only what is needed to pay
        IWETH9(WETH9).transfer(recipient, value);
    } else if (payer == address(this)) {
        // pay with tokens already in the contract (for the exact input multihop case)
        TransferHelper.safeTransfer(token, recipient, value);
    } else {
        // pull payment
        TransferHelper.safeTransferFrom(token, payer, recipient, value);
    }
}

如果第一个 if 语句成立,则说明用户实际支付的是 ETH,这时候就需要把 ETH 转为 WETH 再执行转账,因为底层的池子只接收 ERC20 Token。

为头寸增加流动性

在已有的头寸上是可以再增加流动性的,increaseLiquidity 函数支持该功能。以下是该函数的代码实现:

struct IncreaseLiquidityParams {
    uint256 tokenId; //头寸的tokenId
    uint256 amount0Desired; //预估添加的amount0
    uint256 amount1Desired; //预估添加的amount1
    uint256 amount0Min; //滑点保护的最小amount0
    uint256 amount1Min; //滑点保护的最小amount1
    uint256 deadline; //过期时间
}

function increaseLiquidity(IncreaseLiquidityParams calldata params)
    external
    payable
    override
    checkDeadline(params.deadline) //检查是否过期
    returns (
        uint128 liquidity, //增加的流动性
        uint256 amount0, //实际支付的amount0
        uint256 amount1  //实际支付的amount1
    )
{
        //读取出头寸对象实例
    Position storage position = _positions[params.tokenId];
        //根据poolId读取出poolKey
    PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId];

    IUniswapV3Pool pool;
    //调用内部函数实现流动性的添加
    (liquidity, amount0, amount1, pool) = addLiquidity(
        AddLiquidityParams({
            token0: poolKey.token0,
            token1: poolKey.token1,
            fee: poolKey.fee,
            tickLower: position.tickLower,
            tickUpper: position.tickUpper,
            amount0Desired: params.amount0Desired,
            amount1Desired: params.amount1Desired,
            amount0Min: params.amount0Min,
            amount1Min: params.amount1Min,
            recipient: address(this)
        })
    );
        //计算出头寸的Key
    bytes32 positionKey = PositionKey.compute(address(this), position.tickLower, position.tickUpper);
    //从头寸数据里获取出最近手续费数据
    (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey);

        //将之前赚取的手续费收益结算到tokensOwedX里
    position.tokensOwed0 += uint128(
        FullMath.mulDiv(
            feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128,
            position.liquidity,
            FixedPoint128.Q128
        )
    );
    position.tokensOwed1 += uint128(
        FullMath.mulDiv(
            feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128,
            position.liquidity,
            FixedPoint128.Q128
        )
    );
        //更新最近手续费数据
    position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128;
    position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128;
    //累加流动性
    position.liquidity += liquidity;
        //发送事件
    emit IncreaseLiquidity(params.tokenId, liquidity, amount0, amount1);
}

看其入参,是需要指定头寸的 tokenId 的。实现逻辑的第一行代码就是使用 tokenId 为 key,从 _positions 读取出 Position 对象,后面的逻辑主要目的也是更新该 Position 对象的数据。

然后,也和 mint 函数一样调用了 addLiquidity 函数来完成内部的添加流动性逻辑。之后,会把该头寸内之前的流动性所赚取的手续费收益结算到 Position 对象的 tokensOwed0tokensOwed1 字段,并更新 feeGrowthInside0LastX128feeGrowthInside1LastX128,以及累加流动性 liquidity

最后,同样发送了 IncreaseLiquidity 事件。

减少流动性

可以在一个头寸上增加流动性,自然也可以减少流动性,实现该功能的函数为 decreaseLiquidity,以下是其代码实现:

struct DecreaseLiquidityParams {
    uint256 tokenId; //头寸的tokenId
    uint128 liquidity; //待减少的流动性
    uint256 amount0Min; //滑点保护的最小amount0
    uint256 amount1Min; //滑点保护的最小amount1
    uint256 deadline; //过期时间
}

function decreaseLiquidity(DecreaseLiquidityParams calldata params)
    external
    payable
    override
    isAuthorizedForToken(params.tokenId) //检查调用者是否有权限操作该头寸
    checkDeadline(params.deadline) //检查是否过期
    returns (uint256 amount0, uint256 amount1)
{
    require(params.liquidity > 0);
    //读取出头寸实例
    Position storage position = _positions[params.tokenId];
        //头寸里的总流动性
    uint128 positionLiquidity = position.liquidity;
    require(positionLiquidity >= params.liquidity);

    PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId];
    IUniswapV3Pool pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey));
    //调用底层池子的移除流动性函数
    (amount0, amount1) = pool.burn(position.tickLower, position.tickUpper, params.liquidity);
        //滑点校验
    require(amount0 >= params.amount0Min && amount1 >= params.amount1Min, 'Price slippage check');

    bytes32 positionKey = PositionKey.compute(address(this), position.tickLower, position.tickUpper);
    // this is now updated to the current transaction
    (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey);
        //结算amount0、amount1和手续费收益
    position.tokensOwed0 +=
        uint128(amount0) +
        uint128(
            FullMath.mulDiv(
                feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128,
                positionLiquidity,
                FixedPoint128.Q128
            )
        );
    position.tokensOwed1 +=
        uint128(amount1) +
        uint128(
            FullMath.mulDiv(
                feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128,
                positionLiquidity,
                FixedPoint128.Q128
            )
        );
        //更新最新手续费
    position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128;
    position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128;
    //从头寸中减少流动性
    position.liquidity = positionLiquidity - params.liquidity;
        //发送事件
    emit DecreaseLiquidity(params.tokenId, params.liquidity, amount0, amount1);
}

其入参除了指定头寸的 tokenId,还指定了 liquidity,这是要移除的流动性数量。

相比前面的添加流动性操作,减少流动性函数还多了另一个函数修饰器 isAuthorizedForToken,这是为了校验调用者 msg.sender 是否有权限操作该头寸对应的 tokenId,只有该 tokenIdowner 或获得授权的用户才可以执行减少流动性的操作。

实现逻辑倒也简单,先通过 tokenId 从 _positions 读取出 Position 对象,然后校验头寸里的流动性不能小于要移除的流动性。之后,计算出 pool 地址,并调用 pool 合约底层的 burn 函数来实现底层的移除流动性操作。然后,和增加流动性时一样,结算之前的手续费收益并更新手续费相关字段,移除的流动性也相应从头寸中减少。另外,有一点要注意,结算头寸的 tokensOwed 时,除了手续费收益之外,移除流动性计算所得的 amount0amount1 也同样结算到了该字段里。都统一通过 collect 函数进行提取。

最后,发送 DecreaseLiquidity 事件。

提取手续费收益和流动性移除的代币

UniswapV2 的手续费收益是在移除流动性时一起提取走的,但 UniswapV3 的手续费收益是单独提取的,通过 collect 函数进行提取。而且,移除流动性结算所得的两个代币也是通过 collect 函数一起提取。以下为其代码实现:

struct CollectParams {
    uint256 tokenId; //头寸的tokenId
    address recipient; //接收地址
    uint128 amount0Max; //提取的最大amount0
    uint128 amount1Max; //提取的最大amount1
}

function collect(CollectParams calldata params)
    external
    payable
    override
    isAuthorizedForToken(params.tokenId) //检查调用者是否有权限操作该头寸
    returns (uint256 amount0, uint256 amount1)
{
    require(params.amount0Max > 0 || params.amount1Max > 0);
    //如果参数设置的接收地址为0地址,则实际提取到当前合约地址
    address recipient = params.recipient == address(0) ? address(this) : params.recipient;
        //读取出头寸对象实例
    Position storage position = _positions[params.tokenId];

    PoolAddress.PoolKey memory poolKey = _poolIdToPoolKey[position.poolId];
        //计算出pool地址
    IUniswapV3Pool pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey));
        //缓存两个可提取的金额
    (uint128 tokensOwed0, uint128 tokensOwed1) = (position.tokensOwed0, position.tokensOwed1);

    // trigger an update of the position fees owed and fee growth snapshots if it has any liquidity
    if (position.liquidity > 0) {
            //调用底层的burn函数,但传入的流动性为0,可结算最新的手续费收益
        pool.burn(position.tickLower, position.tickUpper, 0);
        //读取出最新的手续费收益
        (, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) =
            pool.positions(PositionKey.compute(address(this), position.tickLower, position.tickUpper));
                //结算手续费收益
        tokensOwed0 += uint128(
            FullMath.mulDiv(
                feeGrowthInside0LastX128 - position.feeGrowthInside0LastX128,
                position.liquidity,
                FixedPoint128.Q128
            )
        );
        tokensOwed1 += uint128(
            FullMath.mulDiv(
                feeGrowthInside1LastX128 - position.feeGrowthInside1LastX128,
                position.liquidity,
                FixedPoint128.Q128
            )
        );
                //更新最新的手续费收益
        position.feeGrowthInside0LastX128 = feeGrowthInside0LastX128;
        position.feeGrowthInside1LastX128 = feeGrowthInside1LastX128;
    }

    //提取金额最多为入参设置的最大值
    (uint128 amount0Collect, uint128 amount1Collect) =
        (
            params.amount0Max > tokensOwed0 ? tokensOwed0 : params.amount0Max,
            params.amount1Max > tokensOwed1 ? tokensOwed1 : params.amount1Max
        );

    //调用底层的collect函数执行实际的提取操作
    (amount0, amount1) = pool.collect(
        recipient,
        position.tickLower,
        position.tickUpper,
        amount0Collect,
        amount1Collect
    );

    //从结算字段中减少已提取的数额
    (position.tokensOwed0, position.tokensOwed1) = (tokensOwed0 - amount0Collect, tokensOwed1 - amount1Collect);

    emit Collect(params.tokenId, recipient, amount0Collect, amount1Collect);
}

入参有 4 个,tokenId 指定要从哪个头寸提取,recipient 是接收地址,amount0Maxamount1Max 是要提取的最大数量。提取的代币,是从 Position 对象中的 tokensOwed0tokensOwed1 中提取的。另外 tokensOwed0tokensOwed1 只是记录了上一次结算的收益,因此,还会把从上一次结算到当前的收益也计算出来,也累加到 tokensOwed0tokensOwed1。并更新最新的 feeGrowthInside0LastX128feeGrowthInside1LastX128。而且,还会调用 pool 合约底层的 burn 函数来更新手续费,因为没有移除流动性,所以调用 burn 时的第三个参数为 0。如果最后计算出来的 tokensOwed 大于入参的最大提取数量,那就只会提取最大数量。而实际提取的操作,也是在 pool 合约底层的 collect 函数执行的。

销毁头寸

当一个头寸已经没有流动性了,手续费收益等也已经提取完了,则可以将该头寸进行销毁操作。实现逻辑非常简单,以下是其代码实现:

function burn(uint256 tokenId) external payable override isAuthorizedForToken(tokenId) {
    Position storage position = _positions[tokenId];
    require(position.liquidity == 0 && position.tokensOwed0 == 0 && position.tokensOwed1 == 0, 'Not cleared');
    delete _positions[tokenId];
    _burn(tokenId);
}

首先,只有该头寸的拥有者或授权者才有权限执行销毁操作。其次,require 里表明了需要该头寸的 liquidity、tokensOwed0、tokensOwed1 都为 0 的情况下才允许销毁。然后,通过 delete _positions[tokenId] 删除该 tokenId 所存储的头寸数据。再通过 _burn(tokenId) 销毁该 NFT。

至此,整个头寸管理合约的核心操作就讲解完毕了。

点赞 1
收藏 2
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Keegan小钢
Keegan小钢
0x9EF5...063c
公众号自媒体「Keegan小钢」,Web3从业者、培训讲师