Organization
- @sololabs
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Low Risk
3 findings
2 fixed
1 acknowledged
Informational
5 findings
4 fixed
1 acknowledged
Low Risk3 findings
Use safeTransferFrom to avoid cases for certain tokens which can lead to loss of funds
State
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
Om Parikh
Description
some tokens return
falseinstead of reverting, this would use funds reserved for sweeping iftransferFromuser toaddress(this)fails for some tokens.see:
Recommendation
FullRangeLiquidityManageshould usesafeTransferinstead oftransfer.Solo Labs: Fix can be found in commit d8e596.
Cantina Managed: Fix reviewed.
Round fees against the swapper to avoid potential zero-fee and precision edge cases
State
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
Om Parikh
Description
In
_beforeSwapand_afterSwaphooks, the fees are rounded in direction of swapper (against LPs & protocol), this allows to pay significantly lower fees or zero fees in case of lower precision tokens (such as GUSD, WBTC).Also, this will pass zero or lowered dynamic fees to
PoolManagerwhich harms the LPs.Recommendation
Consider using
mulDivRoundingUpinswapFeeAmount&hookFeeAmountand also add / update the test cases, this also aligns with the uniswap's pool behaviour.Solo Labs: Fixed in 6dee71.
Cantina Managed: Fix verified.
Hook fees are preemptively charged on full amountSpecified instead of actual used amount
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Om Parikh
Description
In
Spot._beforeSwap, ifsqrtPriceLimitX96specificed in swap params is reached during thePoolManager.swapthen fullamountSpecifiedwill not be used.However, the hooks fees are currently charged on
params.amountSpecifiedwhich implies whenever swap is limited bysqrtPriceLimitX96, users always pays more than required. The differential can be large in certain cases where amount specific is very high and it is expected to always swap upto limit price.Recommendation
- take a cut in
_afterSwap, but at that time input token would have been used (either partially or fully), so it might require taking fees in output token which is not 1:1 with pool's behaviour. - try simultaing a swap in before swap with
StepComputationsandSwapResultsimilar to core's swap function, this might be gas intensive and still would require passing some fee value for carrying out simulation. - revert if exact input swap was limited by
sqrtPriceLimitX96, this may significantly reduce the usability of a hook.
Solo Labs: At this time we will not be altering this logic, we will clarify our documents to make sure it is specified this is how the pool works. Simulating a swap will consume too much gas per swap for it to make that option worth it, and we would like to keep the behavior as close to possible as we can with V3. We will keep an eye on our issue we raised in their repository and see if we could improve on this in a way that keeps existing behavior and doesn't involve simulating a swap.
Cantina Managed: Acknowledged.
Informational5 findings
Consistency with Uniswap V3 oracle when writing observations
State
Severity
- Severity: Informational
Submitted by
Jonatas Martins
Description
In Uniswap V3, the oracle only writes observations when
ModifyLiquidityParams.liquidityDelta != 0. In Uniswap V4, when adding zero liquidity, the_beforeRemoveLiquidityhook is still called, Hooks.sol#L203.There is no direct impact other than consistency, as there is already a check preventing multiple observations from being added in the same block.
Recommendation
Check for
liquidityDelta != 0in the_beforeRemoveLiquidityfunction to maintain consistency with Uniswap V3.Solo Labs: Fix can be found in commit c12282c.
Cantina Managed: Fix reviewed.
Code improvements
State
Severity
- Severity: Informational
Submitted by
Jonatas Martins
Description and Recommendation
This finding is a list o code improvements and the recommendations:
- Use constants instead of literal numbers for better consistency. Replace the value
10_000_000withMAX_SURGE_FEE_MULTIPLIER_PPMin the revert error within thePoolPolicyManager.setSurgeFeeMultiplierPpmfunction. - Simplify the check in the
PoolPolicyManager.setBaseFeeFactorandPoolPolicyManager.setPoolDailyBudgetPpmfunctions, as the variable will always be!= 0in this condition, making the< 1check redundant:
- if (factor != 0 && (factor < 1 || factor > MAX_BASE_FEE_FACTOR_PPM)) {+ if (factor != 0 && factor > MAX_BASE_FEE_FACTOR_PPM) { revert Errors.ParameterOutOfRange(factor, 1, MAX_BASE_FEE_FACTOR_PPM);}- if (newBudget != 0 && (newBudget < 1 || newBudget > 10 * PrecisionConstants.PPM_SCALE)) {+ if (newBudget > 10 * PrecisionConstants.PPM_SCALE) { revert Errors.ParameterOutOfRange(newBudget, 1, 10 * PrecisionConstants.PPM_SCALE);}- Remove unused variables & imports. Exact locations can be checked via
forge cache clean && forge build(on latest foundry version)
Solo Labs: Fixes can be found in the commits c12282c, 9b1acc3 and db03770
Cantina Managed: Fix reviewed.
Spot contract lacks virtual keyword for function
State
Severity
- Severity: Informational
Submitted by
Jonatas Martins
Description
The
Spotcontract has been changed to be a base contract and to be inherited, changing most functions back to virtual. However, it still lacks the necessary change to make thegetHookPermissionfunction compatible with these new changes.Recommendation
Add the
virtualmodifier to thegetHookPermissionfunction.Solo Labs: Fixed in commit 23b90f5
Cantina Managed: Fix reviewed.
Default max tick per block might never be used
State
Severity
- Severity: Informational
Submitted by
Jonatas Martins
Description
In
PoolPolicyManager, whendefaultMaxTicksis 0, the contract usesDEFAULT_MAX_TICKS_PER_BLOCKinstead. However, after pool initialization, this value is set to at least 1. The check that reverts whendefaultMaxTicksequals 0 prevents the owner from setting it back to 0 to use the default value. As a result, theDEFAULT_MAX_TICKS_PER_BLOCKvariable is only used before the pool is initialized.Recommendation
Consider either allowing
defaultMaxTicksto be set to 0 or removing both theDEFAULT_MAX_TICKS_PER_BLOCKconstant and its relatedsetfunctions. Note that this change would affect how the value 0 is handled when the pool is not initialized—it should never be used for non-initialized pools.Solo Labs: Fixed in the commit 7bc8161
Cantina Managed: Fix reviewed.
A large swap could be broken into smaller chunks to escape capping in perSwapMode
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Om Parikh
Description
one can always break total swap amount into "n" chunks such that resulting price impact and fees are same as single large swap (except rounding), so one can always prevent capping by swapping size such that movement is
maxTicks - 1.due to this,
updateCapFrequencywill return early. This imposes relatively large risks to illiquid & low volume pools (such as PEPE-SHIBA).If done so, it would have following consequences:
- cap event won't be triggered even if full large swap is carried out atomically. This benefits all subsequent swappers, including arbitrage and MEV bots who will get better execution.
- it allows decaying and reducing frequency where it ideally shouldn't
Recommendation
Consider documenting this behavior for pools using
perSwapModeand risks for liquidity providers.Solo Labs: Acknowledged.
Cantina Managed: Acknowledged.