Guardian's review of Position Managers for Gamma Strategies, published July 2025. The report records 58 findings across 2 review rounds, including 5 high and 10 medium.
- Published
- Review window
- June 9 to July 7, 2025
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Ethereum, Arbitrum, Optimism, Base, Polygon, BNB Chain
- Sector
- Yield and vaults
- 0 Critical
- 5 High
- 10 Medium
- 32 Low
- 11 Informational
Scope
11 files in scope · 2,767 nSLOC
| File | nSLOC | Lines |
|---|---|---|
contracts/UniProxyETH.sol | 181 | 290 |
contracts/MultiFeeDistribution.sol | 301 | 525 |
contracts/HypervisorNFPM.sol | 483 | 743 |
contracts/ClearingV3NFPM.sol | 177 | 273 |
contracts/libraries/RewardCalculations.sol | 119 | 157 |
contracts/libraries/PositionValue.sol | 75 | 96 |
contracts/libraries/PositionManagementLibrary.sol | 192 | 228 |
src/UniProxyV2.sol | 100 | 152 |
src/PoolManagerUtils.sol | 417 | 485 |
src/MultiPositionManager.sol | 598 | 778 |
src/ClearingV3.sol | 124 | 164 |
Findings 58
Main Review
49 findings · June 9 to 25, 2025-
H-01 High No Share Slippage Protection In
depositFrontrunning ResolvedDescription
In
MultiPositionManager, deposits do not enforce a minimum share amount, exposing users to front-running attacks that manipulate pool prices to dilute share allocation.Attack Scenario:
- Vault holds equal liquidity in two pools (e.g., USDC/WETH, fee tiers 0.3% and 1%).
- Bob deposits 1000 USDC and 1 WETH, expecting ~1000 shares based on a balanced vault (assume
total0=10_000 USDC,total1=10 WETH,totalSupply=10_000). - Alice front-runs Bob by manipulating prices across the two pools:
Swaps USDC → WETH in pool A (increasing WETH price). Swaps WETH → USDC in pool B (lowering WETH price).
- This skews the
getTotalAmountsresult:
Total token balances (e.g., total0 = 20,000 USDC, total1 = 20 WETH) double, but ratio remains balanced.
- Bob’s deposit is now worth a smaller portion of the pool, and he receives only 500 shares instead of 1000.
- Attacker backruns the deposit and swaps back to bring prices and pool back to original values
As a result, when Bob withdraws he receives less than he deposited losing funds in the process. Depending on the pool type and liquidity available, significant funds could be lost which would go to other depositors of the pool.
Recommendation
Introduce a
minSharesparameter todepositto ensure depositors receive their expected amount of shares. Also, consider implementing a price deviation check (similar to HypervisorNFPM), which would prevent the price manipulation that is needed for this attack vector. -
H-02 High Wrong Usage Of
collectRewardsReturn Values Logical Error ResolvedDescription
HypervisorNFPM._collectAndClaim()incorrectly uses the amounts for the collected pending rewards returned by farmingCenter.collectRewards() . It checks if they are a positive number and claims the rewards only if they are. The problem with this approach is that every time an Algebra position enters or exits the farms, any pending rewards are collected. This action can be triggered by anyone via call toNonFungiblePositionManager.increaseLiquidity(). The malicious user can add minimum amount of liquidity to the base and limit positions, which will collect any pending rewards. Therefore, if_collectAndClaim()is executed without accrual of new rewards, nothing will be claimed. This will disturb the rewards distribution forMultiFeeDistribution. For example:- call
increaseLiquidityto stop the rewards claiming if huge rewards have accumulated staketheir tokens which will callHypervisor.getReward(), but because both rewards are 0, it will not do anything- When the rewards are actually sent to the contract, the malicious staker will have received a part of them.
Additional impacts are:
- Rewards being distributed to the wrong
receiverbecause there is atransferReceiver()function which can change it before the next claim. - the emitted
RewardsCollected()event will use wrong values asrewardandbonusRewardare pending rewards.
Recommendation
(uint256 reward, uint256 bonusReward) = farmingCenter.collectRewards(key, tokenId); farmingCenter.collectRewards(key, tokenId); // Claim and send to MultiFeeDistribution. 0 amount is max amount. if (reward > 0) { farmingCenter.claimReward(key.rewardToken, address(receiver), 0); uint256 reward = farmingCenter.claimReward(key.rewardToken, address(receiver), 0); } if (bonusReward > 0) { farmingCenter.claimReward(key.bonusRewardToken, address(receiver), 0); uint256 bonusReward = farmingCenter.claimReward(key.bonusRewardToken, address(receiver), 0); } emit RewardsCollected( tokenId, reward, bonusReward ); - call
-
M-01 Medium Unfortunate Withdraw Timing Can Lead To Loss MEV Resolved
Description
When a user calls
withdraw, they provide an outMin param which is an array of amount0/amount1 values that should be returned for each position. If they call withdraw and while their transaction is pending, the admin calls rebalance and reduces the number of base positions or simply moves the liquidities to different ticks, then the outMin array may not correlate to the expected return amounts of the previous positions. Therefore, the slippage checks via the outMin parameter can be exploited to steal value from the withdrawal.Here is an example scenario of when the admin reduces the number of base positions:
- There are 5 base positions. The admin rebalances to 3 base positions.
- The user had previously initiated a withdraw transaction with roughly 20% of value expected from each base position.
- Now that there are only 3 base positions, the user should expect to receive ~33% of value from each base position.
- The difference between 33% and 20% can be extracted due to MEV.
Recommendation
One potential mitigation would be to require the user to input a base positions array into the withdraw call that would be checked against the current state of the base positions.
More generally, it may be a good idea to enforce a deadline parameter so that the users' withdraw transaction cannot stay pending, potentially while the base positions array is updated due to rebalances.
-
M-02 Medium Fee Checked Incorrectly Logical Error Resolved
Description
When setting the fee that is sent to the treasury, the new fee value is checked incorrectly. Instead, the previous fee value is checked to ensure that it is not less than one.
if (fee < 1) revert InvalidFee();Recommendation
Instead, check that the
newFeeparameter is not less than one and revert if it is. -
M-03 Medium Tick Bounds Can Be Miscalculated Logical Error Resolved
Description
The
depositfunction in theUniProxyV2contract callsclearance.clearDepositto ensure all positions have their current tick within the allowed range. If any are out of range, the call reverts.However, when there are no limit positions—such as when
limitWidthis set to zero during a rebalance—the_checkTicksfunction receives zero values for those positions fromgetPositions. These zero-value entries are still included in the loop that determines the lowest and highest ticks.As a result, they can be incorrectly selected as the min or max tick, distorting the tick bounds. This may cause a real position with an out-of-range tick to appear valid, allowing the deposit to proceed when it should have reverted.
This undermines the tick range enforcement and introduces the risk of deposits occurring outside of the configured bounds.
Recommendation
In the getPositions and currentTicks functions, return early if limit positions are not set, similar to how this case is handled elsewhere in the contract.
-
M-04 Medium Overflow In Price Calculation Not Fixed Math Resolved
Description
In the previous audit, Issue 31 raised the possibility of overflow for pairs with very large price.
The original price calculation was:
uint256 price = FullMath.mulDiv(uint256(sqrtPrice) * uint256(sqrtPrice), PRECISION, 2**(96 * 2));which was fixed to:
FullMath.mulDiv(uint256(sqrtPrice) * 1e18, uint256(sqrtPrice) * 1e18, 2**(96 * 2));This was fixed in the
_depositfunction ofHypervisornNFPM.sol. However, the original calculation still exists in thecheckPriceChangefunction ofClearingV3NFPM.sol. By using the original implementation, overflow can occur when performing:uint256(sqrtPriceBefore) * uint256(sqrtPriceBefore).Recommendation
Update the price calculation
ClearingV3NFPM.solto the corrected version which prevents overflow. -
M-05 Medium Algebra Oracle Vulnerable To TWAP Lookback DoS DoS Acknowledged
Description
In HypervisorNFPM, a TWAP-based price check is performed before deposits, using Algebra’s Volatility Oracle. The oracle computes TWAP via stored timepoints, which are indexed by a
uint16 timepointIndexwith a maximum of 65,535 entries (uint16.max).Timepoints are recorded during swaps, but only once per block. If a swap occurs in a block and a timepoint is recorded, any subsequent swaps in the same block won’t create additional entries. Over time, once the
timepointIndexoverflows and older timepoints are overwritten.The issue lies with how if the caller specifies a
twapIntervalthat is older than the oldest stored timepoint, the_getTimepointsAtfunction will revert (https://github.com/cryptoalgebra/Algebra/blob/51e5f9697a394237de17ae5892ae1c7575d7f8fc/src/plugin/contracts/libraries/VolatilityOracle.sol#L464)Attack vector: If an attacker triggers a swap in every block, the oracle fills up and starts overwriting past timepoints. On chains like BSC, where the average block time is ~0.75s, the full 65,535 timepoint buffer can be filled in:
0.75s * 65,535 ≈ 49,000 seconds ≈ 13.6 hoursThe HypervisorNFPM vault uses a
twapIntervalof 86,400 seconds (24 hours) for stable pools. This interval will eventually become unreachable in the oracle, causing TWAP reads to revert and permanently blocking deposits unless the interval is reduced.Recommendation
Constrain
twapIntervalto a safe maximum, calculated as:twapInterval = avg block time * 65,535. Alternatively, consider using a non-twap oracle such as Chainlink to obtain price. -
M-06 Medium Rebalance Can Revert During Emergency Mode DoS Resolved
Description
The
rebalancefunction burns and re-mints positions, then calls_approveAndEnterFarmingto approve and enter the new farming position. However,_approveAndEnterFarmingdoes not check whetherisEmergencyWithdrawActivatedis enabled in theAlgebraEternalFarmingcontract.If emergency mode is active,
enterFarmingwill revert insidefarmingCenter, blocking the entire rebalance process. Without rebalancing, the protocol cannot maintain its intended strategy. Furthermore, If the current tick moves out of range, certain functions will be blocked due to implemented checks—such asdeposit, which requires the current tick to be in range.Recommendation
Add a check for isEmergencyWithdrawActivated() in _approveAndEnterFarming, and return early if true, similar to how the isIncentiveDeactivated check is implemented.
-
M-07 Medium Clearing Reverts On Inactive Limit Positions DoS Resolved
Description
Clearing._checkTicks()ensures that each tick incurrentTicksis betweenlowestTickandhighestTick, which are derived from all base and limit positions. However, even inactive limit positions (e.g., whenlimitWidth == 0) are included incurrentTicks. These inactive positions have uninitialized pool keys, defaulting their ticks to 0.As a result, if
lowestTick > 0orhighestTick <= 0,_checkTicks()will incorrectly revert, causing a denial of service for deposits.Recommendation
You can change
MultiPositionManager.currentTicks()to return a magic value for inactive positions and then skip the iteration in the for loop of_checkTicksif thecurrentTickis equal to the magic value. -
M-08 Medium
decreaseLiquidityDoesn't Collect Funds Unexpected Behavior AcknowledgedDescription
HypervisorNFPMhas two functions that allow the owner to manually control positions on behalf of the contract -mintLiquidityanddecreaseLiquidity. The first one can be used to create new positions by adding liquidity and using them to farm incentives. The second one is used to pull liquidity from positions to the contract. The problem is thatdecreaseLiquiditydoesn't call_zeroBurnandfarmingCenter.collectRewards(). This means that:- if the position being decreased is
baseorlimitthe generated fees will be collected together with the removed liquidity and all of them will be attributed to the vault participants, i.e no cut for the protocol team. - if the position being decreased is not
baseorlimit, all the incentives generated in the farm will be stuck there as there is no way for the contract to callcollectRewardswith the id of that position. An exception is if the owner callssetNftIdsand sets the position as eitherbaseorlimitto claim the rewards, but this can disrupt the normal flow of the contract.
Recommendation
Call
_zeroBurn()at the beginning ofdecreaseLiquidityand also collect the received incentives if any. - if the position being decreased is
-
L-01 Low Excess Native Refunds May Revert For Contracts Best Practices Acknowledged
Description
In the
_transferInfunction, if someone sends too much native currency (like ETH), the contract tries to refund the extra using.transfer. While that works fine for normal wallets (EOAs), it becomes a problem if the sender is a contract. The.transfermethod only forwards 2300 gas, which isn't enough for contracts that need more gas in theirreceive()orfallback()functions. So if the sender is a contract that expects to do anything when receiving ETH, this refund logic will revert.Recommendation
Instead of using
.transfer, use.call{value: ...}("")to forward the ETH.(bool success, ) = payable(msg.sender).call{value: msg.value - amount}(""); require(success, "Refund failed"); -
L-02 Low FeeRecipient Update Can Be Frontrun Documentation Acknowledged
Description
In
HypervisorNFPM.sol, the admin sets thefeeRecipientin each call torebalance(). The comment indicates_feeRecipient Address of recipient of % of fees since last rebalance, however this will not always be true.If a user performs any action such as a deposit or withdraw, it will call
_zeroBurn()which will collect the fees and transfer them to the currentfeeRecipient.Recommendation
Note this or update the comment to indicate that the fees may be received to the currently set address in the case of user deposits or withdrawals.
-
L-03 Low Unnecessary Approvals Unexpected Behavior Resolved
Description
The ClearingV3.sol contract performs token approvals but does not perform any transfers.
Recommendation
Remove the token approval logic to avoid confusion of the contract's purpose.
-
L-04 Low Unused ReentrancyGuard In ClearingV3.sol Informational Resolved
Description
The ReentrancyGuard.sol contract is inherited but not used in the ClearingV3.sol.
Recommendation
Remove the inherited contract to avoid confusion and excess code size.
-
L-05 Low Incorrect Error Usage Informational Resolved
Description
InvalidBurn()used in check for reward tokens,Recommendation
TBD
-
L-06 Low Incorrect Price For Negative Tick Logical Error Acknowledged
Description
The getSqrtTwapX96 function in the ClearingV3NFPM contract computes a square-root TWAP over a specified interval. However, when the tick difference is negative and not evenly divisible by the _twapInterval, the function fails to round the tick down as required. Instead, it truncates toward zero, resulting in an incorrect (too high) tick value and thus an inaccurate price
Recommendation
Implement rounding toward negative infinity when the tick difference is negative and has a remainder. Specifically, if tickCumulatives[1] - tickCumulatives[0] is negative and (delta % _twapInterval) != 0, decrement the tick by one.
-
L-07 Low mintLiquidity Mishandles Fees Unexpected Behavior Acknowledged
Description
The
mintLiquidityfunction creates a new liquidity position with a freshtokenId. While this new position is entered into a farming position, it is not accessible byMultiFeeDistributionfor reward claims.Currently,
getRewardonly claims rewards for the base and limittokenIdsset in the vault. As a result, rewards for the newly minted position remain unclaimable until the vault owner explicitly callssetNftIdsto update one of the tracked token IDs.On a similar note, when
setNftIdsis called, the rewards for the current base and limit positions are not collected first. As result, these rewards are lost once the new tokenIds are written.Recommendation
- Consider implementing a
getRewardfunction that allows for the passing in of atokenIdto allow rewards to be claimed for additional positions beyond the base and limit positions. - Consider calling
getRewardon the current base and limit positions beforesetNftIdsupdates.
- Consider implementing a
-
L-08 Low getTotalAmountsPlusFees May Be Stale Warning Acknowledged
Description
The
getTotalAmountsPlusFeesfunction returns the total oftoken0andtoken1including fees, usingcalculatePositionFeeto compute the latest fees and add them to the currently owed amounts.However, this doesn’t account for how Algebra pools handle rebase tokens/donations. At the start of major pool interactions, if the actual token balances exceed expected reserves, the surplus is treated as a donation and distributed to active liquidity providers as additional fees—provided there is non-zero liquidity. Since
getTotalAmountsPlusFeesdoesn’t account for this, these additional fees may not be reflected in the returned values.While this isn’t an issue for core HypervisorNFPM functions (which call
zeroBurnfirst), it could lead to stale or slightly inaccurate fee values when callinggetTotalAmountsPlusFeesdirectly.Recommendation
Be aware that getTotalAmountsPlusFees may be stale or slightly inaccurate in the following scenario.
-
L-09 Low Possible 0 Token Transfer Rewards Resolved
Description
MultiFeeDistribution._updateReward()transfersnewRewards / feeas a fee to the owner unconditionally. If one of the reward tokens is a token that reverts on 0 transfers, users can grief the actions of the contract by sending as little as 1 wei of that token (if this token wasn't accrued currently).Recommendation
Check if the amount after the division is positive
- if (newRewards > 0) { + if (newRewards / fee > 0) { IERC20(rewardToken).safeTransfer(owner(), newRewards/fee); currentBalance = IERC20(rewardToken).balanceOf(address(this)); newRewards = newRewards - (newRewards/fee); // Actual new rewards after fee } -
L-10 Low
setNftIdsDoesn't Collect Funds Unexpected Behavior AcknowledgedDescription
HypervisorNFPM.setNftIds()is a helpful utility function which can be used by the owner to change any of the two position ids. Similar to the issue described in the other report aboutdecreaseLiquidity(),setNftIdsdoesn't call_zeroBurn()and_collectAndClaim(). The following problem exists If the function is used to change the ids for a prolonged period of time - accrued fees and incentive rewards will be either lost if the old ids are not restored or they will be wrongly distributed because they will not be counted towards the total funds for any new deposits in the window wheresetNftIdtook place.Recommendation
Call
_zeroBurn()and_collectAndClaim()before changing the ids. -
L-11 Low Unnecessary Check In
addReward()Best Practices AcknowledgedDescription
MultiFeeDistribution.addReward()checks if the token to be added is an active reward twice, which is unnecessary and only causes higher gas expenditure.(bool isRewardTokenExist, ) = _isRewardTokenExist(_rewardToken); if (isRewardTokenExist) revert ActiveReward(); if (!managers[msg.sender]) revert InsufficientPermission(); for (uint i; i < rewardTokens.length; i ++) { if (rewardTokens[i] == _rewardToken) revert ActiveReward(); }Recommendation
Remove one of the checks, for example the first one since it's more expensive.
- (bool isRewardTokenExist, ) = _isRewardTokenExist(_rewardToken); - if (isRewardTokenExist) revert ActiveReward(); if (!managers[msg.sender]) revert InsufficientPermission(); for (uint i; i < rewardTokens.length; i ++) { if (rewardTokens[i] == _rewardToken) revert ActiveReward(); } rewardTokens.push(_rewardToken); -
L-12 Low
simulateOneSecondGrowthIs Not Used Best Practices AcknowledgedDescription
The
RewardCalculations.simulateOneSecondGrowth()function is never called in the codebase.Recommendation
Consider removing it.
-
L-13 Low Slippage Not Enforced For Limit Positions Frontrunning Acknowledged
Description
The
inMinandoutMinparameters are user-supplied slippage controls used duringwithdraw,rebalanceandcompoundto ensure the user or protocol uses or receives a minimum acceptable amount of tokens when adding or removing liquidity.However, this check is not applied when minting or burning liquidity from limit positions. Instead,
_mintLiquidityForAmountsandburnLiquidityForShareare called with hardcoded zero slippage tolerance:(uint256 amountOut0, uint256 amountOut1) = burnLiquidityForShare( poolManager, limitPositions[i], shares, totalSupply, [uint256(0), uint256(0)] // @audit zero slippage tolerance );This bypasses the intended slippage protection and opens up the vault to price manipulation or frontrunning attacks, where:
- A user may receive significantly less than expected during a
withdraw. - The vault may unintentionally deploy liquidity in a distorted ratio during
rebalanceorcompound
Recommendation
- Apply
inMinandoutMinby passing the relevant slippage values into_mintLiquidityForAmountsandburnLiquidityForSharefor limit positions instead of [0, 0]. - In
UniProxyV2,getOutMinForSharesshould also calculateoutMinfor limit positions.
- A user may receive significantly less than expected during a
-
L-14 Low Unnecessary Assignment Best Practices Acknowledged
Description
The constructor of
HyperVisorNFPMsets the value ofincentiveMakertwice - once outside the if statement and once inside of it - to the same value, which is redundant.Recommendation
Delete the second assignment.
-
L-15 Low Staking Token Can Be Set As Reward Token Best Practices Resolved
Description
MultiFeeDistribution.setStakingToken()doesn't check if_stakingTokenis not an active reward token. This allows bypassing the check inaddReward()and adding a reward token as a staking token.Recommendation
Consider reverting the
setStakingToken()transaction if_stakingTokenis an active reward token. -
L-16 Low Clearing Doesn't Consider Liquidity Informational Acknowledged
Description
When ticks are checked in
Clearing, all positions are included, even inactive ones (i.eliquidity = 0). It may be possible that some positions have 0 liquidity after withdrawals or if a there wasn't enough liquidity when limit positions were created. These positions will still impact the clearing validation.Recommendation
Make sure that's the correct behavior.
-
L-17 Low
CEIPattern Not Followed Best Practices AcknowledgedDescription
MultiPositionManager.deposit()andMultiPositionManager.withdraw()functions doesn't follow theCEIpattern - they transfer tokens before writing to the contract state. This gives the execution flow the recipient for pools with native token or pools with tokens with hooks. This opens up possibilities for read-only reentrancy and unexpected behavior. For example, a user may use the fund transfer inwithdraw()to transfer more tokens to theMultiPositionManagerand cause wrongWithdraw()event data emission.Recommendation
Consider following the
CEIpattern. -
L-18 Low Withdrawals Can Be Weaponized Unexpected Behavior Acknowledged
Description
When users withdraw, funds are taken out of the base and limit positions. Because there isn't any delay between depositing and minting, users can atomically withdraw their deposits and pull liquidity out of the positions and griefing the manager from collecting fees.
Recommendation
Consider implementing a delay between deposit and withdraw
-
L-19 Low Potential 0 Transfer On Withdraw Validation Resolved
Description
MultiPositionManager.withdraw()transfersamount0andamount1of the currencies unconditionally, even if they are zeroes. If used with some tokens that revert on 0 transfers, withdrawals may be blocked.Recommendation
Skip the transfers if the amounts are 0s.
-
L-20 Low ETH Surplus May Not Be Refunded Logical Error Resolved
Description
In the
MultiPositionManager.deposit()function, when interacting with pools wheretoken0 == address(0)(i.e., native token), the_transferIn()function is responsible for handling incoming native tokens and refunding any excess via themsg.value > amountcheck.However, a logic flaw exists: if
amount == 0,_transferIn()returns early without executing the refund logic. This results in a scenario where any native tokens sent along with the call are silently retained, causing a loss of funds for the user.This can happen when:
- The pool’s current tick is above the upper bound of both the base and limit positions, making the
total0 == 0. - The deposit is contributing only token1, so
amount == 0is passed to_transferIn(). - There are no idle token0 funds or pending fees in the contract, so no internal balance offsets this.
- The user unintentionally sends a nonzero
msg.value, expecting a partial refund.
Currently, this situation is prevented by the
Clearingcontract, which blocks such deposits. However, future changes to the clearing logic or the whitelist may reopen this vulnerability.Recommendation
Don't skip the refund for native tokens.
function _transferIn(address from, Currency currency, uint256 amount) internal { - if (amount == 0) return; if (currency.isAddressZero()) { if (msg.value < amount) revert InvalidDepositAmount(amount); if (msg.value > amount) payable(msg.sender).transfer(msg.value - amount); } else if (amount != 0) { IERC20(Currency.unwrap(currency)).safeTransferFrom(from, address(this), amount); } } - The pool’s current tick is above the upper bound of both the base and limit positions, making the
-
L-21 Low
inMinIs Not Enforced Whenliquidity = 0Validation AcknowledgedDescription
PoolManagerUtils._mintLiquidityForAmounts()enforces theinMinconstraint only whenliquidity > 0. However,mintLiquidities()may call_mintLiquidityForAmounts()with a positiveliquidityvalue but with actual token amounts equal to zero—for instance, if the contract’s token balances were already depleted before this call.In such a case, the function skips minting (since
liquidity == 0), and no check is performed againstinMin, even if the expected minimum input amount was positive. This leads to a silent mismatch between intended and actual behavior.Recommendation
Consider validating the added amounts against
inMineven if there was no liquidity added. -
L-22 Low
UniProxy.transferETH()Is Permissionless Informational AcknowledgedDescription
The
UniProxy.transferETH()function used for making ETH transfers is external and permissionless which means any funds in the contract can be taken out by users.Recommendation
Never hold funds in the contract.
-
L-23 Low Whitelisted Depositors Are Not Exempted Validation Resolved
Description
The
ClearingV3contract contains a list with depositors (freeDepositList) that should not be subject of the clearing logic. However, this list is never utilized and in result the whitelisted entities are no different than every other user.Recommendation
Consider executing
clearDepositinUniProxyV2only ifmsg.senderis not whitelisted.+ if (!clearance.getListed(pos, msg.sender)) { clearance.clearDeposit(to, pos); + } -
L-24 Low
UniProxyETHIs Vulnerable To Tokens With Hooks Unexpected Behavior AcknowledgedDescription
UniProxyETH.depositETH()andUniProxyETH.depositETHAndStake()perform these three actions in the following order:clearDeposit()- transfer token from user
- execute the
Hypervisordeposit
If the token to be transferred has hooks, the depositor will be able to bypass the clearing mechanism, including manipulating the price outside of the bounds of the
TWAPand effectively draining the contract.Recommendation
Move the token transfer from the second step in the description before the call to
clearDeposit()and usedeposit0anddeposit1as transferred amounts. This is okay since there is a refund mechanism at the end of the function. -
L-25 Low Zero-Interval TWAP Always Returns Zero Logical Error Resolved
Description
The
getSqrtTwapX96function in theClearingV3NFPMcontract is intended to return the square-root TWAP price over a specified interval. When the interval is zero, it should return the current price. However, while the current price is fetched, it is not assigned to the function’s return variable. As a result, calls with a zero interval always return zero.Consequently, when
checkPriceChangeuses this function, a zero interval leads to a zero price. This causes the price-change threshold check to be exceeded and the function to revert, effectively blocking deposits.Recommendation
Fix getSqrtTwapX96 so that when the interval is zero, the fetched current price is correctly assigned to the return variable
-
L-26 Low Misaligned Limit Positions Due To slot0.tick Unexpected Behavior Acknowledged
Description
In
MultiPositionManager, limit positions are centered aroundslot0.tick, assuming it reflects the current price. However, this is inaccurate when a swap ends exactly at a tick boundary. As per Uniswap's swap logic, whenresult.sqrtPriceX96 == step.sqrtPriceNextX96at the end of a swap step, and the direction iszeroForOne, the protocol sets the current tick totickNext - 1(source)[https://github.com/Uniswap/v4-core/blob/main/src/libraries/Pool.sol#L431].Example scenario:
- Initial setup: Stable pool tick range
[-20, 20], current tick:0, initial price:X - A swap token1 -> token0 moves tick to
1 - An equal swap back from token0 -> token1 brings price back to exactly
X - However, current tick is now
-1, not0due to Uniswap's handling ofzeroForOneswaps
As a result, limit positions of 1 tick width are deployed at range
[-2,0], leading to inefficient limit position placement and therefore missed fee opportunities.Recommendation
Consider allowing admin to specify exactly which tick and ranges to deploy limit liquidity in (similar to how HypervisorNFPM is implemented).
Alternatively, use
slot0.sqrtPriceX96to derive the actual current tick usingTickMath.getSqrtPriceAtTick(). This ensures limit positions are accurately centered at the true price. - Initial setup: Stable pool tick range
-
L-27 Low Inconsistent Price Threshold Check Math Acknowledged
Description
ClearingV3NFPM.checkPriceChange()compares the Algebra pool's spot price to a given TWAP price and reverts if the price deviates more than a set threshold. While the computation works whenspotPrice > twapPrice, it fails in the opposite case - it calculates the price change as if the price went fromspotPricetotwapPriceinstead oftwapPricetospotPrice. The code doing the math is the following:if (price * 10_000 / priceBefore > _priceThreshold || priceBefore * 10_000 / price > _priceThreshold) revert("Price change overflow");Let's say:
priceBefore = $200(TWAP price)price = $100(Spot price)
This is a 50% deviation from the
TWAP. However, value checked against_priceThresholdwill be the maximum betweenprice * 10_000 / priceBefore=100 * 10000 / 200 = 5000priceBefore * 10_000 / price=200 * 10000 / 100 = 20000(100%)
In result, the price change from
$200to$100will be considered as a100%change instead of50%.Recommendation
Compute an absolute delta value between the two prices and calculate the deviation as
delta * 10_000 / priceBefore + 10_000. -
L-28 Low Lack Of Checkpointing When Setting New Fee Logical Error Resolved
Description
In the Hypervisor contract, the admin is able to set the percentage of fees that will collected as protocol fees. However, the new value is set without firstly calling
_zeroBurn()which transfers the relevant fee values to thefeeRecipient.Therefore, The admin can set this value to its lowest value, e.g. 1, which will transfer them all fees that are collected.
Contrast this to the
MultiPositionManagercontract which does successfully call_zeroBurn()prior to setting the new fee.Similarly, the fees are not checkpointed in
MultiFeeDistribution.soleither when resetting the fee.Recommendation
Call
_zeroBurn()prior to updating the fee value inHypervisorNFPM.sol. Call_updateRewards()inMultiFeeDistribution.sol. -
L-29 Low
mintLiquidityCauses Accounting Mismatch Unexpected Behavior AcknowledgedDescription
When
HypervisorNFPM.mintLiquidity()is invoked, funds from the contract are provided as a liquidity for a new Algebra position, however these funds are not accounted for ingetDepositAmount()andwithdraw(). This means that almost any call tomintLiquidity()will result in broken accounting - loss for current depositors and profit for the new ones if liquidity is pulled from that position in the future.Recommendation
Consider:
- tracking the balances of the minted positions via this function
- pulling funds from them on
withdraw()
-
L-30 Low Zero Shares Received During Deposit Logical Error Resolved
Description
The
depositfunction in HypervisorNFPM does not verify that the calculated shares are greater than zero. This issue was previously raised and partially addressed by adding a slippage check ondeposit0anddeposit1. However, that check does not prevent zero-share outcomes caused by rounding errors, price manipulation, or small deposit amounts.This can lead to situations where a user deposits tokens but receives zero shares in return—resulting in loss of funds.
Notably, the
MultiPositionManagerimplementation explicitly guards against this by checking thatshares > 0.Recommendation
Add an explicit check:
require(shares > 0, "ZeroShares"); -
I-01 Informational Incorrect Comment For removeRewardToken Documentation Resolved
Description
The comment above
MultiFeeDistribution.removeRewardTokenindicates:* @notice Add a new reward token to be distributed to stakers.Recommendation
Update the comment to indicate that the function removes a reward token instead of adds.
-
I-02 Informational Incorrect Comment For Managers Documentation Acknowledged
Description
The comment for the
managersmapping in MultiFeeDistribution states that the managers are "Addresses approved to call mint". However, these managers can only add or remove reward tokens.Recommendation
Update the comment to indicate the managers' ability.
-
I-03 Informational Missing Zero Address Check In Constructor Validation Resolved
Description
In the constructor,
_token0and_token1are assigned tocurrency0andcurrency1. While_token0may be the zero address to represent native ETH,_token1should never be zero. Currently, there’s no check to enforce this.Recommendation
Add a validation check:
if (_token1 == address(0)) revert ZeroAddress(); -
I-04 Informational Use Require Over Assert Best Practices Resolved
Description
In the
depositandcalcSharesAndAmountsfunction, the code usesassert(shares > 0);, which triggers apanic: assertion failed (0x01)if the condition fails. This revert reason is opaque and hinders debugging.assertshould be reserved for testing internal errors and invariants. In production,requireshould be used instead to provide a clear and meaningful revert reason.Recommendation
Replace with a require and a clear error message:
require(shares > 0, "ZeroShares"); -
I-05 Informational Redundant Fee Initialization Informational Acknowledged
Description
In HypervisorNFPM, the fee variable is initialized to 1 at declaration, and then set to 1 again in the constructor. Since both values are the same, the initial assignment is unnecessary. The same redundant pattern is present in the MultiFeeDistribution contract.
Recommendation
Remove the redundant fee = 1 assignment and allow the constructor to handle the initialization.
-
I-06 Informational Liquidity May Not Fit Into
uint128Informational AcknowledgedDescription
Whenever the Hypervisor is minting new liquidity, it should be a number fitting in
uint128. However, it's possible to have it overflow this type, especially when the price is large. In this case, the Hypervisor can be put in a state where all of its liquidity adding functions are blocked -deposit()whendirectDeposit()is turned on,rebalance()because positions are unconditional and they spend the whole balance, etc...Recommendation
Currently,
decreaseLiquidity()can be used to pull liquidity out of the positions if needed, but also consider adding more granular control torebalance(), i.e not using the whole balance, but part of it or having optional positions. -
I-07 Informational Outdated Comments Documentation Acknowledged
Description
There are some outdated comments in
MultiFeeDistribution. For example, it's stated that staked tokens cannot be withdrawn fordefaultLockDuration, but there is no such functionality implemented.Recommendation
Consider refactoring the comments.
-
I-08 Informational Position Manager Can Hold Its Tokens Unexpected Behavior Acknowledged
Description
MultiPositionManager.deposit()doesn't allow specifying the position manager contract itself as a recipient, but the ERC20transfer()function is left unchanged which allows direct transfers to the contract. This breaks the expectation that the contract will not hold its tokens.Recommendation
If the goal is for the contract to never hold the tokens, override the transfer function and revert if the recipient is
MultiPositionManager. -
I-09 Informational Ambiguous Error Handling Error Acknowledged
Description
MultiPositionManager.deposit()reverts withZeroAddress()error if thetoparameter is equal toaddress(0), but also if it's equal toaddress(this), which can be deceiving for integrators.Recommendation
Consider using a more general error, like
InvalidAddress().
Remediation Review
9 findings · July 7, 2025-
H-01 High Deposit slippage protection is not sufficient MEV Acknowledged
Description
The recently added slippage protection in
UniProxyV2is insufficient to protect users against the issue described in the originalNo Share Slippage Protection in depositreport.Although the check now applies the
maxSlippageparameter to the result of the_calculateExpectedShares()function, the shares calculation logic is identical toMultiPositionManager._calcSharesAndAmounts().As a result, if the pool's token supplies are manipulated, the expected shares returned by
_calculateExpectedShares()will track the manipulated state. This causes the slippage check to always pass, even when the user receives significantly fewer shares than expected under normal pool conditions.Recommendation
Consider giving the user the ability to specify the
minSharesparameter themselves. -
H-02 High Whitelisted Address Bypasses Crucial Checks Logical Error Acknowledged
Description
In UniProxyV2.deposit, the clearDeposit function is skipped entirely for whitelisted addresses:
if (!clearance.getListed(pos, msg.sender)) { clearance.clearDeposit(to, pos); }While the intention may be to bypass unnecessary restrictions for trusted users or contracts, this also skips critical depositor protections, introducing several risks:
checkTicksis crucial to ensure the pool was not manipulated prior to deposit. Skipping this check could result in the depositor receiving far less shares than expected, losing funds in the process (fix for H-02)- The depositor could mistakenly deposit into a wrong or inactive position as the
onlyAddedPositioncheck is skipped - The depositor can deposit while
ClearingV3is paused
Recommendation
Do not skip
clearDepositentirely for whitelisted depositors. Instead, follow the approach used in HypervisorNFPM, where whitelisted users still invokeclearDeposit, but the function internally returns early after performing only the critical safety checks.This may be less meaningful for MultiPositionManager, but could be useful if there are future checks added to
clearDepositthat should only apply to non-whitelisted depostiors. -
H-03 High checkTicks Should Exclude Limit Positions Logical Error Acknowledged
Description
The
_checkTicksfunction is intended to block deposits when the pool’s current tick has been manipulated outside the vault’s intended tick range. It does this by checking that the current tick lies within the lowest and highest ticks of all active positions returned bygetPositions.However,
getPositionscurrently includes non-empty limit positions, which in some vaults are configured with extremely wide ranges (e.g., covering the entire tick range). This was observed in a production rebalance script shared by the team (limitWidth = 10000000).As a result, if the min/max tick range becomes too wide, the tick check becomes effectively disabled, allowing attackers to manipulate pool price to any extreme while still passing the check.
This reopens the vault to pool manipulation attacks (e.g. H-02 style).
Recommendation
Exclude all limit positions when calculating the tick bounds in
_checkTicks. Only consider base positions, which are typically narrower and represent intended liquidity deployment -
M-01 Medium Incorrect farming check DoS Acknowledged
Description
A new
isEmergencyWithdrawActivated()check has been added toHypervisorNFPM._approveAndEnterFarming()to not try to enter the farming if the emergency mode is activated.if (incentiveId != bytes32(0) && farmingCenter.eternalFarming().isIncentiveDeactivated(incentiveId) && farmingCenter.eternalFarming().isEmergencyWithdrawActivated()) { return; }However, the check incorrectly uses
&&which will execute the return only if all of the conditions are true. Deactivating a given incentive and activating the emergency withdrawal mode for the eternal farming are two independent actions. In result, not only the previous issue was not solved, but a new one was created - the code will callenterFarming()even when the incentive is deactivated.Recommendation
You can create a separate check which returns if
isEmergencyWithdrawActivated() == true -
M-02 Medium _detectStuckRewards can block staking DoS Resolved
Description
The
_detectStuckRewardsfunction checks whether theHypervisorNFPMcontract has any pending rewards or bonus rewards. If rewards are detected, it returns true. This result is used in the_stakefunction of theMultiFeeDistributioncontract to enforce a check. If_detectStuckRewardsreturns true, staking reverts.In most cases, this should not occur because
_stakefirst calls_updateReward, which should collect all outstanding rewards. However, the_updateRewardfunction internally calls_collectAndClaim, which first checks for a valid incentive key before proceeding. If no valid key exists, the function returns early without collecting or claiming rewards.There are scenarios where a position may not have a valid incentive key but still hold unclaimed rewards. For example, if
decreaseLiquidityremoves all liquidity from a position, it exits farming through_exitFarmingwithout collecting or claiming rewards. Similarly, if an incentive ends or emergency withdrawal is activated and a user adds liquidity directly in Algebra, the farming state is reset and the incentive key becomes invalid. In both cases, the rewards remain unclaimed even though the position is no longer eligible.As a result,
_detectStuckRewardsreturns true, which causes the_stakefunction to revert. This prevents users from staking in theMultiFeeDistributioncontract.Recommendation
Update _collectAndClaim to collect any remaining rewards regardless of incentive key validity, or adjust _detectStuckRewards to account for scenarios where rewards are no longer claimable due to expired or exited farming positions.
-
L-01 Low Zero interval TWAP still returns 0 Unexpected Behavior Acknowledged
Description
The
getSqrtTwapX96()function is still returning 0 when_twapInterval == 0, because thesqrtPriceX96variable is shadowed.function getSqrtTwapX96(address pos, uint32 _twapInterval) public view returns (uint160 sqrtPriceX96) { if (_twapInterval == 0) { /// return the current price if _twapInterval == 0 (uint160 sqrtPriceX96, , , , , , ) = IHypervisor(pos).pool().safelyGetStateOfAMM(); } ... }The inner
sqrtPriceX96is a separate variable which lives only in the scope of the if statement. As a result, the function will keep returning 0.Recommendation
Instead of creating a new variable, assign the value to the current one.
function getSqrtTwapX96(address pos, uint32 _twapInterval) public view returns (uint160 sqrtPriceX96) { if (_twapInterval == 0) { /// return the current price if _twapInterval == 0 - (uint160 sqrtPriceX96, , , , , , ) = IHypervisor(pos).pool().safelyGetStateOfAMM(); + (sqrtPriceX96, , , , , , ) = IHypervisor(pos).pool().safelyGetStateOfAMM(); } } -
L-02 Low Possible Zero Amount Transfer Logical Error Acknowledged
Description
In
_transferIn, zero amount transfers do not return early. This was omitted after the fix to issue L-26. As a result, if the token happens to revert on zero transfers, single-sided liquidity deposits will be blocked.Recommendation
function _transferIn(address from, Currency currency, uint256 amount) internal { if (currency.isAddressZero()) { if (msg.value < amount) revert InvalidDepositAmount(amount); if (msg.value > amount) payable(msg.sender).transfer(msg.value - amount); - } else if (amount != 0) { IERC20(Currency.unwrap(currency)).safeTransferFrom(from, address(this), amount); } } -
I-01 Informational
token1can be set asaddress(0)Informational AcknowledgedDescription
The
[I-03] Missing Zero Address Check in Constructorfinding was marked as fixed, but there isn't a check added fortoken1in the constructor of theMultiPositionManager.Recommendation
Consider adding the check.
-
I-02 Informational
PRECISIONis no longer used Best Practices AcknowledgedDescription
Once the price overflow issue was fixed in
ClearingV3NFPM.sol, thePRECISIONconstant is no longer needed.Recommendation
Consider removing it.
No findings match.
More from Gamma Strategies
All 7 reports-
Unilaunch Launchpad and Limit Order Book
28 findings8 high 28 findings: 8 high, 8 medium, 5 low, 7 informational -
MultiPositionManager
83 findings1 high 83 findings: 1 high, 25 medium, 22 low, 35 informational -
Limit Order Manager
19 findings2 high 19 findings: 2 high, 17 low -
PerpetualVault Mitigation Review
24 findings 24 findings: 7 medium, 17 low
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
