Uniswap V4: Secure Design Patterns for Hooks

·

6 min read

Designing Secure Hooks for Uniswap: Key Considerations

Hooks have become a hot topic among Web3 developers and auditors, especially with their potential to extend Uniswap's deep liquidity. While they offer new ways for developers to interact with protocols, ensuring hooks are secure is just as critical as for any other protocol. Vulnerable hooks can lead to the loss of user funds, and liquidity pools could be at risk if they attach hooks that aren’t designed securely. This discussion will explore some of the common pitfalls and offer suggestions for secure design patterns when building hooks.

Example Issue: Relying on amountSpecified Instead of balanceDelta

One major issue that can arise is when a hook relies on amountSpecified instead of the actual delta in a transaction. This design flaw can lead to unintended consequences, such as users extracting more value than they should. Below is an example of a poorly implemented hook, which blindly credits users without checking the pool's liquidity status.

Below you can see the swap parameters being set for a swap. The amountSpecified is the amount the user wants to trade. In this case, a negative amountSpecified indicates tokens leaving a user's wallet, meaning the user is willing to trade 1 ETH for X amount of $TKN.

When trading on Uniswap V4, you can choose from four directions using only the amountSpecified and zeroForOne variables. Let's look at an example where you trade 1 ETH for USDC in a pool that might not have enough liquidity to trade the entire ETH.

Setup for Trading ETH to USDC

  • zeroForOne = true: Indicates selling ETH to buy USDC.

Exact Input vs Output

  • amountSpecified = -1: "I want 1 ETH to leave my wallet" (exact input).

  • amountSpecified = +1: "I want 1 USDC to enter my wallet" (exact output).

Important Notes

  1. amountSpecified refers to the specified input or output, not directly to token0/token1.

  2. Slippage Impact: With a -1 ETH input, slippage might result in only 0.5 ETH being swapped.

  3. Refunds: The Pool Manager will refund any unused amount.

IPoolManager.SwapParams memory params = IPoolManager.SwapParams({
    zeroForOne: zeroForOne,
    amountSpecified: -1e18,
    sqrtPriceLimitX96: zeroForOne ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT // unlimited impact
});

Pools with low liquidity

\Note: The below example was reimagined based on Uniswap’s example. Link below.*

The Problem with Using amountSpecified

Let's say a hook gives users an additional 1% of the amountSpecified in every trade. In a low liquidity scenario:

Pool State:

  • Available liquidity: 10 ETH and 30,000 USDC

  • ETH price: 3,000 USDC/ETH

What Goes Wrong:

1. User requests to trade 100 ETH (amountSpecified)

  • Due to low liquidity, they only trade 10 ETH

  • But the hook calculates the 1% bonus based on amountSpecified (100 ETH)

  • Expected bonus: 1% of (100 ETH × 3,000 USDC) = 3,000 USDC

  • Actual trade: 10 ETH → 30,000 USDC

Result:

  1. User spends 10 ETH
  • Gets 30,000 USDC from the swap

  • Plus a 3,000 USDC bonus (calculated from the original 100 ETH request)

  • Total: 33,000 USDC for 10 ETH (10% extra!)

Lessons and Secure Design Patterns

To avoid such situations, it's essential to consider the following:

  • Check Pool Liquidity: Always verify the available liquidity before applying any incentives or hooks that credit users. Blindly relying on amountSpecified without checking the remaining liquidity in the pool can lead to overspending and loss of funds.

  • Use Delta Instead of amountSpecified: Instead of basing rewards on the specified amount of the trade, use the balanceDelta that is returned from the swap. This ensures that incentives or extra credits are only given based on what is available in the pool and the actual delta from the swap.

Citations: Uniswap v4 Known Effects of Hook Permissions

Exploiting Hooks with Just-in-Time Liquidity

Just-in-Time (JIT) liquidity has become a known vector for exploitation in protocols that use liquidity incentives or donation mechanisms. Specifically, when protocols allow users to donate tokens to liquidity providers (LPs), attackers can use JIT liquidity to siphon off rewards without contributing meaningful liquidity.

    /// @dev The state of a pool
    struct State {
        Slot0 slot0;
        uint256 feeGrowthGlobal0X128;
        uint256 feeGrowthGlobal1X128;
        uint128 liquidity;
        mapping(int24 tick => TickInfo) ticks;
        mapping(int16 wordPos => uint256) tickBitmap;
        mapping(bytes32 positionKey => Position.State) positions;
    }

    /// @notice Donates the given amount of currency0 and currency1 to the pool
    function donate(State storage state, uint256 amount0, uint256 amount1) internal returns (BalanceDelta delta) {
        uint128 liquidity = state.liquidity;
        if (liquidity == 0) NoLiquidityToReceiveFees.selector.revertWith();
        unchecked {
            // negation safe as amount0 and amount1 are always positive
            delta = toBalanceDelta(-(amount0.toInt128()), -(amount1.toInt128()));
            // FullMath.mulDiv is unnecessary because the numerator is bounded by type(int128).max * Q128, which is less than type(uint256).max
            if (amount0 > 0) {
                state.feeGrowthGlobal0X128 += UnsafeMath.simpleMulDiv(amount0, FixedPoint128.Q128, liquidity);
            }
            if (amount1 > 0) {
                state.feeGrowthGlobal1X128 += UnsafeMath.simpleMulDiv(amount1, FixedPoint128.Q128, liquidity);
            }
        }
    }

Example: Exploiting Donation Mechanics in Liquidity Pools

That plans on donating to liquidity providers via PoolManager.donate(). This function allows users to donate tokens to LPs. These donations are added to the global swap fee trackers, immediately increasing the earned swap fees for LPs at the current price.

Here’s how an attacker could exploit this mechanic:

  1. Preceding Transaction: An attacker adds just-in-time liquidity right before a donation, targeting positions around the current price.

  2. Donation Transaction: The protocol rewards all liquidity positions, including the newly added JIT liquidity, giving the attacker a portion of the donation.

  3. Following Transaction: After the donation is applied, the attacker swiftly removes their liquidity, effectively capturing a portion of the donation without providing lasting liquidity.

This kind of attack is possible because the donation amounts can be significantly larger than typical swap fees. By using a sandwich attack that wraps the donation transaction, the attacker can capture a significant portion of the donation.

Proposed Solution: Reward Donations Based on Time in LP Position

To counteract JIT liquidity exploits and better align donations with long-term liquidity provision, donation rewards should be distributed based on how long a user has held an LP position, rather than immediately upon liquidity addition. This solution incentivizes long-term liquidity providers and prevents opportunistic attackers from gaming the system by temporarily adding liquidity.

By calculating rewards based on the duration a user has held a position, we ensure that:

  • True Liquidity Providers: Only LPs who maintain their position for a significant period are rewarded.

  • Reduced Exploits: JIT liquidity providers cannot profit by temporarily adding liquidity before a donation event and then quickly withdrawing it.

Additional Recommendations

  • Vesting Donations: In addition to rewarding based on time in the pool, donation rewards could be vested over time to prevent quick exits after donations are made. This further ensures that LPs provide lasting liquidity and aligns incentives more effectively.

  • Impose Minimum Liquidity Periods: Requiring LPs to maintain their liquidity for a minimum duration to receive any rewards could also prevent quick-exit strategies and ensure rewards are only given to those contributing to long-term stability.

Citations: Spearbit Draft Audit on Uniswap Core