M0 engaged Guardian to review the security of their M0's Uniswap V4 Hooks. From the 2nd of June to the 4th of June, a team of 5 auditors reviewed the source code in scope.
- Published
- Review window
- June 2 to 4, 2025
- Language
- Solidity
- Chains
- Ethereum, Arbitrum, Optimism, Unichain
- Sector
- DEXs and AMMs
- 0 Critical
- 0 High
- 3 Medium
- 13 Low
- 0 Informational
Scope
Overview
M0 engaged Guardian to review the security of their M0's Uniswap V4 Hooks. From the 2nd of June to the 4th of June, a team of 5 auditors reviewed the source code in scope.
Findings 16
-
M-01 Medium Tick Range Update Could Lead To Pool DoS Validation Acknowledged
Description
In the
TickRangeHook, liquidity providers are restricted to adding liquidity within an allowed tick range. For example, let's imagine that the pool operates with a current tick of 5 and the allowed tick range is set to [0, 10].This means LPs have provided liquidity only between ticks 0 and 10 and the pool functions normally within this range. Then, the
setTickRange(20, 30)function is called by a manager. This updates the allowed tick range to [20, 30], while the current tick remains at 5.After this update, LPs can only add new liquidity within the new range of [20, 30]. However, in case there is a big amount of liquidity in the ticks between 5 and 20, the
slot0.tickwill not reach the new lower bound (20) unless the swap atomically consumes all the liquidity placed between the ticks 5 and 20.Therefore, such
setTickRangeupdate, could cause a Denial of Service in the Uniswap V4 pool unless the liquidity providers remove the liquidity from those ticks (5 to 20) after the update. There is no guarantee that this will occur.Recommendation
Consider updating the
_afterSwapimplementation to allow a swap as long as the newgetTickAtSqrtPrice(slot0.sqrtPriceX96)resultant tick after the swap is closer to the pertinent new range bound.This should only be allowed for swaps that starts on a tick that is already out of the valid range.
Resolution
M0 Team: In our case, we plan to only have liquidity between tick range [0,1] , so the described scenario should not occur. We are currently debating if we should keep the tick range check since there can be some undesirable side effects like the one described in this issue.
-
M-02 Medium Missing Domain Separator Validation Acknowledged
Description
In the
AllowlistHook._beforeSwapfunction, theencodeSigAndArgsmethod encodes parameters for the Predicate's authorization process. However, this encoding does not include a domain separator or any chain-specific field, such as the chain ID and, therefore, signatures generated for a swap on one chain could potentially be reused on another chain where the Uniswap V4PoolManagerand Predicate'sServiceManagercontracts are deployed.If the same addresses and parameters are valid across chains, this could lead to unauthorized swaps. The current implementation of
encodeSigAndArgsis as follows:bytes memory encodeSigAndArgs_ = abi.encodeWithSignature("_beforeSwap(address,address,address,uint24,int24,address,boo l,int256)", caller_, key_.currency0, key_.currency1, key_.fee, key_.tickSpacing, address(key_.hooks), params_.zeroForOne, params_.amountSpecified);This encoding includes the caller, pool key details (currencies, fee, tick spacing, hooks address) and swap parameters (direction and amount), but lacks any identifier tying the signature to a specific chain. In cross-chain environments, this omission is a significant risk, as signatures could be replayed on unintended chains where the same contract addresses and parameters exist.
Recommendation
To mitigate the risk of cross-chain signature reuse, modify the
encodeSigAndArgsfunction to include a domain separator. At a minimum, add the chain ID to the encoded parameters to ensure signatures are chain-specific. An updated version could look like this:bytes memory encodeSigAndArgs_ = abi.encodeWithSignature("_beforeSwap(uint256,address,address,address,uint24,int24,add ress,bool,int256)", block.chainid, caller_, key_.currency0, key_.currency1, key_.fee, key_.tickSpacing, address(key_.hooks), params_.zeroForOne, params_.amountSpecified);For a more robust solution, consider adopting a full EIP-712-compliant domain separator.
Resolution
M0 Team: Acknowledged.
-
M-03 Medium Token Sorting Not Accounted Logical Error Resolved
Description
From the deployment config, it appears that the M0 team intends to set the tick lower and upper bounds as
0to1. This corresponds to a price range where token0 is valued between1e18and1.0001e18token1. However, since Uniswap determinestoken0andtoken1based on lexicographic address sorting, it is not guaranteed that wrapped M will always be assigned astoken0.If wrapped M becomes
token1, then the same tick range (0 →1) would apply to the inverse price (token1/token0), flipping the interpretation. In that case, the tick range0 →1would represent a price range of0.999999e18to1e18wrapped M per 1 USDC, which is likely the opposite of the intended bound. As per M0’s config, they aim to deploy wrappedM:USDC pools across 4 chains:address public constant USDC_ETHEREUM = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; address public constant USDC_ARBITRUM = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; address public constant USDC_OPTIMISM = 0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85; address public constant USDC_UNICHAIN = 0x078D782b760474a361dDA0AF3839290b0EF57AD6;Given this, token sorting across the pairs is as follows:
Chain token0 token1
Ethereum 0x437c… (wrapped M) 0xA0b8… (USDC)
Arbitrum 0x437c… (wrapped M) 0xaf88… (USDC)
Optimism 0x0b2C… (USDC) 0x437c… (wrapped M)
Unichain 0x078D… (USDC) 0x437c… (wrapped M) For Ethereum and Arbitrum, wrapped M remains
token0, so the tick range0 →1works as expected. However, for Optimism and Unichain — where wrapped M becomestoken1— this tick range binds the price of wrapped M from ~0.999999 to 1 USDC, effectively inverting the bound and constraining the price in the wrong direction.Recommendation
Consider making the deployment script dynamic — accounting for token order and flipping tick ranges accordingly — or be aware of this possibility and choose tick ranges manually based on token sorting
Resolution
M0 Team: Resolved.
-
L-01 Low Unused PositionManagerStatus.REDUCE_ONLY Warning Resolved
Description
In the
AllowlistHookcontract, thePositionManagerStatusenum defines three possible states for position managers:ALLOWED,REDUCE_ONLY, and an implicitFORBIDDEN(when no status is explicitly set). However, the contract’s logic for controlling liquidity addition only checks if the sender’s status isALLOWED. The relevant code in the_beforeAddLiquidityfunction is:if (_positionManagers[sender_] = PositionManagerStatus.ALLOWED) {revert PositionManagerNotTrusted(sender_);}This check means that any position manager not explicitly marked as
ALLOWED, including those withREDUCE_ONLYstatus, will be rejected when attempting to add liquidity, effectively treatingREDUCE_ONLYthe same asFORBIDDEN. TheREDUCE_ONLYstatus, which likely intends to allow position managers to remove liquidity but not add it, is not leveraged in any meaningful way within the hook.Furthermore, since the hook does not implement a
beforeRemoveLiquidityfunction, liquidity removal is not restricted by this status either. As a result, the distinction betweenREDUCE_ONLYand other non-ALLOWED states is redundant, adding unnecessary complexity to the contract.Recommendation
To simplify the
AllowlistHookcontract and eliminate the underutilizedREDUCE_ONLYstatus, replace thePositionManagerStatusenum with a straightforward boolean mapping, such asmapping(address > bool) public isAllowedLPer. This mapping would store whether a position manager is permitted to add liquidity. The updated check in_beforeAddLiquiditywould then be:if (isAllowedLPer[sender_]) {revert PositionManagerNotTrusted(sender_);}This change preserves the current functionality, only allowing trusted position managers to add liquidity, while reducing the contract’s complexity and lowering gas costs by eliminating the need for an enum.
Resolution
M0 Team: Resolved 15
-
L-02 Low Potential For Front-running & Griefing Warning Acknowledged
Description
The
BaseTickRangeHookcontract enforces a specific tick range ([tickLowerBound, tickUpperBound]) for swaps via the_afterSwapfunction.This function reverts any swap that causes the current tick to fall outside the designated range, ensuring trading occurs within a predefined price window. However, because of this requirement any swap can be front-run or griefed by another swap.
For example:
- A legitimate swap is submitted, which, if executed, would move the current tick to a value still
within the allowed range.
- An attacker observes this pending swap in the mempool and submits a small, preemptive swap
that shifts the current tick closer to the boundary of the allowed range (e.g., near
tickLowerBoundortickUpperBound).- When the legitimate swap executes, it builds on the pool state altered by the attacker's swap,
pushing the current tick just outside the permitted range. This triggers a revert, preventing the legitimate swap from completing.
This allows an attacker to block and grief legitimate swaps by manipulating the pool’s tick position.
Recommendation
Merely an informative issue as this is a direct consequence of the intended design.
Resolution
M0 Team: This is by design and it is indeed possible that transactions may revert if a previous one shifts the current tick closer to the boundary.
-
L-03 Low ZeroForOne Swap Logic Breaks Tick–Price Assumption Warning Acknowledged
Description
As per Uniswap's swap logic, when
result.sqrtPriceX96 = step.sqrtPriceNextX96at the end of a swap step, and the direction iszeroForOne, the protocol sets the current tick totickNext - 1. This behavior is a known quirk in Uniswap's design and is documented in M-02 of this Certora audit report.M0’s
afterSwapincludes a validation step on the current tick. However, in thezeroForOnescenario described above,slot0.tickis set totickNext - 1, which may not matchgetTickAtSqrtPrice(sqrtPriceX96)when the swap ends exactly at a tick boundary.Example scenario:
- Initial setup:
sqrtPriceX96 = SSSSSand hook tick range is [0, 1] - A swap from
token1 →token0for amount X moves the price slightly above tick 0 - A reverse swap
(token0 →token1)for the same amount X brings the pool back tosqrtPriceX96 =
SSSSS- However, the current tick is now -1, not 0, due to Uniswap's internal handling in
zeroForOneswaps
As a result, M0’s
afterSwaplogic—which asserts the current tick to be ≥ 0—reverts, even though the price is correct and in-range. This creates an unintuitive edge case: preventing full symmetric swaps. This leads to:- Unexpected reverts in contracts assuming full liquidity can be consumed
- Confusion for integrators: e.g., an M0 integrator sees liquidity X in the pool and attempts a full
swap, but it fails due to this subtle tick mismatch
Recommendation
Document this edge case explicitly for integrators relying on symmetric swap behavior or full-range liquidity visibility. For the stated example, swap goes through for X-1. Alternatively, you could consider deriving the current tick directly from
sqrtPriceX96usinggetTickAtSqrtPriceinafterSwaphook. This will allow full use of liquidity; however, this would settick = -1for 0 to 1 tick range caseResolution
M0 Team: Acknowledged
- Initial setup:
-
L-04 Low Predicate message omits sqrtPriceLimitX96 Validation Acknowledged
Description
AllowlistHook._beforeSwapbuilds the payload that off-chain operators must sign as follows:bytes memory encodeSigAndArgs_ = abi.encodeWithSignature("_beforeSwap(address,address,address,uint24,int24,address,bool,int25 6)", caller_, key_.currency0, key_.currency1, key_.fee, key_.tickSpacing, address(key_.hooks), params_.zeroForOne, params_.amountSpecified);The
sqrtPriceLimitX96field (present inIPoolManager.SwapParams) is not part of the signed data and therefore:- In exact-in swaps
(amountSpecified < 0), the signer approves spending a fixed input amount but has no
guarantee on the minimum output.
- In exact-out swaps
(amountSpecified > 0), the signer approves delivering a fixed output amount but does
not cap the maximum input. A price spike before execution can force the caller to pay far more than the signers deemed acceptable.
- Because the task hash lacks any price limit, the user can use a still-valid signature later (until
expireByBlockNumber) when the price has shifted, while remaining within the signedamountSpecified.While the end-user’s router usually enforces its own
amountOutMin/amountInMaxto ensure the swap meets the user’s minimum expectations, these protections are separate from the Predicate layer’s role. The Predicate layer, which uses operator signatures to authorize swaps, does not includesqrtPriceLimitX96in the signed data.This omission means the signatures do not enforce any price constraints, allowing swaps to execute at uncontrolled prices despite the authorization. As a result, the Predicate layer fails to uphold the economic conditions (such as acceptable price or slippage) that the signers intended, weakening its effectiveness as a policy guard.
Recommendation
Bind the signature to an explicit price or slippage limit by including
sqrtPriceLimitX96in the encoded arguments:bytes memory encodeSigAndArgs_ = abi.encodeWithSignature("_beforeSwap(address,address,address,uint24,int24,address,bool,int25 6)", caller_, key_.currency0, key_.currency1, key_.fee, key_.tickSpacing, address(key_.hooks), params_.zeroForOne, params_.amountSpecified, params_.sqrtPriceLimitX96 // NEW FIELD);Resolution
M0 Team: Acknowledged.
- In exact-in swaps
-
L-05 Low Allow/Deny-list Depends On msgSender() Warning Acknowledged
Description
To identify the ultimate user, the hook queries the router that invoked it:
address caller_ = IBaseActionsRouterLike(sender_).msgSender();This is the expected approach as documented in the Uniswap V4 docs. If a malicious router is mistakenly placed in the trusted-router mapping (
_swapRoutersor_positionManagers), it can return any address it chooses.That forged caller_ will:
- Pass the liquidity-provider or swapper allow-list checks even when the real user is not authorized.
- Be embedded in the Predicate message, misleading off-chain policy signers.
Thus the entire allow/deny mechanism is only as strong as the set of routers that are granted “trusted” status.
Recommendation
Ensure that only audited, non-modifiable and well-known router implementations are ever whitelisted.
Resolution
M0 Team: This is by design and we will ensure that only audited and open source routers are added to the allowlist.
-
L-06 Low Tick Range Risk Due To Paired Asset Volatility Warning Acknowledged
Description
Even if the price of
wrappedMremains constant and within the bounds the M0 team expects, there is no guarantee that the price of the paired asset (USDC) won’t deviate.If USDC depegs or appreciates, it can push the pool outside of the configured tick range—even if the price of
wrappedMremains within the intended bounds.Recommendation
Be aware of this scenario and be prepared to rebalance the pool if necessary.
Resolution
M0 Team: Acknowledged. We will update the tick range if necessary.
-
L-07 Low Pectra Upgrade Enables EOAs Validation Acknowledged
Description
The
AllowlistHook UniswapV4hook is designed to enforce strict access control by restricting token swaps and liquidity provisioning to trusted addresses listed in_swappersAllowlistand_liquidityProvidersAllowlist, respectively.This mechanism ensures that only authorized entities can interact with the pool to swap and add liquidity. However, the Ethereum Pectra upgrade, particularly through
EIP-3074, introduces a significant concern in regards to both allowlists.EIP-3074enables Externally Owned Accounts (EOAs) to execute smart contract code within a single transaction, allowing them to act as proxies for other addresses. This capability compromises the hook’s ability to restrict actions to trusted parties.Specifically, a malicious EOA that is already present on either the
_swappersAllowlistor the_liquidityProvidersAllowlistcould delegate its execution to a smart contract that calls the Uniswap V4PoolManager’s respective pool to perform a swap or liquidity addition on behalf of an untrusted address, in exchange for a fee.When the
AllowlistHookchecks the caller, it recognizes the trusted EOA and approves the transaction, allowing the untrusted address to bypass the allowlist restrictions.Recommendation
Consider monitoring the EOAs whitelisted in the hook and remove them from the allowlists if they show this behaviour.
Resolution
M0 Team: Our team will monitor pools and ensure that addresses added to the allowlist will not perform swaps or liquidity additions for third parties.
-
L-08 Low Potential Front-run DoS Validation Acknowledged
Description
ServiceManager.validateSignatures()rejects a task if its identifier has already been consumed:require(spentTaskIds[_task.taskId], "Predicate.validateSignatures: task ID already spent");spentTaskIdsis declared once and shared by every client that relies on the sameServiceManagerinstance:mapping(string > bool) public spentTaskIds; // global scopeBecause the key is only the free-form taskId string, any operator that is authorized to call
validateSignatures()can front-run another client’s legitimate transaction, submit the sametaskIdfirst and set the flag to true. The second, honest call then reverts with “task ID already spent”, blocking the legit user’s action.The
AllowlistHookuses:authorizeTransaction → ServiceManager.validateSignatures()for every swap or liquidity change when
isPredicateCheckEnabled = true.A malicious or compromised operator therefore has a trivial denial-of-service vector against all pools that use the hook.
Recommendation
Isolate replay-protection per client rather than globally in the ServiceManager contract:
// Predicate-contracts: ServiceManager.sol // before mapping(string > bool) public spentTaskIds; // aftermapping(address > mapping(string > bool)) public spentTaskIds; // key-scoped// usage require(spentTaskIds[_task.msgSender][_task.taskId], "Predicate.validateSignatures: task ID already spent"); spentTaskIds[_task.msgSender][_task.taskId] = true;By including the originating contract address (or alternatively the
policyID) in the key, one client can no longer “burn” another client’s taskId, removing the griefing vector without altering external behaviour for honest users.Resolution
M0 Team: Acknowledged.
-
L-09 Low TickRangeHook Does Not Support Multiple Pools Warning Acknowledged
Description
The
BaseTickRangeHookcontract in Uniswap V4 currently defines a single pair of state variables,tickLowerBoundandtickUpperBound, to enforce tick range restrictions for operations like liquidity provision or swaps. This design implies that the hook is intended to serve a single Uniswap V4 pool, as these tick bounds are global and not tied to any specific pool.Therefore, the current expectation is that a new instance of
BaseTickRangeHookshould be deployed for each pool requiring a unique configuration. This approach works but is inefficient, as it increases deployment costs and scatters logic across multiple contract instances.A more elegant solution exists: by leveraging the poolId, a unique identifier for each Uniswap V4 pool, the hook could manage pool-specific configurations within a single contract. This would allow multiple pools to use the same hook while maintaining independent tick bounds and other settings, such as allowlists.
Recommendation
Consider updating the
BaseTickRangeHookcontract to make its state variables pool-specific by incorporating the poolId into the storage design. This would enable a single hook instance to support multiple pools, each with its own tailored tick bounds and configurations. Specifically: Replace the globaltickLowerBoundandtickUpperBoundwith mappings keyed bypoolId:mapping(bytes32 > int24) public tickLowerBounds; mapping(bytes32 > int24) public tickUpperBounds;Extend this pattern to other state variables, such as allowlists:
mapping(bytes32 > mapping(address > bool)) public allowLists;Adjust the hook's internal logic to fetch pool-specific settings based on the
poolIdprovided in hook calls (e.g.,_beforeAddLiquidityor_afterSwap):function _beforeAddLiquidity(bytes32 poolId, ...) internal view {int24 lower = tickLowerBounds[poolId]; int24 upper = tickUpperBounds[poolId]; // Apply pool-specific tick range checks}This redesign allows a single
BaseTickRangeHookcontract to manage multiple pools, reducing deployment overhead and centralizing logic while ensuring each pool operates with its own independent settings.Resolution
M0 Team: Acknowledged. 23
-
L-10 Low WRAPPED_M Missing On Unichain Warning Acknowledged
Description
The
WRAPPED_Maddress defined inConfig.solfor Unichain does not point to a deployed contract. As a result, any attempt to create pools involving this token will fail.Recommendation
Deploy a valid
WRAPPED_M ERC-20contract at the specified address on Unichain, or updateConfig.solto reference a valid address before proceeding with any pool creation.Resolution
M0 Team: We will deploy Wrapped M to Unichain at the same address than other networks before deploying hooks and pools on this network.
-
L-11 Low Donate() Not Guarded By Hook Logic Warning Acknowledged
Description
The
AllowlistHookenforces strict access control on liquidity provision, requiring addresses to be explicitly approved before they can add liquidity to the pool.However, the pool’s
donatefunction bypasses these restrictions, allowing anyone—including unapproved or blacklisted addresses—to send tokens directly into the pool.While this action does not increase the pool's liquidity, it does add to the fee balance available to existing liquidity providers.
Recommendation
Consider whether this behavior is acceptable within the intended threat model. If not, use the
beforeDonatehook to block all donation attempts.Resolution
M0 Team: Resolved.
-
L-12 Low Pools Can Be Initialized With An Out-Of-Range Tick Warning Acknowledged
Description
When a pool is initialized in Uniswap V4 with a
sqrtPriceX96that translates to an initial tick outside the allowed range, defined bytickLowerBoundandtickUpperBound, it creates significant problems for liquidity providers.Liquidity providers can only provide liquidity within the predefined range of
[tickLowerBound,tickUpperBound]. If the pool starts with a tick beyond these bounds (e.g., abovetickUpperBound), their liquidity positions are immediately out of range.As a result, LPs are forced to deposit only one token (e.g.,
token1) instead of a balanced mix of both tokens, since the price is already outside their specified range. This one-sided exposure increases their risk, leaving them vulnerable to price movements in one direction without the offsetting balance of holding both assets.Let’s imagine the scenario where the pool is initialized at tick 20,
tickLowerBoundis set to 0 andtickUpperBoundto 10. Multiple liquidity providers provide liquidity right away, some in the 0,1 range, others in the 6,9 range etc.These liquidity providers will obviously provide one sided liquidity as the current
slot0.tickis outside of the range where they are allowed to provide by theTickRangeHookand where they actually provided liquidity. Consequently, they are very exposed to impermanent lost, in this concrete case, especially the ones that deposited in ranges closer to thetickLowerBound.Recommendation
Consider using the
_afterInitializehook in theBaseTickRangeHookcontract to verify that the initial tick falls within the allowed range[tickLowerBound, tickUpperBound)after pool initialization.If the tick is outside this range, the process should revert with a descriptive error, such as
InitialTickOutOfRange. This constraint ensures the pool always starts in a state where LPs can provide balanced liquidity, earn fees and avoid the risks of one-sided exposure.Resolution
M0 Team: The initial tick at deployment is not enforced to allow market makers to create single sided 26 liquidity positions once the pool is created and then allow them to swap and bring the liquidity in range.
-
L-13 Low Pool Price Is Initialized At The Lower Bound Warning Acknowledged
Description
Currently, the
Deploy.s.solscript deploys the Uniswap V4 pool as:IPoolManager(config_.poolManager).initialize(pool_, TickMath.getSqrtPriceAtTick(0));This sets the initial pool
sqrtPriceX96at the lower bound forcing initial liquidity providers to provide one sided liquidity, in this case, onlyWrappedMtokens.Recommendation
Unless this is intended, consider initializing the Uniswap V4 pool as:
IPoolManager(config_.poolManager).initialize(pool_, 79230143144055126352967237632);which corresponds to tick (0.5):
python import math def get_sqrt_price_x96(tick: float) -> int: Q96 = 2 ** 96 price = 1.0001 ** tick sqrt_price = math.sqrt(price) sqrt_price_x96 = int(sqrt_price * Q96) return sqrt_price_x96 tick = 0.5 result = get_sqrt_price_x96(tick) print(f"sqrtPriceX96 for tick {tick}: {result}") # 79230143144055126352967237632Resolution
M0 Team: Resolved
No findings match.
Invariants 10
The review's fuzzing suite asserted 10 invariants. 10 held.
Every invariant tested
| ID | Invariant | Result |
|---|---|---|
G-01 | Pool tick must be 0 or 1 after swap | Held |
AL-01 | Liquidity providers allowlist state mismatch | Held |
AL-02 | Swappers allowlist state mismatch | Held |
AL-03 | Liquidity provider allowlist status mismatch | Held |
AL-04 | Swapper allowlist status mismatch | Held |
AL-05 | Batch liquidity provider allowlist status mismatch | Held |
AL-06 | Batch swapper allowlist status mismatch | Held |
AL-07 | Batch swapRouter allowlist status mismatch | Held |
AL-08 | Batch position manager allowlist status mismatch | Held |
AL-09 | Batch position manager allowlist status mismatch | Held |
More from M0
All 10 reports-
Liquidity Delivery Updates
4 findings 4 findings: 1 low, 3 informational -
PYUSDX
21 findings 21 findings: 8 low, 13 informational -
Liquidity Delivery
59 findings3 critical · 5 high 59 findings: 3 critical, 5 high, 10 medium, 14 low, 27 informational -
M Extensions Updates
16 findings 16 findings: 1 medium, 5 low, 10 informational
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.
