前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >剖析DeFi交易产品之UniswapV4:添加/移除流动性

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

作者头像
Keegan小钢
发布2023-11-30 16:51:59
2110
发布2023-11-30 16:51:59
举报
文章被收录于专栏:Keegan小钢

前一篇文章我们已经知道了创建新池子的流程,那接下来就要添加流动性了。而其实,在 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 的账本还没抹平,交易就会失败。

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2023-11-29,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Keegan小钢 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
对象存储
对象存储(Cloud Object Storage,COS)是由腾讯云推出的无目录层次结构、无数据格式限制,可容纳海量数据且支持 HTTP/HTTPS 协议访问的分布式存储服务。腾讯云 COS 的存储桶空间无容量上限,无需分区管理,适用于 CDN 数据分发、数据万象处理或大数据计算与分析的数据湖等多种场景。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档