Dr. DODO is Researching

發布於 2023-12-19到 Mirror 閱讀

Dev’s Note: The Discovery of Aggregator Hook

"This (Aggregator Hook) is great for other DEXs - just adding a piece of code allows access to a 'super aggregator', why not embrace it? And for Uniswap, it's equally beneficial as it can smoothly and costlessly absorb liquidity from other DEXs for its own use."

— Attens, DODO DEV Leader

After the release of Uniswap V4, it swiftly stirred up a storm among developers: the Hook structure brought rich expansibility to traditional, unchanging templated pool contracts. The Uniswap V4 Hook interface covers nearly the entire lifecycle of pool-fund interactions, providing fertile ground for developers' brainstorming. From the relatively simple TWAMM Hook, which introduced protection via time-weighted oracles, to the more complex LimitOrder Hook showcasing the greater potential of Hooks, or the cross-chain transaction Hooks born at hackathons, various Hooks are flourishing rapidly.

As practitioners in the DeFi sector, we're not just onlookers. At ETH Global in November, we introduced a new Hook design, which was honored with the “Best of Use Hook” award. Owing to its high versatility, it can be integrated as a code component in various liquidity pool contracts, and we've named it the Aggregator Hook.

The Aggregator Hook aims to act as a 'bridge', linking market liquidity directly to Uniswap V4 pools; it dynamically manages liquidity funds using the 'just-in-time principle', injecting liquidity before transactions and withdrawing it afterwards, minimizing the impact of liquidity migration on the original pool. This integration allows LPs to manage funds in an unprecedentedly convenient way, leveraging Uniswap's robust architecture while utilizing liquidity from a broader DEX ecosystem.

This article will explore the origin, operational mechanisms of the Aggregator Hook, and its profound impact on Uniswap and the broader DEX ecosystem.

I. The Birth of Aggregator Hook

Our idea began with a simple need: to migrate our efficient DODO V3 pool system to Uni V4 using a Hook.

Researching LimitOrderHook, we discovered Hooks could manage funds, a seemingly small but pivotal insight. It led us to an innovative concept: a Hook, as an independent contract holding funds, could itself act as a pool. This means a contract could potentially serve as a pool for other DEXs or have different functions (like a crowdfunding pool or trade custodianship) and still fulfill the criteria of a Uni V4 Hook, thus functioning as both.

Building on this understanding, we developed our initial concept, the Aggregator Hook: a pool-based Hook that functions both as a DODO V3 pool and as a Uni V4 Hook. Our goal was to integrate our quoting system into the Hook's functions, using Just-In-Time (JIT) techniques to ensure users get the same quotes on Uni V4 as on DODO V3.

Our first approach seemed straightforward: set upper and lower price limits for each currency, convert these into ticks, and fill liquidity based on the pre-sale quantity. Users would trade, and ideally, the transaction would be successful. However, we encountered significant price discrepancies.

We experimented with various tickSpacing and Fee combinations, and tried different tick arrangements based on price limits, but the best outcome still had a 0.2% price difference from the native DODO V3 quotes, enough to affect user experience. This discrepancy was obviously due to the different algorithms used by DODO V3 and Uni V4. To address this, we considered two approaches:

  1. The Main Approach: Fully account for the algorithmic differences between Uni V4 and DODO V3 to find a liquidity remapping formula.

  2. The Alternative Approach: Use a price obtained from DODO V3 for a given fromAmount and directly determine liquidity filling parameters using this price to eliminate the discrepancy.

After a brief trial, we decisively chose to take the alternative route! While we believe the first approach could potentially yield an elegant paper if a solution were found, it's a pity that we aren't mathematicians.

The downside of the second approach was apparent: it would inevitably increase gas consumption due to an additional pricing query. However, its advantages were equally clear: the function to obtain prices using fromAmount isn't exclusive to DODO V3; all Dex pools have similar pricing functions, such as getAmountsOut, get_dy, querySellTokens, etc. Regardless of the function name, there's always a way to get pricing results from a contract.

This means our Hook component isn't limited to use in DODO V3 pools but can integrate into any Dex pool, or even function as a solver in a quoting component. Any contract with liquidity and pricing for that liquidity can, by adding this Hook code, function as a Hook without affecting its original logic, transforming it into a valid Uni V4 pool via the Hook-Pool mechanism. Ideally, Uni V4's routing could not only navigate to traditional Uni V4 pools but also to these trick pools we've created.

I find this exciting. It's beneficial for other DEXes, offering an easy integration into a "super aggregator" with just a bit of added code. It's also advantageous for Uniswap: it can effortlessly and cost-effectively absorb the liquidity of other DEXes for its use.

Of course, this grand vision depends on the final routing algorithms Uni V4 employs and how it prioritizes these pools. That's a topic for another discussion, possibly another article focusing on Uni V4's routing algorithms. However, given the unique nature of Hooks, any routing algorithm must account for their impact, as the operations within Hooks directly influence the final quotes available to users.

Hopefully, everyone remembers our grand assumption's basic premise: we obtain a quote, find a way to fill liquidity in Uni V4 that mirrors this quote, ensuring users get the same prices through Uni V4 trading as they would through direct trading via the Hook-Pool.

Luckily, we finally made it. That's why we can formally call it the 'Aggregator Hook'.

II. Operational Process of Aggregator Hook

When discussing the specific implementation of the Aggregator Hook, the magic can be likened to a subtle coin hidden in the performer's sleeve:

  1. The user initiates a swap call.

  2. This triggers the beforeSwapHook, within which three operations occur:

    1. Remove all remaining liquidity from the pool (Hush! It’s the key point.)

    2. Use the user's fromAmount to determine the native trading price of the Hook-Pool.

    3. Calculate the parameters for modifyPosition based on the trading price, and refill the liquidity.

  3. The swap call concludes.

  4. The external Router handles the user's transferIn and transferOut processes.

The process is illustrated in the following diagram:

When a user's trade order approaches, the BeforeSwap hook is activated, prompting liquidity providers (LPs) to inject liquidity into the Uniswap V4 pool. This added liquidity accommodates the incoming order, facilitating its successful execution. However, instead of withdrawing this liquidity at the end of the transaction, it remains in the pool. This constitutes the first transaction's complete lifecycle.

The innovation lies in the handling of subsequent transactions. Before the next trade is initiated, the BeforeSwap hook is once again triggered. Its first order of business is to remove any excess liquidity that was left over from the previous transaction.

Once the residual liquidity is withdrawn, the LPs repeat the initial steps: they analyze the new trade's demands and add the precise amount of liquidity needed between the two price ticks. This meticulous addition ensures that the liquidity is not only optimally utilized but is also positioned with maximum efficiency, thus reducing slippage and improving overall trade execution within the Uniswap ecosystem.

III. Mechanism Dissect

The most intriguing part comes last.

When to Remove Liquidity

Firstly, we need to explain why it's necessary to remove liquidity, based on two key considerations:

  • In this Hook setup, the Hook-Pool is the focal point, and liquidity should be concentrated as much as possible within it.

  • Calculating price remapping is simpler when the pool is devoid of surplus liquidity.

In Just-In-Time (JIT) operations, the usual approach is: inject liquidity before the user's swap and withdraw it after the swap. Initially, we also thought along these lines - adding liquidity in the beforeSwapHook and removing it in the afterSwapHook, a smooth and logical process. However, working with Hooks often brings surprises. Let's take a look at the current structure of Uni V4's latest 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);
    }
}

The central challenge obstructing our grand plan was that the real token exchange between the user and the poolManager only happens after the manager.swap is completed. This meant that the afterSwapHook couldn't actually occur 'after the swap', preventing us from handling the liquidity removal there. As a result, the removeLiquidity function lost its optimal timing, and in our confusion, it was left awkwardly standing alone in the test after the swap function call, out of place like a clown. Facing a dead end, we were considering using the hookData in the swap function and thinking about modifying the testRouter. At this critical juncture, we received a crucial piece of advice from one of the creators of the golden apple, Uni's @ken, and their Dev @saucepoint:

Remove liquidity at the start of the next transaction.

This approach was undoubtedly the best for us. Firstly, it required minimal changes to the code, which was crucial as we only had 7 hours left before our hackathon presentation. Secondly, it didn't rely on the unpredictable testRouter for automated liquidity removal; we only had to wait a bit for the next user to come in, and the receivables from the previous transaction would be returned as they were.

Of course, the owner of the Hook-Pool can withdraw liquidity from Uni V4 at any time, not necessarily waiting for the next trading user. The following remove function is 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;
    }

To protect funds, we employed the beforeModifyPosition Hook to verify the msg.sender for liquidity modifications.

    // 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

For filling liquidity in Uni V4, which maintains consistent calculation formulas with Uni V3 despite its transformation into a library, we focus on filling single-sided liquidity within a very narrow tick range, like between tick -46874 and tick -46873, to minimize the costs associated with crossing ticks. By examining the code and related analytical articles, we can derive a formula using Uni's algorithm.

Let's assume fromAmount = A and toAmount = B.

For 0 → 1

  • get next price

  • make delta y = B

  • solve these function and get anwser

For 1 → 0:

To continue this process, or to directly utilize the symmetry between x and y in Uni's system (noting that here fromAmount = A refers to the amount of token1 and toAmount = B refers to the amount of token0), we can derive the following:

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);
        }
    }

But that's not the end. Observing the liquidity distribution in Uni V3/V4 reveals further insights.

source:https://uniswapv3book.com/docs/milestone_1/calculating-liquidity/

After filling liquidity, only half of the price range in the chosen tick interval effectively supports our single-sided trades. For a trade selling token0 for token1 within the [tickLower, tickUpper] interval, the target price should stay above the midpoint of [priceLower, priceUpper]. If it falls below this midpoint - (priceLower + priceUpper) / 2, it leads to price_next exceeding the lower limit, requiring adjustment of the lower tick. The same correction is needed for trades in the reverse direction. For example:


When a user needs to sell token1 for token0, leading to an increase in tick, we follow this process:用户fromAmount =  1e18
User's fromAmount = 1e18
We determine a target price, targetPrice = 0.009214376791555881
Then, we calculate toAmount = 9214376791555881
Based on the target price, we compute the nearest two ticks, yielding the corresponding ticks:
tickLower = -46873, with a price of:0.009213682692310668, sqrtpLower = 7604947311928302784860913664
tickUpper = -46872, with a price of:0.009214604060579898, sqrtpUpper = 7605327559449958075588319979

At this point, the midpoint price, mid_price = 0.009214143376445282, is determined.
It can be observed that target_price < mid_price, indicating that the price at tickUpper might not work.

For verification:
Insert tickLower, fromAmount, and toAmount into the formula
This calculation results in L = 1274268715777041035648
The corresponding sqrtpNext = 7605520219457829539575984515 is greater than sqrtpUpper, not meeting the boundary condition

Therefore, it is necessary to update the filling parameter's tickUpper to -46871, keeping L unchanged.

The corresponding code for this adjustment:

    		// 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 --;
            }     
        }

Limitation: Price Balance

Up to this point, the claims we've made about the Aggregator Hook have largely been validated. The Aggregator Hook is good, but not without its flaws. Its primary limitation is the direction of the trade. This limitation can be described in the following scenario:

  1. The current price in the V4 pool is above the price corresponding to tick0, denoted as currentPrice.

  2. When targetPrice > currentPrice, the V4 pool can only execute swaps from 1 to 0.

  3. When targetPrice < currentPrice, the V4 pool can only execute swaps from 0 to 1.

This limitation also has an intuitive explanation. We can view targetPrice as the market price and currentPrice as the price generated in the old Dex pool through trading. When the dex pool's price is lower than the market price, the pool requires users to only buy token0 to raise the price, achieving price balance. Conversely, when the dex pool's price is higher than the market price, the pool requires users to only buy token1 to lower the price, achieving price balance.

The code representation for this limiting condition is:

(, int24 tick0,,) = poolManager.getSlot0(poolId);
if(!zeroForOne && tickLower <= tick0) revert TradeDirectionError();
if(zeroForOne && tickUpper >= tick0) revert TradeDirectionError();

IV. Further Discussion

The significance of the Aggregator Hook lies in its fresh perspective on designing Hooks. Previously, our approach to Hook design was primarily aligned with the Uni V4 Pool perspective, contemplating what new functionalities Hooks could bring to "trading."

The Aggregator Hook offers a completely new angle. Due to its independence, a contract with a Hook doesn't necessarily have to be "All in Hook"; the Hook might just be a part of its functionality—creating a bridge to the Uni V4 pool, while the contract itself could execute any operation. "Trading" with Uni V4 is just one part of this contract's functionalities.

For instance, some small projects claim that after crowdfunding their tokens, they will add a certain proportion of tokens and funds to UniSwap. In the era of Uni V3, the operations of crowdfunding contracts and Uni V3 contracts were asynchronous, and users had to rely on project teams to transfer funds. However, with Uni V4 Hooks, why not use the Hook's independence to integrate liquidity addition directly into the same contract? It could even execute liquidity addition during user deposits (though this would lead to high gas costs, it might be desirable for users seeking such transparency). It's a radical idea, but it illustrates the limitless possibilities of Hooks.

The primary limitation of the Aggregator Hook is the gas cost of transactions. Normal Uni V3 transactions cost around 120k gas, but due to various liquidity adjustments in our case, a single transaction's gas cost is estimated between 420k to 550k using forge snapshot, significantly higher than a standard swap. This restricts the integrated pool approach to storage gas-insensitive Layer 2s or chains with low gas prices. A trick comes at a trick's cost, which seems fair.

There are areas for improvement, mainly concerning the handling of liquidity. There are two potential directions for this, one simpler and the other more complex. Let's start with the simpler one:

  • In the Price Balance section, we discussed the issue of trading direction. If we maintain the liquidity removal scheme, the rules for targetPrice and currentPrice can't change, but the limitTick doesn't necessarily have to be different from tick0. When limitTick equals tick0, liquidity addition becomes bilateral, and the formula for calculating the amount of liquidity to add needs to change. This adjustment could slightly relax the requirements for trading direction by one tick.

The more complex direction:

  • Since we've considered bilateral liquidity addition, why not go all the way and not force liquidity removal? Instead, only remove liquidity when the Hook-Pool manager deems it necessary. This would further reduce the gas cost during user swaps, making the Aggregator Hook more "usable." However, this could introduce a more complex liquidity addition algorithm than the simpler solution mentioned above.

The current source code for the Aggregator Hook pool is available at: Aggregator-Hook. For a clearer view of the transaction results, we added a call to the afterSwap Hook to print the results, but this isn't mandatory. There are many intermediate values printed, which can be observed using forge test to watch the Aggregator Hook in action.

DODO V3 might become the first pool to adopt this approach. Our next step is to integrate the Aggregator Hook into the DODO V3 pool. This will likely require some updates to the pool reserve and pool state. After completing all tests, we plan to open-source it, realizing the initial vision of the Hook-Pool concept.

Reference

https://github.com/Uniswap/v4-core/blob/main/docs/whitepaper-v4.pdf

https://uniswapv3book.com/