{"content":{"title":"剖析DeFi交易产品之UniswapV4：添加/移除流动性","body":"文章首发于公众号：[Keegan小钢](https://mp.weixin.qq.com/s?__biz=MzA5OTI1NDE0Mw==&mid=2652494697&idx=1&sn=94fc4dd59eef628172f662f6612143f2&chksm=8b685179bc1fd86f14ef947a67b8b76a9af7215724610ff6aa68be1fb9a32db37d9985d62033&token=1287901096&lang=zh_CN#rd)\r\n***\r\n[前一篇文章](https://mp.weixin.qq.com/s?__biz=MzA5OTI1NDE0Mw==&mid=2652494690&idx=1&sn=314940585188acc52e6912b24e1d4448&chksm=8b685172bc1fd864cf7674a3e27a6ef7b88ee97627dde1148603f53521538873b7987a5d3396&token=1287901096&lang=zh_CN#rd)我们已经知道了创建新池子的流程，那接下来就要添加流动性了。而其实，在 **PoolManager** 合约里，添加和移除流动性都是在同一个函数里统一处理的。当然，要完成添加或移除流动性的全流程，会涉及到多个函数。接下来我们展开一一细说。\r\n\r\n当我们想要往一个池子里添加或移除流动性的时候，和创建池子时一样，需要先通过实现了 **ILockCallback** 接口的合约调用 `lock()` 函数，激活成为 `locker`。然后在回调函数 `lockAcquired()` 里调起 **PoolManager** 合约的 `modifyPosition()` 函数。我们先来看其函数声明：\r\n\r\n```C++\r\nfunction modifyPosition(\r\n    PoolKey memory key,\r\n    IPoolManager.ModifyPositionParams memory params,\r\n    bytes calldata hookData\r\n) external override noDelegateCall onlyByLocker returns (BalanceDelta delta) {...}\r\n```\r\n\r\n先看参数列表，`key` 指定了要操作流动性的池子，`params` 指定头寸参数，`hookData` 是需要传给 Hooks 合约的数据。\r\n\r\n`params` 具体包含了三个字段：\r\n\r\n```C++\r\nstruct ModifyPositionParams {\r\n    // the lower and upper tick of the position\r\n    int24 tickLower;\r\n    int24 tickUpper;\r\n    // how to modify the liquidity\r\n    int256 liquidityDelta;\r\n}\r\n```\r\n\r\n即要操作的头寸的 tick 下限和上限，以及要增加或减少的流动性数量 `liquidityDelta`。如果是要增加流动性，`liquidityDelta` 为正数，若为负数则说明是要减少流动性。\r\n\r\n函数声明里定义了两个函数修饰器，`noDelegateCall` 和 `onlyByLocker`。`noDelegateCall` 限制了不能用代理方式调用，`onlyByLocker` 限制了调用前需要先成为 `locker`。\r\n\r\n返回值 `delta` 记录两个代币的变动值。另外，前面我们已经了解到，`BalanceDelta` 其实是 `amount0` 和 `amount1` 两个数组合到一起的数值。因此，`delta` 其实记录的也是两个代币的净余额。\r\n\r\n接下来，就开始梳理函数体的实现了。先看前面的部分代码如下：\r\n\r\n```C++\r\n// 将池子的key转为id\r\nPoolId id = key.toId();\r\n// 检查池子是否已初始化\r\n_checkPoolInitialized(id);\r\n// 判断是否需要调用hooks合约的beforeModifyPosition钩子函数\r\nif (key.hooks.shouldCallBeforeModifyPosition()) {\r\n    bytes4 selector = key.hooks.beforeModifyPosition(msg.sender, key, params, hookData);\r\n    // Sentinel return value used to signify that a NoOp occurred.\r\n    if (key.hooks.isValidNoOpCall(selector)) return BalanceDeltaLibrary.MAXIMUM_DELTA;\r\n    else if (selector != IHooks.beforeModifyPosition.selector) revert Hooks.InvalidHookResponse();\r\n}\r\n```\r\n\r\n第一行代码，先把 key 转为了 id。然后，根据 id 检查该池子是否已经初始化了，还没初始化的池子自然就不能允许执行添加和移除流动性的操作了。之后，就会判断是否需要调 hooks 合约的 `beforeModifyPosition` 钩子函数。\r\n\r\n接下来的代码就是调用库合约函数执行修改头寸的内部逻辑，如下所示：\r\n\r\n```C++\r\nPool.FeeAmounts memory feeAmounts;\r\n(delta, feeAmounts) = pools[id].modifyPosition(\r\n    Pool.ModifyPositionParams({\r\n        owner: msg.sender,\r\n        tickLower: params.tickLower,\r\n        tickUpper: params.tickUpper,\r\n        liquidityDelta: params.liquidityDelta.toInt128(),\r\n        tickSpacing: key.tickSpacing\r\n    })\r\n);\r\n```\r\n\r\n需要注意，调用库合约的 `modifyPosition` 内部函数时，传入的 `owner` 参数为 `msg.sender`，即是说，对于 **PoolManager** 来说，所有的头寸的 `owner` 都是当前合约的调用者，即调用当前函数的合约。因此，在调用者合约里，还需要对用户级别的头寸进行管理的，即类似 UniswapV3 的 **NonfungiblePositionManager** 合约还是需要的。\r\n\r\n修改头寸的内部函数实现代码还是比较长的，限于篇幅，我们就不贴代码了，就简单介绍下其实现逻辑，主要包括以下几点：\r\n\r\n1. 更新 tick 的下限和上限的元数据\r\n2. 如果 tick 的流动性从 0 增长为非 0 状态，或从非 0 状态减少成了为 0 的状态，则在 tick 位图中执行翻转操作\r\n3. 如果是减少流动性且需要执行翻转，清除 tick 元数据\r\n4. 计算和更新费用增长数据\r\n5. 更新用户头寸数据\r\n6. 当前 tick 处于区间内时，更新当前激活的流动性\r\n7. 计算出两个代币的变动值，即 delta\r\n\r\n该内部函数返回了两个值 `delta` 和 `feeAmounts`。`feeAmounts` 记录了两个代币的协议费和 hook 费用。`delta` 则记录了两个代币的值。关于 `delta` 的具体值，有必要展开说明一下。我们分不同场景进行说明。\r\n\r\n添加流动性的时候，delta 里的两个数值为非负数。如果添加的流动性是单边的，即价格区间超出了当前价格的话，那 delta 里有一个值是零值。比如，当前价格为 2000，但添加流动性的价格区间是 [3000, 4000]，就是添加了单边流动性，则 delta 里的两个代币的数组有一个为正数，有一个为零。\r\n\r\n减少流动性的时候，则 delta 里的两个数值为负数。\r\n\r\n执行完调整头寸的内部函数之后，接下来的一行代码，会实现将变动的余额累加到状态变量中进行存储：\r\n\r\n```C++\r\n_accountPoolBalanceDelta(key, delta);\r\n```\r\n\r\n该函数的实现其实就是分别将两个代币进行累加存储，如下所示：\r\n\r\n```C++\r\nfunction _accountPoolBalanceDelta(PoolKey memory key, BalanceDelta delta) internal {\r\n    //处理代币0\r\n  \t_accountDelta(key.currency0, delta.amount0());\r\n  \t//处理代币1\r\n    _accountDelta(key.currency1, delta.amount1());\r\n}\r\n\r\nfunction _accountDelta(Currency currency, int128 delta) internal {\r\n    if (delta == 0) return;\r\n\t\t//读出当前的locker\r\n    address locker = Lockers.getCurrentLocker();\r\n  \t//读出locker当前的余额变动\r\n    int256 current = currencyDelta[locker][currency];\r\n  \t//累加上最新变动额，成为下一个变动额\r\n    int256 next = current + delta;\r\n\r\n    unchecked {\r\n        if (next == 0) {\r\n          \t//变动账户数量减1\r\n            Lockers.decrementNonzeroDeltaCount();\r\n        } else if (current == 0) {\r\n          \t//变动账户数量加1\r\n            Lockers.incrementNonzeroDeltaCount();\r\n        }\r\n    }\r\n\t\t//更新存储\r\n    currencyDelta[locker][currency] = next;\r\n}\r\n```\r\n\r\n这里面的逻辑主要有两块。第一是更新 `currencyDelta`，这是一个嵌套映射类型的状态变量，用来记录每个 locker 的每个代币的余额变动值，其定义如下：\r\n\r\n```C++\r\nmapping(address locker => mapping(Currency currency => int256 currencyDelta)) public currencyDelta;\r\n```\r\n\r\n当值为正的时候，表示池子欠 locker 的金额。当值为负的时候，则表示 locker 欠池子的金额。\r\n\r\n第二块是更新存在余额变动的 locker 的计数器。当 current 为 0 的时候，则表示新增了一个有余额变动的 locker，此时需要计数器加一。而 next 变成 0 的时候，则表示有一个 locker 已经完成了余额变动的流程了，从计数器中减一。而实现计数器的加减，本质上其实是使用了瞬态存储操作码 `tstore` 和 `tload` 来完成的，以 `incrementNonzeroDeltaCount()` 函数实现为例，如下所示：\r\n\r\n```C++\r\nfunction incrementNonzeroDeltaCount() internal {\r\n  //瞬态存储的位置  \r\n  uint256 slot = NONZERO_DELTA_COUNT;\r\n    assembly {\r\n      \t//读取出当前的计数\r\n        let count := tload(slot)\r\n        //计数加1\r\n        count := add(count, 1)\r\n        //存储新的计数\r\n        tstore(slot, count)\r\n    }\r\n}\r\n```\r\n\r\n`_accountPoolBalanceDelta()` 函数其实就是对用户做一个记账。记下欠用户多少资产，或用户欠池子多少资产。后面需要调用者完成其他操作来抹平这个账本的。\r\n\r\n回到 `modifyPosition()` 函数本身，执行完余额变动之后，接下来是对一些费用累加到对应的状态变量中，如下所示：\r\n\r\n```C++\r\nunchecked {\r\n    if (feeAmounts.feeForProtocol0 > 0) {\r\n        protocolFeesAccrued[key.currency0] += feeAmounts.feeForProtocol0;\r\n    }\r\n    if (feeAmounts.feeForProtocol1 > 0) {\r\n        protocolFeesAccrued[key.currency1] += feeAmounts.feeForProtocol1;\r\n    }\r\n    if (feeAmounts.feeForHook0 > 0) {\r\n        hookFeesAccrued[address(key.hooks)][key.currency0] += feeAmounts.feeForHook0;\r\n    }\r\n    if (feeAmounts.feeForHook1 > 0) {\r\n        hookFeesAccrued[address(key.hooks)][key.currency1] += feeAmounts.feeForHook1;\r\n    }\r\n}\r\n```\r\n\r\n最后的一段代码则如下：\r\n\r\n```C++\r\n//是否需要调起hooks合约的afterModifyPosition钩子函数\r\nif (key.hooks.shouldCallAfterModifyPosition()) {\r\n    if (\r\n        key.hooks.afterModifyPosition(msg.sender, key, params, delta, hookData)\r\n            != IHooks.afterModifyPosition.selector\r\n    ) {\r\n        revert Hooks.InvalidHookResponse();\r\n    }\r\n}\r\n//发送事件\r\nemit ModifyPosition(id, msg.sender, params.tickLower, params.tickUpper, params.liquidityDelta);\r\n```\r\n\r\n至此， `modifyPosition()` 函数就结束了。\r\n\r\n但是，我们可以发现，整个函数处理完了之后，并没有涉及到代币转账的逻辑。这里我们需要分开场景说明了。\r\n\r\n添加流动性的时候，调用者需要将代币支付给到池子合约，而这个支付操作，其实是需要在调用者合约里实现的 `lockAcquired()` 回调函数里完成的。具体来说，是需要在调用 `modifyPosition()` 函数后完成支付，伪代码类似如下：\r\n\r\n```C++\r\nfunction lockAcquired(bytes calldata data) external returns (bytes memory) {\r\n  ...\r\n  BalanceDelta delta = poolManager.modifyPosition(key, params, hookData);\r\n  if (delta.amount0 > 0) key.currency0.transfer(poolManager, delta.amount0());\r\n  if (delta.amount1 > 0) key.currency1.transfer(poolManager, delta.amount1());\r\n  ...\r\n}\r\n```\r\n\r\n完成支付之后，下一步还需要通知到 **PoolManager** 合约，把欠的款项在记账系统中进行抹平，这是通过调用 `settle()` 函数来实现的。以下是 settle() 函数的代码实现：\r\n\r\n```C++\r\nfunction settle(Currency currency) external payable override noDelegateCall onlyByLocker returns (uint256 paid) {\r\n  \t//读取出之前的代币储备\r\n    uint256 reservesBefore = reservesOf[currency];\r\n  \t//代币储备更新为最新余额\r\n    reservesOf[currency] = currency.balanceOfSelf();\r\n  \t//前后两个储备的差额就是已支付的金额\r\n    paid = reservesOf[currency] - reservesBefore;\r\n    //从记账系统中减去以支付的金额\r\n    _accountDelta(currency, -(paid.toInt128()));\r\n}\r\n```\r\n\r\n`reservesOf[currency]` 存储的是转账之前的代币余额，而通过 `currency.balanceOfSelf()` 则可读取出最新的代币余额，这两个余额的差值就是已支付的金额了，最后再从记账系统中减去这部分已支付的金额即可。\r\n\r\n前面我们知道，执行完 `modifyPosition()` 函数之后，记账系统中其实会记了用户欠池子的两个代币数额。完成支付之后，再通过 `settle()` 函数，最后一行执行 `_accountDelta()` 就会把这个账本平衡了。\r\n\r\n因为 `settle()` 只处理一个代币，所以需要支付两个代币的时候，就需要调用两次 `settle()` 函数。\r\n\r\n我们把对 `settle()` 函数的调用也加到前面 `lockAcquired()` 函数里则大致如下：\r\n\r\n```C++\r\nfunction lockAcquired(bytes calldata data) external returns (bytes memory) {\r\n  ...\r\n  BalanceDelta delta = poolManager.modifyPosition(key, params, hookData);\r\n  if (delta.amount0 > 0) {\r\n    key.currency0.transfer(poolManager, delta.amount0());\r\n    poolManager.settle(key.currency0);\r\n  }\r\n  if (delta.amount1 > 0) {\r\n    key.currency1.transfer(poolManager, delta.amount1());\r\n    poolManager.settle(key.currency1);\r\n  }\r\n  ...\r\n}\r\n```\r\n\r\n那如果是减少流动性的话，这时候记账系统里记录的是池子欠用户的两个代币。那么，这时候需要调用的则是 `take()` 函数了。以下是 `take()` 函数的代码实现：\r\n\r\n```C++\r\nfunction take(Currency currency, address to, uint256 amount) external override noDelegateCall onlyByLocker {\r\n    //平衡账本\r\n  \t_accountDelta(currency, amount.toInt128());\r\n  \t//从储备里减掉提取的数量\r\n    reservesOf[currency] -= amount;\r\n  \t//转账给用户\r\n    currency.transfer(to, amount);\r\n}\r\n```\r\n\r\n在 `lockAcquired()` 函数里完成移除流动性的流程，则实现大致如下：\r\n\r\n```C++\r\nfunction lockAcquired(bytes calldata data) external returns (bytes memory) {\r\n  ...\r\n  BalanceDelta delta = poolManager.modifyPosition(key, params, hookData);\r\n  if (delta.amount0 < 0) {\r\n    poolManager.take(key.currency0, to, uint256(-delta.amount0));\r\n  }\r\n  if (delta.amount1 < 0) {\r\n    poolManager.settle(key.currency1, to, uint256(-delta.amount1));\r\n  }\r\n  ...\r\n}\r\n```\r\n\r\n最后，回到 `lock()` 函数里，还有最后一个校验要说明一下，即以下这段代码：\r\n\r\n```C++\r\nif (Lockers.length() == 1) {\r\n    if (Lockers.nonzeroDeltaCount() != 0) revert CurrencyNotSettled();\r\n    Lockers.clear();\r\n} else {\r\n    Lockers.pop();\r\n}\r\n```\r\n\r\n一般情况下，一笔交易里的 `locker` 只有一个，即会进入 `if` 语句。而完成了完整流程之后，`nonzeroDeltaCount()` 是会返回 0 的，如果不为 0，则说明记账系统里该 locker 的账本还没抹平，交易就会失败。\r\n\r\n至此，添加和移除流动性的基本流程就到此结束了。"},"author":{"user":"https://learnblockchain.cn/people/96","address":null},"history":null,"timestamp":1701225583,"version":1}