剖析DeFi交易产品之UniswapV4:添加/移除流动性

2023-11-30 16:51:59 浏览数 (2)

前一篇文章我们已经知道了创建新池子的流程,那接下来就要添加流动性了。而其实,在 PoolManager 合约里,添加和移除流动性都是在同一个函数里统一处理的。当然,要完成添加或移除流动性的全流程,会涉及到多个函数。接下来我们展开一一细说。

当我们想要往一个池子里添加或移除流动性的时候,和创建池子时一样,需要先通过实现了 ILockCallback 接口的合约调用 lock() 函数,激活成为 locker。然后在回调函数 lockAcquired() 里调起 PoolManager 合约的 modifyPosition() 函数。我们先来看其函数声明:

代码语言:javascript复制
function modifyPosition(
    PoolKey memory key,
    IPoolManager.ModifyPositionParams memory params,
    bytes calldata hookData
) external override noDelegateCall onlyByLocker returns (BalanceDelta delta) {...}

先看参数列表,key 指定了要操作流动性的池子,params 指定头寸参数,hookData 是需要传给 Hooks 合约的数据。

params 具体包含了三个字段:

代码语言:javascript复制
struct ModifyPositionParams {
    // the lower and upper tick of the position
    int24 tickLower;
    int24 tickUpper;
    // how to modify the liquidity
    int256 liquidityDelta;
}

即要操作的头寸的 tick 下限和上限,以及要增加或减少的流动性数量 liquidityDelta。如果是要增加流动性,liquidityDelta 为正数,若为负数则说明是要减少流动性。

函数声明里定义了两个函数修饰器,noDelegateCallonlyByLockernoDelegateCall 限制了不能用代理方式调用,onlyByLocker 限制了调用前需要先成为 locker

返回值 delta 记录两个代币的变动值。另外,前面我们已经了解到,BalanceDelta 其实是 amount0amount1 两个数组合到一起的数值。因此,delta 其实记录的也是两个代币的净余额。

接下来,就开始梳理函数体的实现了。先看前面的部分代码如下:

代码语言:javascript复制
// 将池子的key转为id
PoolId id = key.toId();
// 检查池子是否已初始化
_checkPoolInitialized(id);
// 判断是否需要调用hooks合约的beforeModifyPosition钩子函数
if (key.hooks.shouldCallBeforeModifyPosition()) {
    bytes4 selector = key.hooks.beforeModifyPosition(msg.sender, key, params, hookData);
    // Sentinel return value used to signify that a NoOp occurred.
    if (key.hooks.isValidNoOpCall(selector)) return BalanceDeltaLibrary.MAXIMUM_DELTA;
    else if (selector != IHooks.beforeModifyPosition.selector) revert Hooks.InvalidHookResponse();
}

第一行代码,先把 key 转为了 id。然后,根据 id 检查该池子是否已经初始化了,还没初始化的池子自然就不能允许执行添加和移除流动性的操作了。之后,就会判断是否需要调 hooks 合约的 beforeModifyPosition 钩子函数。

接下来的代码就是调用库合约函数执行修改头寸的内部逻辑,如下所示:

代码语言:javascript复制
Pool.FeeAmounts memory feeAmounts;
(delta, feeAmounts) = pools[id].modifyPosition(
    Pool.ModifyPositionParams({
        owner: msg.sender,
        tickLower: params.tickLower,
        tickUpper: params.tickUpper,
        liquidityDelta: params.liquidityDelta.toInt128(),
        tickSpacing: key.tickSpacing
    })
);

需要注意,调用库合约的 modifyPosition 内部函数时,传入的 owner 参数为 msg.sender,即是说,对于 PoolManager 来说,所有的头寸的 owner 都是当前合约的调用者,即调用当前函数的合约。因此,在调用者合约里,还需要对用户级别的头寸进行管理的,即类似 UniswapV3 的 NonfungiblePositionManager 合约还是需要的。

修改头寸的内部函数实现代码还是比较长的,限于篇幅,我们就不贴代码了,就简单介绍下其实现逻辑,主要包括以下几点:

  1. 更新 tick 的下限和上限的元数据
  2. 如果 tick 的流动性从 0 增长为非 0 状态,或从非 0 状态减少成了为 0 的状态,则在 tick 位图中执行翻转操作
  3. 如果是减少流动性且需要执行翻转,清除 tick 元数据
  4. 计算和更新费用增长数据
  5. 更新用户头寸数据
  6. 当前 tick 处于区间内时,更新当前激活的流动性
  7. 计算出两个代币的变动值,即 delta

该内部函数返回了两个值 deltafeeAmountsfeeAmounts 记录了两个代币的协议费和 hook 费用。delta 则记录了两个代币的值。关于 delta 的具体值,有必要展开说明一下。我们分不同场景进行说明。

添加流动性的时候,delta 里的两个数值为非负数。如果添加的流动性是单边的,即价格区间超出了当前价格的话,那 delta 里有一个值是零值。比如,当前价格为 2000,但添加流动性的价格区间是 [3000, 4000],就是添加了单边流动性,则 delta 里的两个代币的数组有一个为正数,有一个为零。

减少流动性的时候,则 delta 里的两个数值为负数。

执行完调整头寸的内部函数之后,接下来的一行代码,会实现将变动的余额累加到状态变量中进行存储:

代码语言:javascript复制
_accountPoolBalanceDelta(key, delta);

该函数的实现其实就是分别将两个代币进行累加存储,如下所示:

代码语言:javascript复制
function _accountPoolBalanceDelta(PoolKey memory key, BalanceDelta delta) internal {
    //处理代币0
    _accountDelta(key.currency0, delta.amount0());
    //处理代币1
    _accountDelta(key.currency1, delta.amount1());
}

function _accountDelta(Currency currency, int128 delta) internal {
    if (delta == 0) return;
    //读出当前的locker
    address locker = Lockers.getCurrentLocker();
   //读出locker当前的余额变动
    int256 current = currencyDelta[locker][currency];
   //累加上最新变动额,成为下一个变动额
    int256 next = current   delta;

    unchecked {
        if (next == 0) {
           //变动账户数量减1
            Lockers.decrementNonzeroDeltaCount();
        } else if (current == 0) {
            //变动账户数量加1
            Lockers.incrementNonzeroDeltaCount();
        }
    }
    //更新存储
    currencyDelta[locker][currency] = next;
}

这里面的逻辑主要有两块。第一是更新 currencyDelta,这是一个嵌套映射类型的状态变量,用来记录每个 locker 的每个代币的余额变动值,其定义如下:

代码语言:javascript复制
mapping(address locker => mapping(Currency currency => int256 currencyDelta)) public currencyDelta;

当值为正的时候,表示池子欠 locker 的金额。当值为负的时候,则表示 locker 欠池子的金额。

第二块是更新存在余额变动的 locker 的计数器。当 current 为 0 的时候,则表示新增了一个有余额变动的 locker,此时需要计数器加一。而 next 变成 0 的时候,则表示有一个 locker 已经完成了余额变动的流程了,从计数器中减一。而实现计数器的加减,本质上其实是使用了瞬态存储操作码 tstoretload 来完成的,以 incrementNonzeroDeltaCount() 函数实现为例,如下所示:

代码语言:javascript复制
function incrementNonzeroDeltaCount() internal {
  //瞬态存储的位置  
  uint256 slot = NONZERO_DELTA_COUNT;
    assembly {
        //读取出当前的计数
        let count := tload(slot)
        //计数加1
        count := add(count, 1)
        //存储新的计数
        tstore(slot, count)
    }
}

_accountPoolBalanceDelta() 函数其实就是对用户做一个记账。记下欠用户多少资产,或用户欠池子多少资产。后面需要调用者完成其他操作来抹平这个账本的。

回到 modifyPosition() 函数本身,执行完余额变动之后,接下来是对一些费用累加到对应的状态变量中,如下所示:

代码语言:javascript复制
unchecked {
    if (feeAmounts.feeForProtocol0 > 0) {
        protocolFeesAccrued[key.currency0]  = feeAmounts.feeForProtocol0;
    }
    if (feeAmounts.feeForProtocol1 > 0) {
        protocolFeesAccrued[key.currency1]  = feeAmounts.feeForProtocol1;
    }
    if (feeAmounts.feeForHook0 > 0) {
        hookFeesAccrued[address(key.hooks)][key.currency0]  = feeAmounts.feeForHook0;
    }
    if (feeAmounts.feeForHook1 > 0) {
        hookFeesAccrued[address(key.hooks)][key.currency1]  = feeAmounts.feeForHook1;
    }
}

最后的一段代码则如下:

代码语言:javascript复制
//是否需要调起hooks合约的afterModifyPosition钩子函数
if (key.hooks.shouldCallAfterModifyPosition()) {
    if (
        key.hooks.afterModifyPosition(msg.sender, key, params, delta, hookData)
            != IHooks.afterModifyPosition.selector
    ) {
        revert Hooks.InvalidHookResponse();
    }
}
//发送事件
emit ModifyPosition(id, msg.sender, params.tickLower, params.tickUpper, params.liquidityDelta);

至此, modifyPosition() 函数就结束了。

但是,我们可以发现,整个函数处理完了之后,并没有涉及到代币转账的逻辑。这里我们需要分开场景说明了。

添加流动性的时候,调用者需要将代币支付给到池子合约,而这个支付操作,其实是需要在调用者合约里实现的 lockAcquired() 回调函数里完成的。具体来说,是需要在调用 modifyPosition() 函数后完成支付,伪代码类似如下:

代码语言:javascript复制
function lockAcquired(bytes calldata data) external returns (bytes memory) {
  ...
  BalanceDelta delta = poolManager.modifyPosition(key, params, hookData);
  if (delta.amount0 > 0) key.currency0.transfer(poolManager, delta.amount0());
  if (delta.amount1 > 0) key.currency1.transfer(poolManager, delta.amount1());
  ...
}

完成支付之后,下一步还需要通知到 PoolManager 合约,把欠的款项在记账系统中进行抹平,这是通过调用 settle() 函数来实现的。以下是 settle() 函数的代码实现:

代码语言:javascript复制
function settle(Currency currency) external payable override noDelegateCall onlyByLocker returns (uint256 paid) {
    //读取出之前的代币储备
    uint256 reservesBefore = reservesOf[currency];
    //代币储备更新为最新余额
    reservesOf[currency] = currency.balanceOfSelf();
    //前后两个储备的差额就是已支付的金额
    paid = reservesOf[currency] - reservesBefore;
    //从记账系统中减去以支付的金额
    _accountDelta(currency, -(paid.toInt128()));
}

reservesOf[currency] 存储的是转账之前的代币余额,而通过 currency.balanceOfSelf() 则可读取出最新的代币余额,这两个余额的差值就是已支付的金额了,最后再从记账系统中减去这部分已支付的金额即可。

前面我们知道,执行完 modifyPosition() 函数之后,记账系统中其实会记了用户欠池子的两个代币数额。完成支付之后,再通过 settle() 函数,最后一行执行 _accountDelta() 就会把这个账本平衡了。

因为 settle() 只处理一个代币,所以需要支付两个代币的时候,就需要调用两次 settle() 函数。

我们把对 settle() 函数的调用也加到前面 lockAcquired() 函数里则大致如下:

代码语言:javascript复制
function lockAcquired(bytes calldata data) external returns (bytes memory) {
  ...
  BalanceDelta delta = poolManager.modifyPosition(key, params, hookData);
  if (delta.amount0 > 0) {
    key.currency0.transfer(poolManager, delta.amount0());
    poolManager.settle(key.currency0);
  }
  if (delta.amount1 > 0) {
    key.currency1.transfer(poolManager, delta.amount1());
    poolManager.settle(key.currency1);
  }
  ...
}

那如果是减少流动性的话,这时候记账系统里记录的是池子欠用户的两个代币。那么,这时候需要调用的则是 take() 函数了。以下是 take() 函数的代码实现:

代码语言:javascript复制
function take(Currency currency, address to, uint256 amount) external override noDelegateCall onlyByLocker {
    //平衡账本
   _accountDelta(currency, amount.toInt128());
    //从储备里减掉提取的数量
    reservesOf[currency] -= amount;
    //转账给用户
    currency.transfer(to, amount);
}

lockAcquired() 函数里完成移除流动性的流程,则实现大致如下:

代码语言:javascript复制
function lockAcquired(bytes calldata data) external returns (bytes memory) {
  ...
  BalanceDelta delta = poolManager.modifyPosition(key, params, hookData);
  if (delta.amount0 < 0) {
    poolManager.take(key.currency0, to, uint256(-delta.amount0));
  }
  if (delta.amount1 < 0) {
    poolManager.settle(key.currency1, to, uint256(-delta.amount1));
  }
  ...
}

最后,回到 lock() 函数里,还有最后一个校验要说明一下,即以下这段代码:

代码语言:javascript复制
if (Lockers.length() == 1) {
    if (Lockers.nonzeroDeltaCount() != 0) revert CurrencyNotSettled();
    Lockers.clear();
} else {
    Lockers.pop();
}

一般情况下,一笔交易里的 locker 只有一个,即会进入 if 语句。而完成了完整流程之后,nonzeroDeltaCount() 是会返回 0 的,如果不为 0,则说明记账系统里该 locker 的账本还没抹平,交易就会失败。

至此,添加和移除流动性的基本流程就到此结束了。

0 人点赞