前一篇文章我们已经知道了创建新池子的流程,那接下来就要添加流动性了。而其实,在 PoolManager 合约里,添加和移除流动性都是在同一个函数里统一处理的。当然,要完成添加或移除流动性的全流程,会涉及到多个函数。接下来我们展开一一细说。
当我们想要往一个池子里添加或移除流动性的时候,和创建池子时一样,需要先通过实现了 ILockCallback 接口的合约调用 lock()
函数,激活成为 locker
。然后在回调函数 lockAcquired()
里调起 PoolManager 合约的 modifyPosition()
函数。我们先来看其函数声明:
function modifyPosition(
PoolKey memory key,
IPoolManager.ModifyPositionParams memory params,
bytes calldata hookData
) external override noDelegateCall onlyByLocker returns (BalanceDelta delta) {...}
先看参数列表,key
指定了要操作流动性的池子,params
指定头寸参数,hookData
是需要传给 Hooks 合约的数据。
params
具体包含了三个字段:
struct ModifyPositionParams {
// the lower and upper tick of the position
int24 tickLower;
int24 tickUpper;
// how to modify the liquidity
int256 liquidityDelta;
}
即要操作的头寸的 tick 下限和上限,以及要增加或减少的流动性数量 liquidityDelta
。如果是要增加流动性,liquidityDelta
为正数,若为负数则说明是要减少流动性。
函数声明里定义了两个函数修饰器,noDelegateCall
和 onlyByLocker
。noDelegateCall
限制了不能用代理方式调用,onlyByLocker
限制了调用前需要先成为 locker
。
返回值 delta
记录两个代币的变动值。另外,前面我们已经了解到,BalanceDelta
其实是 amount0
和 amount1
两个数组合到一起的数值。因此,delta
其实记录的也是两个代币的净余额。
接下来,就开始梳理函数体的实现了。先看前面的部分代码如下:
// 将池子的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
钩子函数。
接下来的代码就是调用库合约函数执行修改头寸的内部逻辑,如下所示:
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 合约还是需要的。
修改头寸的内部函数实现代码还是比较长的,限于篇幅,我们就不贴代码了,就简单介绍下其实现逻辑,主要包括以下几点:
该内部函数返回了两个值 delta
和 feeAmounts
。feeAmounts
记录了两个代币的协议费和 hook 费用。delta
则记录了两个代币的值。关于 delta
的具体值,有必要展开说明一下。我们分不同场景进行说明。
添加流动性的时候,delta 里的两个数值为非负数。如果添加的流动性是单边的,即价格区间超出了当前价格的话,那 delta 里有一个值是零值。比如,当前价格为 2000,但添加流动性的价格区间是 [3000, 4000],就是添加了单边流动性,则 delta 里的两个代币的数组有一个为正数,有一个为零。
减少流动性的时候,则 delta 里的两个数值为负数。
执行完调整头寸的内部函数之后,接下来的一行代码,会实现将变动的余额累加到状态变量中进行存储:
_accountPoolBalanceDelta(key, delta);
该函数的实现其实就是分别将两个代币进行累加存储,如下所示:
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 的每个代币的余额变动值,其定义如下:
mapping(address locker => mapping(Currency currency => int256 currencyDelta)) public currencyDelta;
当值为正的时候,表示池子欠 locker 的金额。当值为负的时候,则表示 locker 欠池子的金额。
第二块是更新存在余额变动的 locker 的计数器。当 current 为 0 的时候,则表示新增了一个有余额变动的 locker,此时需要计数器加一。而 next 变成 0 的时候,则表示有一个 locker 已经完成了余额变动的流程了,从计数器中减一。而实现计数器的加减,本质上其实是使用了瞬态存储操作码 tstore
和 tload
来完成的,以 incrementNonzeroDeltaCount()
函数实现为例,如下所示:
function incrementNonzeroDeltaCount() internal {
//瞬态存储的位置
uint256 slot = NONZERO_DELTA_COUNT;
assembly {
//读取出当前的计数
let count := tload(slot)
//计数加1
count := add(count, 1)
//存储新的计数
tstore(slot, count)
}
}
_accountPoolBalanceDelta()
函数其实就是对用户做一个记账。记下欠用户多少资产,或用户欠池子多少资产。后面需要调用者完成其他操作来抹平这个账本的。
回到 modifyPosition()
函数本身,执行完余额变动之后,接下来是对一些费用累加到对应的状态变量中,如下所示:
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;
}
}
最后的一段代码则如下:
//是否需要调起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()
函数后完成支付,伪代码类似如下:
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() 函数的代码实现:
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()
函数里则大致如下:
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()
函数的代码实现:
function take(Currency currency, address to, uint256 amount) external override noDelegateCall onlyByLocker {
//平衡账本
_accountDelta(currency, amount.toInt128());
//从储备里减掉提取的数量
reservesOf[currency] -= amount;
//转账给用户
currency.transfer(to, amount);
}
在 lockAcquired()
函数里完成移除流动性的流程,则实现大致如下:
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()
函数里,还有最后一个校验要说明一下,即以下这段代码:
if (Lockers.length() == 1) {
if (Lockers.nonzeroDeltaCount() != 0) revert CurrencyNotSettled();
Lockers.clear();
} else {
Lockers.pop();
}
一般情况下,一笔交易里的 locker
只有一个,即会进入 if
语句。而完成了完整流程之后,nonzeroDeltaCount()
是会返回 0 的,如果不为 0,则说明记账系统里该 locker 的账本还没抹平,交易就会失败。
至此,添加和移除流动性的基本流程就到此结束了。