Unfair distribution of rewards for LPs
| Title | Unfair distribution of rewards for LPs |
|---|---|
| Reward | 5661 OP, 2 dups |
| Contest | Super DCA Liquidity Network - 29 Sep 2025 on Sherlock |
| Author | vinica_boy |
| Context | Incorrect rewards distribution |
This issue requires knowledge of Uniswap v4 hooks. Each time a pool operation happens, these hooks may be called depending on the hook's configuration. Here is a nice resource on these hooks. Find the image below showing just the hook before and after modifying liquidity.
/// @inheritdoc IPoolManager
function modifyLiquidity(PoolKey memory key, ModifyLiquidityParams memory params, bytes calldata hookData)
external
onlyWhenUnlocked
noDelegateCall
returns (BalanceDelta callerDelta, BalanceDelta feesAccrued)
{
PoolId id = key.toId();
{
Pool.State storage pool = _getPool(id);
pool.checkPoolInitialized();
key.hooks.beforeModifyLiquidity(key, params, hookData);
BalanceDelta principalDelta;
(principalDelta, feesAccrued) = pool.modifyLiquidity(
Pool.ModifyLiquidityParams({
owner: msg.sender,
tickLower: params.tickLower,
tickUpper: params.tickUpper,
liquidityDelta: params.liquidityDelta.toInt128(),
tickSpacing: key.tickSpacing,
salt: params.salt
})
);
// fee delta and principal delta are both accrued to the caller
callerDelta = principalDelta + feesAccrued;
}
// event is emitted before the afterModifyLiquidity call to ensure events are always emitted in order
emit ModifyLiquidity(id, msg.sender, params.tickLower, params.tickUpper, params.liquidityDelta, params.salt);
BalanceDelta hookDelta;
(callerDelta, hookDelta) = key.hooks.afterModifyLiquidity(key, params, callerDelta, feesAccrued, hookData);
// if the hook doesn't have the flag to be able to return deltas, hookDelta will always be 0
if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) _accountPoolBalanceDelta(key, hookDelta, address(key.hooks));
_accountPoolBalanceDelta(key, callerDelta, msg.sender);
}
The pool's reward distribution occurs during each liquidity operation through the beforeAddLiquidity and beforeRemoveLiquidity hooks. The Uniswap pool's donate mechanism is used, which distributes rewards to in-range LPs at the current slot0.tick.
function donate(PoolKey memory key, uint256 amount0, uint256 amount1, bytes calldata hookData)
external
onlyWhenUnlocked
noDelegateCall
returns (BalanceDelta delta)
{
PoolId poolId = key.toId();
Pool.State storage pool = _getPool(poolId);
pool.checkPoolInitialized();
key.hooks.beforeDonate(key, amount0, amount1, hookData);
delta = pool.donate(amount0, amount1);
_accountPoolBalanceDelta(key, delta, msg.sender);
// event is emitted before the afterDonate call to ensure events are always emitted in order
emit Donate(poolId, msg.sender, amount0, amount1);
key.hooks.afterDonate(key, amount0, amount1, hookData);
}
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);
}
}
}
The problem with this design is that rewards accrued over a certain period are distributed to the current in-range position, regardless of which positions provided active liquidity during the accrual period.
Weaponizing it
This can be exploited by an attacker who performs the following steps (can be done in a single transaction):
1. Some time has passed and rewards have been accumulated, but not distributed.
2. Attacker makes a swap to move the tick to a predefined tick where their position provides liquidity in a small adjacent tick range.
3. Triggers the reward distribution mechanism by collecting fees (e.g., modifying liquidity with a 0 liquidity delta, which triggers the beforeRemoveLiquidity hook).
4. Their position accrues most of the fees, because of the very tight range they provided liquidity in.
5. Swaps back to move the tick to its original value.
Conclusion
This finding would earn you 5661 OP, which is not much given the OP price at the time. It still requires deep Uniswap knowledge, so make sure to read up on it.