探索 Aggregator Hook 的誕生:Uniswap V4 的革命性創新,重塑 Defi 生態中流動性管理的未來。

作者:Attens & Bruce

編輯:Lisa

出品:DODO Research

封面:Photo by willow xk on Unsplash

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 的具體實現,你會發現魔法其實只是表演者袖子里那枚不易被察覺的硬幣:

  1. 用戶發起 swap call
  2. 觸發 beforeSwapHook,Hook 中執行三個操作:
    1. 拿掉池子中剩餘的全部流動性(Hush! It’s the key point.)
    2. 利用使用者的 fromAmount 得到 Hook-Pool 原生的交易價格
    3. 通過交易價格計算 modifyPosition 的參數,填充流動性
  3. swap call 結束
  4. 外層 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 的演算法得到以下公式。

Suppose fromAmount = A, toAmount = B

For 0 → 1

get next price

make delta y = B

solve these function and get anwser

  • For 1 → 0: 重複以上過程,或者直接利用 Uni 中 x 和 y 的對稱性得到:(注意,此處 fromAmount = A = token 1 的 amount,toAmount = B = token 0 的 amount)

對應代碼:

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,得到对应的 ticktickLower = -46873, 对应的价格为:0.009213682692310668, sqrtpLower = 7604947311928302784860913664tickUpper = -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. 最核心的限制就是交易方向。 這個限制描述為以下情況:

  1. 當前 V4 池子價格在 tick 0 對應的價格上,記為 currentPrice
  2. 當 targetPrice > currentPrice 時,V4 池僅能執行 swap 1 to 0
  3. 當 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/

免責聲明

本研究報告內的資訊均來自公開披露資料,且本文中的觀點僅作為研究目的,並不代表任何投資意見。 報告中出具的觀點和預測僅為出具日的分析和判斷,不具備永久有效性。

免責聲明:作為區塊鏈資訊平臺,本站所發佈文章僅代表作者及嘉賓個人觀點,與 Web3Caff 立場無關。 文章內的資訊僅供參考,均不構成任何投資建議及要約,並請您遵守所在國家或地區的相關法律法規。