Attackers will steal rewards from legitimate pools by making duplicate pools for listed token
| Title | Attackers will steal rewards from legitimate pools by making duplicate pools for listed token |
|---|---|
| Reward | 232 OP, 24 dups |
| Contest | Super DCA Liquidity Network - 29 Sep 2025 on Sherlock |
| Author | Kirkeelee |
| Context | Incorrect Uniswap V4 understanding |
This finding does not pay especially well — there are 24 duplicates — but it carries a key piece of information about Uniswap v4.
Unlike v3, Uniswap v4 allows any number of pools for the same token pair, and the protocol was not aware of this. The pool key, which identifies each pool, varies over the following parameters:
/// @notice Returns the key for identifying a pool
struct PoolKey {
/// @notice The lower currency of the pool, sorted numerically
Currency currency0;
/// @notice The higher currency of the pool, sorted numerically
Currency currency1;
/// @notice The pool LP fee, capped at 1_000_000
uint24 fee;
/// @notice Ticks that involve positions must be a multiple of tick spacing
int24 tickSpacing;
/// @notice The hooks of the pool
IHooks hooks;
}
More about Uniswap v4 hooks here. The key functionality is the modify-liquidity hooks, called when adding or removing liquidity, even a zero amount.
/// @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);
}
Weaponizing it
1. Attacker identifies a listed token (e.g., USDC) with an existing legitimate pool (e.g., USDC/SuperDCA with dynamic fee).
2. Attacker creates a new malicious pool for the same pair (USDC/SuperDCA) using the same SuperDCAGauge hook, with minimal or zero liquidity (bypassing listing requirements).
3. Attacker adds minimal liquidity to the malicious pool, triggering _beforeAddLiquidity, which calls _handleDistributionAndSettlement.
4. In _handleDistributionAndSettlement, rewards accrue for the token (USDC) globally, and the community share is donated to the malicious pool, since it has liquidity.
5. Attacker removes liquidity from the malicious pool, triggering _beforeRemoveLiquidity again, potentially accruing more rewards.
6. Attacker repeats the add/remove operations to maximise the theft, then withdraws the donated rewards from the malicious pool.
Conclusion
This finding would earn you 232 OP, which at the OP price of the time buys you a nice dinner.