探索 Aggregator Hook 的诞生:Uniswap V4 的革命性创新,重塑 Defi 生态中流动性管理的未来。
撰文:Attens & Bruce
编辑:Lisa,DODO Research
这(Aggregator Hook)对其他 DEX 是一件很好的事,增加一段代码就可以多接入一个「超级聚合器」,何乐而不为;对 Uniswap 当然也是一件很好的事,它可以丝滑地、无成本地吸纳其他 DEX 的流动性,为己所用。
— Attens, DODO DEV Leader
Uniswap V4 发布后迅速在开发者中掀起了一场风暴:Hook 结构为传统的、一成不变的模板化 pool 合约带来了丰富的可拓展性。Uniswap V4 Hook 的接口几乎涵盖了池子 - 资金交互的全生命周期,为开发者的「头脑风暴」提供了丰饶的土壤。从相对而言比较简单的 TWAMM Hook,引入时间加权预言机的保护;到 LimitOrder Hook 展示 Hook 更强大的潜力,又或是在黑客松里诞生的跨链交易 Hook,各种 Hook 如雨后春笋蓬勃生长。
身为 Defi 从业者,我们自然也不会坐壁上观。在 11 月举行的 ETH Global 中,我们提出了一种新的 Hook 设计,并有幸获得 “Best of Use Hook” 奖项。由于其具有较高的泛用性,可以作为一个代码组件搭载在各种流动性池子合约中,我们将其命名为:Aggregator Hook。
Aggregator Hook 旨在充当 “桥梁”,使得市场上的流动性能直接链接到 Uniswap V4 的池子;同时利用 “及时原则” 动态管理流动性资金,在交易前注入流动性,在交易后撤出流动性,将流动性迁移对原池子的影响最小化。这种集成使 LPs 能夜以前所未有的便捷方式管理资金,利用 Uniswap 的坚固架构,同时利用更广泛的 DEX 生态系统中的流动性。
本文将探讨 Aggregator Hook 的起源、操作机制,以及它为 Uniswap 及更广泛的 DEX 生态系统带来的深远影响。
I. Aggregator Hook 的诞生
最初这个想法来源于我们的一个朴素需求:我们有一个报价效率很高的 DODO V3 池,能不能将这一套系统利用 Hook 迁移到 Uni V4 上?
在调研 LimitOrderHook 时我们得到了启发:Hook 可以托管一部分资金。这是一个不起眼的发现,但它打开了兔子洞的大门。“Hook 是个独立合约” 加上 “Hook 可以托管资金”,是否我们可以推导出:Hook 本身就可以是一个池子?以及,现在我们有一个合约,这个合约可能是其他 DEX 的池子,或者本身有其他逻辑(例如,可能是一个众筹池子,也可能是一个交易托管系统),但同时这个合约满足 Uni V4 对 Hook 合约的要求,且有 Uni V4 Hook 对应的函数,那么它同时也可以是一个 Hook。
于是得到了初始构想:Aggregator Hook 是一个池子本位的 Hook,既是一个 DODO V3 池子,也能作为 Uni V4 的 Hook,我们要把报价系统迁移到 Hook 对应的函数里,利用 JIT 使得用户在 Uni V4 交易得到和 DODO V3 相同的报价。初始的方案非常自然:我们有每个币的价格上限、价格下限、代售数量,将价格上下限转换成 tick,按照代售数量填充流动性,用户交易,交易成功,完美——等等!我们遇到了巨大的价差。
我们尝试了许多种 tickSpacing 和 Fee 的组合,也应用过各种价格上下限对应的 tick 组合,最好的结果也与原生 DODO V3 的报价有 0.2% 的价差,足以影响用户的交易体验。很明显,这是由于 DODO V3 与 Uni V4 的算法不同导致的,为了解决这个价差,面前有两个门:
- 正门:充分考虑 Uni V4 和 DODO V3 的算法差异,找到流动性 remapping 公式
- 后门:利用 fromAmount 从 DODO V3 得到一个价格,利用这个价格直接确定流动性填充参数,以消除价差
我们经过了短暂尝试后,毫不犹豫地选择了走后门!虽然我们相信第一个方案如果真能推出解析解的话应该可以产出一篇优雅的小论文,可惜我们不是数学家。第二个方案的弊端显而易见:一定会引入更多的 gas 消耗,毕竟增加了一次询价;但优势同样也非常明显:利用 fromAmount 得到价格的函数并不是 DODO V3 独有的,所有的 Dex pools 都会有类似的询价函数,getAmountsOut、get_dy、querySellTokens… 不管怎样的函数名,总有方法从合约得到询价结果。则这个 Hook 组件不仅可以在 DODO V3 池子里使用,也可以在任意 Dex 池子里集成使用,甚至于也不一定是一个池子,而是一个 solver 的报价组件。任何一个有流动性、对该流动性有报价的合约,只要增加这段 Hook 代码,就可以在不影响原独立逻辑的情况下同时成为一个 Hook,以及它可以通过 Hook - Pool 同时构成一个合法的 Uni V4 池子。这时,理想情况下,Uni V4 的路由不仅可以路由到传统的 Uni V4 池子,同时也可以路由到我们创造的这种 trick pool。
我们感到兴奋 - 这对其他 Dex 是一件很好的事,增加一段代码就可以多接入一个 ”超级聚合器“,何乐而不为。对 Uniswap 当然也是一件很好的事:它可以丝滑地、无成本地吸纳其他 Dex 的流动性,为己所用。
当然,这种宏伟的愿景取决于 Uni V4 最后会使用怎样的路由算法、又怎样处理这些池子的优先级。这是另一个话题,也许我们可以写另一篇文章讨论 Uni V4 的路由算法。但由于 Hook 的特殊性,可以肯定的是不管怎样的路由算法,必须考虑到 Hook 的影响,因为 Hook 里的操作直接和用户能得到的最终报价有关。
希望大家此时还记得我们宏伟假设的最基本前提:我们得到一个报价,我们找到一个流动性填充方式可以在 Uni V4 中投影这个报价,使得用户通过 Uni V4 交易与通过 Hook-Pool 直接交易拿到的价格相同。
最终,我们做到了。这就是为什么我们正式称它为 “Aggregator Hook”。
II. Aggregator Hook 的操作流程
而当我们真正谈论到 Aggregator Hook 的具体实现,你会发现魔法其实只是表演者袖子里那枚不易被察觉的硬币:
- 用户发起 swap call
- 触发 beforeSwapHook,Hook 中执行三个操作:
- 移除池子中剩余的全部流动性(Hush! It’s the key point.)
- 利用用户的 fromAmount 得到 Hook-Pool 原生的交易价格
- 通过交易价格计算 modifyPosition 的参数,填充流动性
- swap call 结束
- 外层 Router 处理用户 transferIn 及 transferOut
如下图所示:
当用户的交易订单接近时,BeforeSwap 钩子被激活,流动性提供者(LPs)向 Uniswap V4 池注入流动性。增加的流动性适应了即将到来的订单,促使其成功执行。然而,与其在交易结束时撤回这些流动性,不如让它留在池中。这构成了第一笔交易完整生命周期的一部分。
创新之处在于处理后续交易。在下一笔交易启动前,BeforeSwap 钩子再次被触发。它的首要任务是移除上一笔交易遗留的任何多余流动性。
一旦残余流动性被撤回,LPs 重复初始步骤:他们分析新交易的需求,并在两个价格区间内添加精确数量的流动性。这种细致的添加确保了流动性不仅被最优化利用,而且还以最高效率定位,从而减少滑点并改善 Uniswap 生态系统内的整体交易执行。
III. 机制和代码剖析
最精彩的部分应当压轴。
When to remove liquidity
首先我们要解释一下为什么我们需要移除流动性,这基于以下两点考虑:
- 在该 Hook 中,Hook-Pool 才是主体,流动性应当尽可能地集中在 Hook-Pool 中。
- 池子没有多余流动性时,进行价格 remapping 的计算比较简单。
In JIT,通常的做法是:在用户 swap 前充入流动性,在用户 swap 之后提出流动性。我们一开始当然也是这样想的,beforeSwapHook 中添加流动性,afterSwapHook 中移除流动性,逻辑很顺畅——然而 Hook 总是惊喜多多。让我们看一下目前的 Uni V4 最新的 testRouter 结构:
function swap(
PoolKey memory key,
IPoolManager.SwapParams memory params,
TestSettings memory testSettings,
bytes memory hookData
) external payable returns (BalanceDelta delta) {
delta = abi.decode(
manager.lock(address(this), abi.encode(CallbackData(msg.sender, testSettings, key, params, hookData))),
(BalanceDelta)
);
uint256 ethBalance = address(this).balance;
if (ethBalance > 0) CurrencyLibrary.NATIVE.transfer(msg.sender, ethBalance);
}
function lockAcquired(address, bytes calldata rawData) external returns (bytes memory) {
require(msg.sender == address(manager));
CallbackData memory data = abi.decode(rawData, (CallbackData));
(,, uint256 reserveBefore0, int256 deltaBefore0) = _fetchBalances(data.key.currency0, data.sender);
(,, uint256 reserveBefore1, int256 deltaBefore1) = _fetchBalances(data.key.currency1, data.sender);
assertEq(deltaBefore0, 0);
assertEq(deltaBefore1, 0);
BalanceDelta delta = manager.swap(data.key, data.params, data.hookData);
// ···
// omit some judges
if (deltaAfter0 > 0) {
if (data.testSettings.currencyAlreadySent) {
manager.settle(data.key.currency0);
} else {
_settle(data.key.currency0, data.sender, int128(deltaAfter0), data.testSettings.settleUsingTransfer);
}
}
if (deltaAfter1 > 0) {
if (data.testSettings.currencyAlreadySent) {
manager.settle(data.key.currency1);
} else {
_settle(data.key.currency1, data.sender, int128(deltaAfter1), data.testSettings.settleUsingTransfer);
}
}
if (deltaAfter0 < 0) {
_take(data.key.currency0, data.sender, int128(deltaAfter0), data.testSettings.withdrawTokens);
}
if (deltaAfter1 < 0) {
_take(data.key.currency1, data.sender, int128(deltaAfter1), data.testSettings.withdrawTokens);
}
return abi.encode(delta);
}
}
See, 阻碍我们的伟大计划的核心就是:用户在 manager.swap 完成之后才会和 poolManager 进行真正的代币交换。这意味着:afterSwapHook 并不能真正在 'after swap' 后发生,我们就无法在 afterSwapHook 里处理这笔流动性移除。removeLiquidity 函数失去了它的最佳站位,我们因此手忙脚乱,任由这位被放弃的演员只能干巴巴地在测试里站在 swap 函数调用后面,单独一行,突兀地像个小丑。走投无路的我们正打算利用 swap 函数里的 hookData 做文章,思考改造 testRouter 的可能性;这时,金苹果制造者之一,Uni 的 @ken 和他们的 Dev @saucepoint 给了我们至关重要的建议:可以在下一笔交易的开始移除流动性。
这个方案对于我们一定是最佳的。首先,它对代码的改动极小,当时我们离黑客松 presentation 只剩 7 小时了;其次,它不再依赖阴晴不定的 testRouter 完成自动化的流动性移除,你只需要忍耐一小会,等到下一个用户进来,上一笔的应收款就会原样退还。
当然 Hook-Pool 的 owner 可以在任意时刻提出 Uni V4 中的流动性,并不一定要等到下一个交易用户,以下 remove 函数是 public 的:
function removeRemainingLiquidity(PoolKey calldata key) public returns(bool){
PoolId poolId = key.toId();
uint128 liquidity = poolManager.getLiquidity(poolId);
if(liquidity == 0) return true;
_modifyPosition(
key,
IPoolManager.ModifyPositionParams({
tickLower: tickLower,
tickUpper: tickUpper,
liquidityDelta: -int128(liquidity)
})
);
liquidity = poolManager.getLiquidity(poolId);
if(liquidity != 0) return false;
return true;
}
同时,为了保护资金,我们使用了 beforeModifyPosition Hook 验证修改流动性的 msg.sender
// prevent user fill liquidity
function beforeModifyPosition(
address sender,
PoolKey calldata,
IPoolManager.ModifyPositionParams calldata,
bytes calldata
) external view override returns (bytes4) {
if (sender != address(this)) revert SenderMustBeHook();
return AggregatorHook.beforeModifyPosition.selector;
}
How to fill liquidity
Uni V4 和 Uni V3 计算公式是一致的,尽管 Uni V4 的 pool 变成了一个 lib。我们仅考虑在一个极小的 tick 间隔内填充单边流动性,例如 tick -46874 与 tick -46873 之间填充流动性,以尽量减少跨 tick 消耗。阅读代码和相关的解析文章可以利用 Uni 的算法得到以下公式。
对应代码:
function _calJITLiquidity(int24 curTick, uint256 fromAmount, uint256 toAmount, bool zeroForOne) internal view returns(uint128 liquidity) {
uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick(curTick);
if(zeroForOne) {
// prioritize division to avoid overflow
uint256 tmp1 = fromAmount * uint256(sqrtPriceX96) / Q96 *uint256(sqrtPriceX96) / Q96- toAmount;
uint256 tmp2 = fromAmount * uint256(sqrtPriceX96) * toAmount / Q96;
liquidity = uint128(tmp2 / tmp1);
} else {
// prioritize division to avoid overflow
uint256 tmp1 = fromAmount - toAmount * uint256(sqrtPriceX96) / Q96 * uint256(sqrtPriceX96) / Q96;
uint256 tmp2 = fromAmount * uint256(sqrtPriceX96) * toAmount / Q96;
liquidity = uint128(tmp2 / tmp1);
}
}
但这不是结束。观察 Uni V3/V4 的流动性分布我们可以看到:
quote from:https://uniswapv3book.com/docs/milestone_1/calculating-liquidity/
填充流动性之后,该段价格实际只有 50% 的价格区间能满足我们的单边交易需求。如果我们 sell token0 to token1,选定的填充 tick 区间为 [tickLower, tickUpper],对应的价格区间为 [priceLower, priceUpper]。在交易进行中,预期的价格行为是从 price 降低,降低到目标价格,但该目标价格不能低于 (priceLower + priceUpper) / 2。如果低于 (priceLower + priceUpper) / 2,会表现为 price_next 超过价格下限,需要对 tick 下限做校正。对于另一端的交易也需要校正。举个例子:
用户需要 sell token1 to token0, 对应 tick 增加
用户 fromAmount = 1e18
我们得到一个目标价格,targetPrice = 0.009214376791555881
此时计算得 toAmount = 9214376791555881
根据目标价格计算最近的两个 tick,得到对应的 tick
tickLower = -46873, 对应的价格为:0.009213682692310668, sqrtpLower = 7604947311928302784860913664
tickUpper = -46872, 对应的价格为:0.009214604060579898, sqrtpUpper = 7605327559449958075588319979
此时中间价格 mid_price = 0.009214143376445282
可以观察到 target_price < mid_price, 此时预计该价格 tickUpper 无法 work
验证:
将 tickLower,fromAmount,toAmount 代入公式 (2)
计算得 L = 1274268715777041035648
对应的 sqrtpNext = 7605520219457829539575984515 > sqrtpUpper
不符合边界条件
此时需要将填充参数的 tickUpper 更新为 -46871, L 不变。
该校正对应代码:
// tick correct
if(zeroForOne == false) {
int24 limitTick = tickUpper;
uint256 priceNext = fromAmount * Q96 / liquidity + TickMath.getSqrtRatioAtTick(calTick);
uint256 priceLimit = uint256(TickMath.getSqrtRatioAtTick(limitTick));
if(priceNext > priceLimit) {
tickUpper = tickLower + 2;
}
} else {
int24 limitTick = tickLower ;
uint256 sqrtPCal = uint256(TickMath.getSqrtRatioAtTick(calTick));
uint256 priceNext = (liquidity * sqrtPCal) / (liquidity + sqrtPCal / Q96 * fromAmount);
uint256 priceLimit = uint256(TickMath.getSqrtRatioAtTick(limitTick));
if(priceNext > priceLimit) {
tickLower --;
}
}
Price Balance
到目前为止,我们吹过的牛基本都圆回来了。Aggregator Hook is good, but not flawless. 最核心的限制就是交易方向。这个限制描述为以下情况:
- 当前 V4 池子价格在 tick 0 对应的价格上,记为 currentPrice
- 当 targetPrice > currentPrice 时,V4 池仅能执行 swap 1 to 0
- 当 targetPrice < currentPrice 时,V4 池仅能执行 swap 0 to 1
这个限制其实也有非常符合直觉的理解方法。我们可以将 targetPrice 看成市场价格,currentPrice 是老 Dex 池通过交易产生的价格。当 Dex pool 价格比市场价格低时,池子要求用户只能买入 token0 以提高价格,达到价格平衡;而 Dex pool 价格比市场价格高时,池子要求用户只能买入 token1 以降低价格,达到价格平衡。
该限制条件代码表示为:
(, int24 tick0,,) = poolManager.getSlot0(poolId);
if(!zeroForOne && tickLower <= tick0) revert TradeDirectionError();
if(zeroForOne && tickUpper >= tick0) revert TradeDirectionError();
IV. 深入讨论与展望
我们认为 Aggregator Hook 最大的意义在于更新了设计 Hook 的角度。
之前我们考虑 Hook 设计时一直沿用 Uni V4 Pool 本位的角度,思考使用 Hook 能给 “交易” 带来什么新的功能;Aggregator Hook 给出了一种全新的角度。由于 Hook 的独立性,这个合约并不一定 “All in Hook”,Hook 可能只是它的一部分功能——制造与 Uni V4 池子的桥梁,但它本身可以是执行任何操作的合约,“交易” 与 Uni V4 是这个合约功能的一部分。
举一个很简单的例子,一些小项目方会声称自己众筹代币后会将一定比例的代币与资金添加到 UniSwap 中。在 Uni V3 的时代,众筹合约和 Uni V3 合约的操作是异步的,用户们只能依赖项目方进行资金转移,但有了 Uni V4 Hook 之后:我们为什么不利用 Hook 的独立性把添加流动性的操作直接集成在同一个合约里?甚至在用户充值时就直接执行流动性添加操作(当然这会带来很高的 gas 消耗,但如果有用户需要这种透明性的话)。这很疯狂,但它预示了 Hook 的无限可能。
Aggregator Hook 最大的限制其实是其交易的 gas 消耗。正常 Uni V3 的 gas 消耗大约在 12w 左右,而由于我们在其中进行了各种流动性增减操作,单次的交易 gas 用 forge snapshot 计算在 42~55w 左右,远高于正常的 swap 行为,注定了这种集成池子的办法只能在 storage gas 不敏感的 L2 上使用,或者在 gas price 很低的链上使用。Trick 付出 trick 的代价,这很合理。
它当然还有一些可以提升的地方,集中在流动性充提的部分。有两个可能的方向,一个简单一点,一个复杂一点。先来点小菜:
- 在 Price Balance 小节中我们讨论了交易方向问题,如果我们保持流动性移除的方案,targetPrice 和 currentPrice 的规则无法更改,但是 tick0 和 limitTick 之间却并不一定不能相等。只是当 limitTick = tick0 时,充入流动性时会变成双边充值,计算充入流动性数额的公式需要更改。这个更改可以将交易方向的要求稍微放宽一点。放宽一个 tick。
更复杂的:
- 既然我们已经考虑到双边充值的流动性了,为什么不贯彻到底,不再强制移除流动性?而仅在 Hook-Pool 管理者认为需要的时候 removeLiquidity。进一步减少 user swap 时 gas 消耗,使 Aggregator Hook 更 ”可用“。当然这很有可能会引入比上小菜方案更复杂的流动性充值算法。
当前 Aggregator Hook 池子的源码如下:
https://github.com/Attens1423/Aggregator-Hook
为了直观看到交易结果我们增加了 afterSwap Hook 的调用打印结果,但这不是必须的。内部有许多中间值打印,可以使用 forge test 观看 Aggregator Hook 的表演。
DODO V3 也许会成为第一个吃螃蟹的池子。我们下一步的计划是打算将 Aggregator Hook 集成到 DODO V3 池子中,这里应该还有一些必要的 pool reserve 和 pool state 的更新工作,在完成了所有 test 之后我们会将它开源,实现最初的 Hook-Pool 设想。
参考文献
https://github.com/Uniswap/v4-core/blob/main/docs/whitepaper-v4.pdf
https://uniswapv3book.com/
编辑:Lisa,DODO Research