# Unfair distribution of rewards for LPs

Bug Deep Dive #6 · 2 December 2025

Bug Deep Dives · [The Contest Academy](https://0xsimao.com/the-contest-academy) · 0xSimao

---

| 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](https://www.cyfrin.io/blog/uniswap-v4-hooks-security-deep-dive) is a nice resource on these hooks. Find the image below showing just the hook before and after modifying liquidity.

```solidity
/// @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.

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

```solidity
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.


[**Full Report**](https://audits.sherlock.xyz/contests/1171/report)

---

Newer: [Attackers will steal rewards from legitimate pools by making duplicate pools for listed token](https://0xsimao.com/the-contest-academy/bug-deep-dive-7) · Older: [Foundation recall will inflate governance power for earnest voters](https://0xsimao.com/the-contest-academy/bug-deep-dive-5)
