Uniswap V4: Secure Design Patterns for Hooks
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
amountSpecified refers to the specified input or output, not directly to token0/token1.
Slippage Impact: With a -1 ETH input, slippage might result in only 0.5 ETH being swapped.
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:
- 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:
Preceding Transaction: An attacker adds just-in-time liquidity right before a donation, targeting positions around the current price.
Donation Transaction: The protocol rewards all liquidity positions, including the newly added JIT liquidity, giving the attacker a portion of the donation.
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