Universal Hook engaged Guardian to review the security of their Universal Hook Contracts. From the 27th of November to the 5th of December, a team of 5 auditors reviewed the source code in scope.
- Published
- Review window
- November 27 to December 5, 2025
- Rounds
- Main Review, Remediation Review, Remediation Review V2
- Language
- Solidity
- Chains
- Base, Arbitrum, Katana
- Sector
- Tokens
- 0 Critical
- 0 High
- 5 Medium
- 15 Low
- 41 Informational
Scope
Overview
Universal Hook engaged Guardian to review the security of their Universal Hook Contracts. From the 27th of November to the 5th of December, a team of 5 auditors reviewed the source code in scope.
Findings 61
Main Review
44 findings-
M-01 Medium Users Can Swap Non-whitelisted uAsset For USDC Validation Resolved
Description
MintLimiterallows the Pauser Role to pauseuAssetsviapauseAsset(), which removes an asset from the set of whitelisteduAssets.The protocol documentation states the following regarding the flow of selling
uAssets:"The orderinput token is a valid whitelisted uAsset and the output token is USDC (token received by the user)".Once the token is paused, it can no longer be minted, thus
USDC->uTokensswaps will begin to fail. In addition, pools with the pauseduTokencannot be initialized, as thebeforeInitializehook enforces that theuTokenmust be whitelisted.However, there is nothing preventing swaps from a paused
uToken->USDCwithin the hook contract. In a situation where auAssetis removed from the whitelist, users holding this asset can still swap them into USDC, thus taking merchant USDC and breaking the intended flow of sellinguAssets.Recommendation
Check if the input
uAssetis whitelisted within the_beforeSwaphook. Also consider checking if the outputuAssetis whitelisted, in case the hook owns anyERC6909of non-whitelisteduAssetwithinPoolManager,USDC→uAssetswaps will still be successful (which will allow users to continue purchasing non-whitelisteduAsset).Resolution
Universal Hook Team: Resolved.
-
M-02 Medium Blacklist Bypass Via Uniswap ERC6909 Unexpected Behavior Resolved
Description
WrappedAssetV2contains a blacklist mechanism which blocks certain users from transferring and receivinguAsset.However, there is a way blacklisted users can circumvent this. Consider a scenario where Bob is blacklisted from transferring/receiving
uBTC.- Bob swaps
USDC -> uBTC. Now the Hook contract will transfer uBTC to Uniswap'sPoolManager
contract.
- Instead of calling
PoolManager.take()(which will fail since Bob is blacklisted), Bob calls
PoolManager.mint(), which wraps theuBTCtoERC6909owned by Bob.- Bob can now freely transfer the
uBTCthrough Uniswap'sERC6909transfer functions. - If Bob swaps
uBTCback toUSDCat a later time, he simply has to burn theERC6909, where the
UniversalJitHookthen receives the assets viaPoolManager.mint().In the above scenario, Bob has successfully circumvented the blacklist by receiving
uBTC, freely transferring, and selling back forUSDCfreely at a later time.Recommendation
Uniswap encodes
msg.senderwhen calling thebeforeSwaphook. Consider reverting if the sender ortx.originis the address of a blacklisted address.Note that this does not prevent blacklisted addresses from receiving and then transferring
ERC6909version ofuAssets, but does prevent them from buying/selling these assets directly via theUniversalJitHook.Resolution
Universal Hook Team: Resolved.
- Bob swaps
-
M-03 Medium Swap Can Use Stale Prices Validation Resolved
Description
Proof of concept: PoC
When a swap happens, the before swap hook sets the virtual price based on the oracle report timestamp, to catch latest updates:
// If the oracle report is newer or happened in the same block as the last update, update the virtual price if (swapParams.zeroForOne && report.timestamp > lowerVirtualPrice[poolId].timestamp) { lowerVirtualPrice[poolId].update(TickMath.getSqrtPriceAtTick(report.jitLowerTick)); } else if (!swapParams.zeroForOne && report.timestamp > upperVirtualPrice[poolId].timestamp) { upperVirtualPrice[poolId].update(TickMath.getSqrtPriceAtTick(report.jitUpperTick)); }The
updatefunction incorrectly sets the price timestamp to the current block timestamp, rather than the oracle report timestamp:function update(VirtualPrice storage virtualPrice, uint160 virtualSqrtPriceX96) internal { virtualPrice.timestamp = uint48(block.timestamp); virtualPrice.virtualSqrtPriceX96 = virtualSqrtPriceX96; }This allows for a scenario where a swap happens, then an update price occurs, then another swap occurs all in the same block. In this case, the second swap would not see the updated virtual price because the timestamps would be equal, and the condition would fail.
The first swap in the previous example should either be the first swap ever or the first after a price update, else it wouldn't need to synchronize the virtual price.
This would result in the second swap using stale virtual price data.
Recommendation
Consider changing the
updateto set the price’s timestamp as the report’s.Resolution
Universal Hook Team: Resolved.
-
M-04 Medium Pool Drain Depletes Mint Limits & Merchant Funds Validation Resolved
Description
Proof of concept: PoC
When swapping
uAssets→USDC, the hook first tries to fulfill the swap by burning anyERC6909claims it owns. If the claims are insufficient, the hook transfers the remainingUSDCfrom the merchant.The amount burned is computed as the minimum of:
- the requested
USDCamount - the hook’s
ERC6909claims - the
PoolManager’sUSDCbalance
This can be abused by wrapping a swap inside a custom
take →swap →settlesequence. By interacting directly with thePoolManager(instead of the router), an attacker can temporarily drain allUSDCheld by thePoolManager, perform their swap, and then return and settle theUSDC.Because the
PoolManager’sUSDCbalance becomes0during the attacker’s swap, the hook is forced to fulfill the entire swap using the merchant’sUSDCbalance, even if the hook actually owns enough claims to cover the swap.Similarly, for
USDC->uAssetswaps, the_settleWithClaimsfunction checks if the hook address has any wrappedERC6909 uAssetsto settle before mintinguAssetsto the user. With a similartake →swap →settlesequence, an attacker can temporarily drain alluAssetsheld by thePoolManager, forcing the hook to mint newuAssetsto the user instead of settling with existing wrappeduAssets, depleting the hook's mint limit.This DoSes all future swaps until the next epoch, where the mint limit resets.
uAsset->USDCexample:If there are 3 pools, each with 10k
USDCliquidity, and the merchant holds exactly 30kUSDC, an attacker can force the hook to pull more than 10kUSDCfrom the merchant for a single pool. This may prevent honest users from swappingUSDC→uAssetin that pool until the merchant sweeps the accumulated claims back from the hook.Recommendation
Consider removing the
PoolManagerbalance check when deciding how much to burn from claims.Settling with claims (
burn = true) does not transfer any assets from thePoolManager, and the burned amount is already fully accounted for in thePoolManager’s internal deltas. Because of this, the hook does not need to rely on thePoolManager’s temporary balance during the swap.Resolution
Universal Hook Team: Resolved.
- the requested
-
M-05 Medium Paused Actors Can Still Burn uAssets Validation Resolved
Description
The docs mention that paused actors should not be able to burn
uAssets:Account pausing to prevent a witness or merchant from being able to mint or burn
uAssetsin the event of a security incident.However, when pausing an actor only the mint limit is set to 0, which is only validated when minting
uAssets. When burninguAssetsthe mint limit is not validated, so even if the actor (merchant/hook) is paused they can still burnuAssets.For example, if a security incident happens, and the project decided to pause the hook to block any future mints or burns, the hook could still be able to burn assets in exchange for USDC.
This would also allow merchant to burn assets as well, but would require witness signatures.
Recommendation
Consider validating if the actor is paused when performing a burn operation.
Resolution
Universal Hook Team: Resolved.
-
L-01 Low Wrong EIP712 Struct Hashes Signatures Acknowledged
Description
The contract’s
EIP-712struct hashes are not constructed according to the canonicalEIP-712spec. Specifically, the type hashes mix unrelated type strings and omit dependent type definitions:bytes32 internal constant OPERATION_TYPE_HASH = keccak256(abi.encodePacked(OPERATION_TYPE, REQUEST_TYPE)); bytes32 internal constant REQUEST_TYPE_HASH = keccak256(abi.encodePacked(REQUEST_TYPE));Under
EIP-712rules:- Each struct must have its own independent type hash:
keccak256("Operation(uint8 opType,address asset,uint256 amount)")- A primary struct (e.g.,
Request) must include its dependent struct types as part of its canonical
type string.
In this implementation:
OPERATION_TYPE_HASHincorrectly includes theRequesttype definition.REQUEST_TYPE_HASHomits theOperationsubtype entirely.
As a result, the final
EIP-712digest differs from what standard libraries will produce.Recommendation
Consider constructing type hashes according to the
EIP-712canonical rules, for example:bytes32 constant OPERATION_TYPE_HASH = keccak256("Operation(uint8 opType,address asset,uint256 amount)"); bytes32 constant REQUEST_TYPE_HASH = keccak256( "Request(Operation[] operations,address account,uint256 expiration,uint256 nonce)" "Operation(uint8 opType,address asset,uint256 amount)" );Resolution
Universal Hook Team: Acknowledged.
-
L-02 Low Missing Delta Tick Validation Validation Resolved
Description
In
_setPricefunction ofUniversalJITOracle.sol, when updating the oracle price after the initial setup, the contract validates that the change injitLowerTickdoes not exceedMAX_DELTA_TICKcompared to the previous value.This check prevents excessive price movements for
zeroForOneswaps. However, the corresponding validation forjitUpperTickis absent, creating an asymmetric security control when setting the price ofoneForZeroswaps.Recommendation
Add a matching delta tick validation for
jitUpperTickto ensure symmetric protection for both swap directions.Resolution
Universal Hook Team: Resolved.
-
L-03 Low sweepToMerchant Does Not Verify PoolKey Validation Resolved
Description
UniversalJitHook.sweepToMerchant()accepts an arbitraryPoolKey, but does not verify that the pool corresponds to a valid (USDC,uAsset) pair.Because of this, a caller can supply a malicious or non-whitelisted
PoolKey, for example a pair of arbitrary tokens or twouAssets. The sweep logic will incorrectly interpret which token should be burned and which should be sent to the merchant:pairIs0 = _isPairCurrency(key.currency0); pairCurrency = pairIs0 ? key.currency0 : key.currency1; wrappedCurrency = pairIs0 ? key.currency1 : key.currency0;This means that tokens intended to be burned can be transferred to the
Merchantaddress instead.This breaks the invariant that
uAssetsaccumulated in thePoolManagerare meant to be burned during sweep, and sending them to the merchant instead causes temporary excess circulatinguAssetsupply until an admin manually burns the surplus.Recommendation
Validate the key is a whitelisted (
USDC,uAsset) pair.Resolution
Universal Hook Team: Resolved.
-
L-04 Low sweepToMerchant Enable Mint Limit Exhaustion DoS Resolved
Description
The protocol implements a mint limit on
MerchantController.mintFromHookthat resets every 8-hour epoch to prevent unbounded token minting in case of vulnerability exploitation. TheUniversalJitHookis designed to avoid reaching this limit by reusing existingERC6909claims in thePoolManagerrather than minting newuAssettokens when claims are available.However, the
sweepToMerchantfunction is publicly callable by anyone and performs the following operations: 1. Burns all wrappeduAssetclaims held by the Hook in thePoolManager. 2. Transfers other assets to the merchant.By burning the Hook's
ERC6909claims, an attacker forces the Hook to callmintFromHookon subsequent swaps that buyuAsset. This allows an attacker to artificially inflate the Hook's mint counter without performing trades.The attack flow: 1. Attacker observes
uAssetclaims accumulating in the Hook (from swaps). 2. Attacker callssweepToMerchantto burn these claims. 3. When the next user buysuAsset, the Hook must callmintFromHookinstead of settling claims. 4. By repeatedly callingsweepToMerchantand triggeringuAssetmints, an attacker can exhaust the epoch's mint limit. 5. Once the limit is reached, legitimateuAssetpurchases are blocked, creating a temporary denial of service.Recommendation
- Restrict
sweepToMerchantAccess
Add access control to ensure only authorized addresses (e.g. merchant) can call
sweepToMerchant.Resolution
Universal Hook Team: Resolved.
- Restrict
-
L-05 Low Missing sqrtPriceLimitX96 Check Validation Resolved
Description
The
UniversalJITHook.soloverrides the swap delta calculation and settles balances based on oracle prices, but completely ignores thesqrtPriceLimitX96parameter provided by users inSwapParams.This parameter is the standard mechanism for slippage protection in Uniswap V4, it allows users to specify a maximum or minimum acceptable execution price.
By ignoring this limit, the hook removes the user's ability to enforce price bounds on their swap.
The consequences are:
- Users can receive less
amountOut. - Users can pay more
amountInthan expected
Recommendation
Add validation in the swap functions to enforce the user's price limit. After computing the swap amounts, verify that the execution price respects the user's limit.
Resolution
Universal Hook Team: Resolved.
- Users can receive less
-
L-06 Low Missing Tick Crossover Validation Validation Acknowledged
Description
The
_checkTicksAndRangefunction inUniversalJITOracle.solvalidates that the new price ticks are internally consistent (update.jitLowerTick <= update.jitUpperTick), but fails to validate that the new ticks don't cross the previous ticks. This creates a guaranteed arbitrage opportunity that can be sandwiched for risk-free profit.Consider the following scenario:
- Current oracle state:
jitLowerTick = 100,jitUpperTick = 150 - New oracle update:
jitLowerTick = 50,jitUpperTick = 80
The new ticks pass the internal consistency check (50 <= 80), but a crossover situation exists where the entire new price range falls below the old price range. An attacker observing this price update can:
- For a downward crossover (new ticks fall below old ticks): Execute a
zeroForOneswap at the old
price before the update, then execute a
oneForZeroswap at the new lower price after the update for guaranteed profit. 2. For an upward crossover (new ticks rise above old ticks): Execute aoneForZeroswap at the old price before the update, then execute azeroForOneswap at the new higher price after the update for guaranteed profit.The issue occurs because the
_beforeSwaphook inUniversalJITHookuses the new oracle price immediately for swap pricing, regardless of whether it represents a logical continuation of the previous price. A crossover represents a discontinuous price jump that should be rejected.Recommendation
Add crossover validation to
_checkTicksAndRangethat prevents the new ticks from crossing or inverting relative to the previous ticks.This ensures price updates maintain logical continuity and eliminates the guaranteed arbitrage window that crossover situations create.
Resolution
Universal Hook Team: Acknowledged.
- Current oracle state:
-
L-07 Low Mint Cap Griefing Blocks USDC->uAsset Swaps DoS Resolved
Description
When users swap
USDC→uAsset, the JIT hook first tries to settle the requireduAssetsusing existingPoolManagerclaims. If the available claims are insufficient, the hook mints additionaluAssetsthrough theMerchantController.Minting is rate-limited per minter per epoch (8 hours).
If the limit is reached, further
USDC→uAssetswaps cannot mint and will revert.Separately, the hook exposes
sweepToMerchant, which allows anyone to burn the accumulatedPoolManagerclaims and then burn the correspondinguAssets.Because claims are burned but the epoch mint cap is not reset, an attacker can artificially deplete the cap for the entire epoch. 1. Attacker takes a flashloan of
USDC. 2. SwapsUSDC→uAsset, forcing the hook to mintuAssets. 3. SwapsuAsset→USDCback. 4. Repays the flashloan (only pays slippage/spread). 5. CallssweepToMerchant, burning alluAssetclaims created during the swaps. 6. Result: the mint cap for the epoch is fully consumed, but the system holds zero claims.Now, any user attempting
USDC→uAssetswaps will fail because:- the hook cannot settle from claims (they were burned), and
- minting is blocked due to the exhausted epoch limit.
This creates a griefing DoS that lasts until the epoch resets, preventing normal user swaps.
Note: Even if
sweepToMerchantwere permission-restricted, the same DoS occurs as long as a sweep is executed in the same epoch after the attacker performs the cycle.Recommendation
Consider keeping a configurable buffer of
uAssetclaims in thePoolManagerthat is not burned when callingsweepToMerchant, this could either be a percentage of the mint limit or an absolute value, as long as it is enough to fulfill "normal" swaps.Resolution
Universal Hook Team: Resolved.
-
L-08 Low Oracle Updates Limited By MAX_DELTA_TICK Logical Error Resolved
Description
The Universal protocol contains 80+ assets including
uBTC,uETH,uSOL,uXRP,uDOGE, and more. If any of these tokens experiences a price drop, theMAX_DELTA_TICKcheck prevents the oracle from keeping pace with market reality. Consider the following scenario:Assume 1
uAsset= 20,000USDCand theUniversalJitHookcurrently reflects this price.The
uAssetnow experiences a price drop, where 1uAssetnow equals 10,000 USDC. TheUniversalJitOraclewill not have enough time to update to the market price, because of the following check.The new price can only update as far as the immutable
MAX_DELTA_TICKis configured to, which in tests is configured to120, reflecting a tight delta tick bound on price updates for all (uAsset,USDC) pools.In addition, oracle updates must happen at least 5 seconds from the previous update, which means one update every 5 blocks.
This would mean, moving from a tick of
-177,300(token0 = uAsset,token1=USDC, where 1uAsset= 20,000USDC) to a tick of-184,200(1uAsset= 10,000USDC) would require about 58 calls tosetPrice(), as theMAX_TICK_DELTAonly allows a price shift of120ticks per update. In addition, these calls must be separate by at least 5 blocks due to theMIN_FREQUENCYcheck.This allows an attacker to buy the
assettokens at 10,000USDCeach, convert touAsset(or buyuAssetdirectly from, for example separate dex's) and continue to sell by swapping through theUniversalJitHookfor 1uAsset= 20,000USDC, effectively draining Merchant liquidity and stealing funds.Due to the
MAX_DELTA_TICKandMIN_FREQUENCYlimits, the oracle will not have enough time to reflect the true market price and will continue to slowly adjust to the current market price, allowing the attacker to continue stealing funds.Recommendation
Avoid using an immutable
MAX_DELTA_TICK. Instead, make this parameter configurable so the oracle can adjust prices in a single update for highly volatile assets.Resolution
Universal Hook Team: Resolved.
-
L-09 Low Burn Event Is Not Emitted For Burns Events Resolved
Description
For normal burn requests, the
TokensBurnt()event is emitted, which would allow the offchain service to track the burns and sell the underlying assets on Coinbase.However, there is no event emitted in the
burnFromHook()function.Recommendation
Consider whether the event needs to be emitted in
burnFromHook()as well for offchain usage.Resolution
Universal Hook Team: Resolved.
-
L-10 Low Merchant Can Bypass Burn Request Verification Unexpected Behavior Acknowledged
Description
WrappedAssetV2::burnenforces access control to ensureuAssetcan only be burned from theMerchantController.This is to ensure that Merchants can only execute burn requests that are signed and verified by trusted witnesses, in case a Merchant address is compromised, or to prevent malicious redemption transactions. This is specifically for the redemption flow, where users can redeem
uAssetfor underlying.However, a malicious Merchant can bypass the burn request process by transferring
uAssetdirectly toPoolManagerand wrapping inERC6909, then transferring theERC6909 uAssetto the hook address. The hook address will proceed to burn theuAssetduring the nextsweepToMerchant.This allows a malicious merchant to destroy
uAssetsheld by legitimate users (since users sent them to the Merchant to unlock underlying during redemption flow), potentially causing user losses (until admin directly re-mints them to users) and undermining trust in the protocol.In addition, the access control can be bypassed by regular users who can achieve direct burns via the same method.
Recommendation
Consider tracking the burnable amount via internal accounting rather than relying on the entire
balanceOf ERC6909 uAssetswithinPoolManagerduringsweepToMerchant().Resolution
Universal Hook Team: Acknowledged.
-
I-01 Informational Unused Constructor Param Best Practices Resolved
Description
The variable
tickSpacinginUniversalJITOracle.solis passed to the constructor but never used.Recommendation
Remove the variable from constructor as it is never used.
Resolution
Universal Hook Team: Resolved.
-
I-02 Informational _getGlobalBalance Is Internal And Not Used Best Practices Resolved
Description
The function
_getGlobalBalanceis internal view function, but is never called in any function.Recommendation
Remove the
_getGlobalBalancefunction.Resolution
Universal Hook Team: Resolved.
-
I-03 Informational Unused State Variable liquidityLeft Informational Resolved
Description
The
UniversalJitHook.solcontract contains an unused state variable mappingliquidityLeft.This mapping is never written to or read from anywhere in the contract codebase.
Recommendation
Remove the mapping
liquidityLeftfrom the contract or implement the necessary functionality.Resolution
Universal Hook Team: Resolved.
-
I-04 Informational Unresolved TODOs Within VirtualPrice.sol Best Practices Acknowledged
Description
The
VirtualPricecontract has multiple unresolved TODOs:- A TODO noting that
LiquidityAmountsis imported from Uniswap V4’s test utils, suggesting the
usage still needs to be validated for production use.
- Several require invariant checks marked as “TODO: remove after testing”, which look like leftover
test assertions.
While these do not currently present a security issue, they indicate unfinished review/cleanup work and could cause unnecessary reverts or confusion in future maintenance.
Recommendation
Explicitly validate/document the use of
LiquidityAmountsas production-safe, and keep invariants as assertions or remove them if no longer needed.Resolution
Universal Hook Team: Acknowledged.
- A TODO noting that
-
I-05 Informational Swap Events Emit Zero Amounts Events Resolved
Description
The protocol’s custom Uniswap v4 hook overwrites the
SwapReturnDelta, which forces all swap executions to operate withamountSpecified = 0insidePoolManager.swap().Because the hook adjusts the swap amount,
Hook.sol’sbeforeSwap()updatesamountToSwap, but the logic inPool.soltreats anyparams.amountSpecified == 0as a no-op swap.As a consequence,
Pool.swap()immediately returnsBalanceDeltaLibrary.ZERO_DELTA, and the emittedSwapevent always hasamount0 = 0andamount1 = 0.This results in all emitted swap events being incorrect and unusable.
Recommendation
Consider emitting an event inside the Hook.
Resolution
Universal Hook Team: Resolved.
-
I-06 Informational Unused Imports/Errors Best Practices Resolved
Description
Errors: 1.
AccountPausedinsrc/assets/MerchantController.sol2.ThresholdTooHighinsrc/assets/MerchantController.sol3.ZeroThresholdinsrc/assets/MerchantController.sol4.InvalidTickSpacinginsrc/v4-hook/UniversalJITOracle.sol5.NotHookinsrc/v4-hook/UniversalJITOracle.sol6.PriceOutOfBoundsinsrc/v4-hook/UniversalJITOracle.sol7.SpreadTooLargeinsrc/v4-hook/UniversalJITOracle.sol8.SpreadTooSmallinsrc/v4-hook/UniversalJITOracle.solUnused Imports: 1.
StateLibraryinsrc/v4-hook/UniversalJitHook.sol2.TickMathinsrc/v4-hook/lib/VirtualPrice.sol3.FullMathinsrc/v4-hook/lib/VirtualPrice.sol4.SafeCastinsrc/v4-hook/lib/VirtualPrice.sol5.LiquidityAmountsinsrc/v4-hook/lib/VirtualPrice.solRecommendation
Remove unused errors/imports
Resolution
Universal Hook Team: Resolved.
-
I-07 Informational Missing MAX_DELTA_TICK Validation Validation Partially resolved
Description
The
UniversalJitOracleutilizes an immutable variableMAX_DELTA_TICKto bound how much the newly reported tick is allowed to change between oracle updates.The contract also has defined the following, which is unused: error
InvalidMaxDeltaTick();, suggesting the intention to properly validate the max delta tick value before it is permanently set, as a misconfigured or negativeMAX_DELTA_TICKcan permanently brick price updates.Recommendation
Properly validate the
MAX_DELTA_TICKin the constructor, for example adding lower/upper bounds.Resolution
Universal Hook Team: Partially Resolved.
-
I-08 Informational No Validation Of Pool Fee Tier Validation Acknowledged
Description
Inside
_checkTicksAndRange, the oracle validates tick ordering and JIT ranges, but it does not validate anything about the pool’s fee tier, even though all swaps executed through the hook are intended to behave as zero-fee swaps with no LPs.This creates a configuration mismatch scenario where a pool may be initialized with a non-zero fee tier, but all swaps executed through the hook behave as fee-free swaps.
Recommendation
Consider adding a check to
_checkTicksAndRangeto ensure that the pool’s fee tier is zero, this requires passing the pool key instead of the pool ID.Resolution
Universal Hook Team: Acknowledged.
-
I-09 Informational No Desired Price Is Signed In Merchant Requests Best Practices Acknowledged
Description
When a mint merchant request is processed, the minted amount is multiplied by the price of the asset and the resulting USD value is consumed from the limit of the merchant and all of the witnesses.
Because there is no data signed about the price of the token, the merchant and the witnesses may end up executing an undesirable request if
setMintLimitPriceorsetMintLimitPricesis called in between.Recommendation
Consider whether a price information should be included in the signed data.
Resolution
Universal Hook Team: Acknowledged.
-
I-10 Informational Unnecessary Approval Given To The PoolManager Best Practices Resolved
Description
In the
_settleUAsset()function, theuAssetis first minted to the hook and then an allowance is given to the poolManager before executingsettle().function _settleUAsset(Currency asset, uint256 amount) internal { uint256 settled = _settleWithClaims(asset, amount); uint256 remain = amount - settled; if (remain > 0) { address assetAddress = Currency.unwrap(asset); merchantController.mintFromHook(assetAddress, address(this), remain); IERC20(assetAddress).approve(address(poolManager), remain); asset.settle(poolManager, address(this), remain, false); } }This allowance is not needed because
settle()transfers the tokens to the pool manager, which means the manager itself never has to pull them.IERC20(Currency.unwrap(currency)).safeTransfer(address(poolManager), amount);Recommendation
Remove the approval.
Resolution
Universal Hook Team: Resolved.
-
I-11 Informational Notes About Nonce Behavior Warning Acknowledged
Description
MerchantController._validateRequest()calls_useUnorderedNonce()for the merchant of the request and each of the witnesses. It ensures a nonce cannot be used twice by the same address. However, there are a few things that should be noted about this nonce behavior.When a request is validated, the nonce is invalidated for the current merchant.
_useUnorderedNonce(merchant, nonce);This means the merchant won't be able to execute a new request with the same nonce. On the other hand, other merchants are able to use the same nonce, since the nonce is account specific. For the new merchant's request, all of the witnesses of the previous request, plus the previous merchant (if witness as well), will produce invalid signatures.
This is because they have already attested for that nonce in the first request. In addition, if the merchant is also a witness, they won't be able to attest to their own request.
In result, the merchant will have to submit additional signatures of other witnesses if any are available, or create a new request with different nonce.
This also increases the risk of merchants griefing each others. For example, merchant A submits a request with
nonceX. Merchant B does the same for a different request and frontruns merchant A. If any of the witnesses are the same, merchant A's transaction will revert.Recommendation
If this behavior is acceptable, consider documenting it as code comment. Otherwise, rework the nonce system to not use global nonces for different merchants and witnesses.
Resolution
Universal Hook Team: Acknowledged.
-
I-12 Informational Unnecessary Storage Read Wastes Gas Gas Optimization Resolved
Description
In the
safeGetPricefunction ofUniversalJITOracle.sol, theroundvariable is loaded from storage but never used.The function reads the entire
OracleRoundstruct from storage into memory but then retrieves the price data again via the separategetPrice(poolId)call.This unnecessary storage read wastes gas on every call.
Recommendation
Remove the unused variable assignment to reduce gas consumption.
This change eliminates the redundant
SLOADoperation and produces cleaner, maintainable code without any change to functionality.Resolution
Universal Hook Team: Resolved.
-
I-13 Informational Redundant onlyPoolManager Modifier Usage Gas Optimization Resolved
Description
The internal
_beforeInitialize()function has theonlyPoolManagerapplied to it. This is not needed because this function is called only inBaseHook.beforeInitialize()which already applies the modifier.Recommendation
Remove the
onlyPoolManagermodifier from_beforeInitialize().Resolution
Universal Hook Team: Resolved.
-
I-14 Informational Unused And Missing Event Emissions Events Acknowledged
Description
The protocol contains several state-modifying functions that fail to emit appropriate events, reducing transparency and hindering off-chain monitoring.
Specifically,
MerchantController.soldefines four unused events that should be emitted during critical state changes. Additionally,MintLimiter.solandUniversalJITOracle.sollack event definitions and emissions for important state modifications.MerchantController.soldefines but never emits:AssetWhitelistSet- should emit when asset whitelist status changesOracleSet- should emit when oracle configuration is updatedPauseSet- should emit when pause status is toggledEpochReset- should emit when account epoch is reset
MintLimiter.soldefinesMintLimitSetbut never emits it, and lacks events for:_setActorMintLimit()- when individual actor mint limits are configuredpauseAsset()- when an asset is pausedpauseActor()- when an actor is paused
UniversalJITOracle.solis missing event emissions for:_setPrice()- when price state is updated
Recommendation
Emit all existing events in their corresponding state-modifying functions in
MerchantController.sol. Create and emit new events for the missing functions inMintLimiter.solandUniversalJITOracle.sol. This will improve protocol transparency and enable proper off-chain event indexing and monitoring.Resolution
Universal Hook Team: Acknowledged.
-
I-15 Informational Pool Creation May Be Front-ran Informational Acknowledged
Description
UniversalJitHook._beforeInitialize()validates the tokens of the pool are the correct ones and an oracle update for that pool is available.In case when the oracle update was submitted, but no pool was created, anyone can call
PoolManager.initialize()with whatever price they want to.This will have no effect on the functionality of the hook because the price is never used, but may result in unexpected state for the pool.
Recommendation
Keep that in mind when initializing pools.
Resolution
Universal Hook Team: Acknowledged.
-
I-16 Informational Unnecessary Type Casting Best Practices Resolved
Description
In the
_computeReportZeroForOne()and_computeReportOneForZero()functions, thereport.jitLowerRangeandreport.jitUpperRangevariables are casted touint24. This is not needed because the variables are alreadyuint24s.struct OracleReport { int24 jitLowerTick; int24 jitUpperTick; uint24 jitLowerRange; uint24 jitUpperRange; uint256 uAssetLiquidity; uint256 usdcLiquidity; uint256 timestamp; }Recommendation
Remove the casting.
Resolution
Universal Hook Team: Resolved.
-
I-17 Informational Inconsistent Naming Conventions Best Practices Resolved
Description
The codebase contains naming inconsistencies between related contracts. The
UniversalJITOraclecontract (inUniversalJITOracle.sol) and theUniversalJitHookcontract (inUniversalJitHook.sol) use different capitalization conventions.Specifically,
UniversalJITOracleuses all-caps "JIT" whileUniversalJitHookuses only the first letter capitalized as "Jit".Recommendation
Choose one capitalization style and apply it uniformly to both contract names and their corresponding file names.
Resolution
Universal Hook Team: Resolved.
-
I-18 Informational Inconsistent Merchant Controller Upgradability Upgradeability Acknowledged
Description
The
merchantControllerinWrappedAssetV2is immutable, meaning all deployeduAssetspermanently reference the same controller.However, in the hook, the merchant controller is not immutable and can be updated via
setMerchantController.If the merchant controller is ever changed, the hook will start using the new contract, but existing
uAssetswill still reference the old one.Newly deployed
uAssets(using a new implementation with the updated controller) would use the new controller, while previously deployed ones cannot be updated, creating inconsistent behavior across assets.Recommendation
Consider either making the merchant controller immutable in the hook as well, or adding a
setMerchantControllerfunction toWrappedAssetV2to allow updating the merchant controller for already deployeduAssetsinstead of having to deploy a whole new implementation.Resolution
Universal Hook Team: Acknowledged.
-
I-19 Informational Merchant May Be Forced To Trade Warning Acknowledged
Description
One of the known issues of the project is that there exists a griefing vector for the merchant. Users can request to be minted
uAssetsby signingUniswapXorders, but later revoke their approval before the actual swap happens.Because minting
uAssetsand swapping them for the user's tokens happens atomically, both actions will fail, but the merchant will already have purchased the underlying token of theuAsseton Coinbase.This means any malicious user can force the merchant to execute spot buys on Coinbase, which doesn't pose a risk for the solvency of the
uAssetsbecause they become overcollateralized. However, the merchant will be charged fees for each buy and sell. Furthermore, their USD balance will decrease and unexpected rebalances may be triggered.After such forced buy happens, if the merchant doesn't immediately sell, but decides to keep the tokens for backing future
uAssets, any negative price movement will affect the net worth of the merchant.On the other hand, if the merchant sells, the malicious user can potentially execute the griefing attack again. If there are no strict validations on the orders this may be even repeated until the merchant balance goes close to 0.
Blacklisted users can also make mint/burn revert.
Recommendation
Carefully consider the risks before deploying the offchain system and enforce the needed validation for each request. Migrating to an escrow design would be best.
Resolution
Universal Hook Team: Acknowledged.
-
I-20 Informational Misleading NatSpec Documentation Resolved
Description
The parameter documentation for
WrappedAssetV2.setUserBlacklist()incorrectly states "The chain to blacklist/whitelist", while in reality it is a user that is being blacklisted or whitelisted.Recommendation
Fix the comment.
Resolution
Universal Hook Team: Resolved.
-
I-21 Informational WrappedAssetV2 Salt May Not Be Unique Best Practices Resolved
Description
The salt in
WrapFactoryV2.deployBeaconProxy()usesabi.encodePacked(name, symbol). Different inputs can produce the same packed bytes (e.g., ("ab","c") and ("a","bc")) leading to the same salt. This can result in inability to deploy certain tokens if their address was already used.Recommendation
Use
abi.encode()instead.Resolution
Universal Hook Team: Resolved.
-
I-22 Informational Misleading NatSpec Documentation Resolved
Description
The comment above
getUserBlackliststates that this function should only be called by the admin. However, it is a public function that can be called by anyone.Recommendation
Fix the comment.
Resolution
Universal Hook Team: Resolved.
-
I-23 Informational Admin Can't Burn For Blacklisted Address Unexpected Behavior Acknowledged
Description
When an account is blacklisted, the admin can’t burn its
uAssetbalance because the update method checks that the user is not blacklisted, so the tokens remain stuck.Burning them would require unblacklisting the user first, but that would allow the user to frontrun the burn and swap the tokens into something else.
Recommendation
Consider fixing the update method so that when the
toaddress in_updateisaddress(0), it does not revert.Resolution
Universal Hook Team: Acknowledged.
-
I-24 Informational Burn Operations Ignore Asset Pause Mechanism Validation Resolved
Description
The
MintLimiter.solcontract implements a pause mechanism via thepauseAsset()function, which sets an asset's price to 0.This effectively prevents minting operations, as the
_mint()function callscalculateMintValue()which checks the assets price and will revert if the asset is paused.However, the
_burn()function inMerchantController.soldoes not include this whitelist check, allowing burn operations to proceed even when an asset has been paused by an admin.This inconsistency could enable unintended token burning during periods when the asset should be frozen.
Recommendation
Consider adding the
_isAssetWhitelisted()check to the_burn()function if that was the intended pause functionality.To ensure that burn operations respect the pause mechanism in the same manner as mint operations.
Resolution
Universal Hook Team: Resolved.
-
I-25 Informational Unrecoverable ERC6909 Balances Risk Warning Resolved
Description
The
UniversalJITHook.solcontract contains the immutableMERCHANTaddress. ThesweepToMerchant()function sends theERC6909 currency0/currency1of a specific Pool toMERCHANT. Once deployed, this address cannot be modified.In a (unlikely) scenario where the private keys associated with the
MERCHANTaddress are lost or compromised, there is no mechanism to redirect future fund sweeps to an alternative address.Recommendation
Consider implementing a changeable merchant address mechanism.
Resolution
Universal Hook Team: Resolved.
-
I-26 Informational Mint Limit Bypass Via Tiny Swaps Rounding Resolved
Description
When minting new tokens from
USDC->uAssetswaps, the mint value is calculated and checked against the mint limit:MintLimiter::calculateMintValueSafeCastLib.toUint104(FixedPointMathLib.mulWad(amount, price));amountis in 18 decimals (uAsset), andpricemust be in 8 decimals for the result to be in8 decimals(mulWad), as the documentation states that mint limits have 8 decimal precision:// Note: both mint limit and minted should be denominated in 8 decimals precision struct Actor { uint104 mintLimit; Epoch epoch; }If
amount * price < 1e18, thenSafeCastLib.toUint104(FixedPointMathLib.mulWad(amount, price));rounds down to 0 and completely skips the mint limit check, which is possible ifamountis a very low value.Even if a user batches a large number of these dust mints, the total amount that can bypass the mint limit is bounded to a negligible USD value and does not meaningfully bypass the limit.
Recommendation
Either round up when calculating
SafeCastLib.toUint104(FixedPointMathLib.mulWad(amount,price));or revert ifvalue == 0withinMerchantController::mintFromHookResolution
Universal Hook Team: Resolved.
-
I-27 Informational MintLimitReached Doesn't Include Current Value Best Practices Acknowledged
Description
MintLimiter.MintLimitReached()is used to revert when a mint would make the total minted usd value of an actor goes beyond their limit. The error has the following signature:MintLimitReached(uint256 limitValue, uint256 mintValue)The first parameter is the limit that must not be exceeded and the second one is the value that the user tried to mint. The error doesn't include the value that has already been minted until now.
Recommendation
Consider adding a third parameter that shows how much usd value has been already minted.
Resolution
Universal Hook Team: Acknowledged.
-
I-28 Informational Unused Role Definitions Informational Resolved
Description
The codebase defines several role constants that are not actively utilized in the contracts. Specifically,
MerchantController.soldeclares bothEXECUTOR_ROLEandRESPONDER_ROLE, but neither role is enforced in any function or modifiers.According to the
README, these roles were intended to perform specific administrative functions—EXECUTOR_ROLEfor resetting epochs andRESPONDER_ROLEfor pausing merchants or witnesses during incidents.However, the actual pause functionality is instead implemented through a separate
PAUSER_ROLEdefined inMintLimiter.sol.Recommendation
It's recommended to assess the access control strategy across the protocol to align with the documented roles in the
README.Either implement the
EXECUTOR_ROLEandRESPONDER_ROLEin their intended functions withinMerchantController.sol, or remove these unused constants. This will improve code clarity and reduce maintenance burden.Resolution
Universal Hook Team: Resolved.
-
I-29 Informational Warning About Temporary Unbacked uAssets Warning Acknowledged
Description
The
UniversalJitHookuses virtual liquidity for bothtoken0andtoken1to facilitateuAsset→USDCswaps.The USDC virtual liquidity for each pool can exceed the total USDC actually traded into that pool through USDC →
uAssetswaps. Sponsors confirmed this is intentional, as virtual liquidity shapes the price curve, reduces price movement, and caps trade sizes independently of the actual USDC contributed to that pool.This can introduce temporary periods where the merchant’s available USDC is lower than the amount required to fully collateralize outstanding uAssets. Consider the following example:
uAssets: uBTC (1 uBTC = 90,000 USDC)and uETH (1 uETH = 3,000 USDC). Assume pools (uBTC, USDC) and (uETH, USDC).Bob owns 1 uBTC from direct issuance (deposit 1 WBTC). Carole owns 12 uETH by depositing 12 ETH into custodian. MERCHANT holds 10,000 USDC.
Assume the Merchant's 10,000 USDC was received through USDC → uBTC swaps, and no USDC → uETH swaps occurred.
Alice swaps 90k USDC for 30 uETH. Merchant now holds 100k USDC.
Oracle updates (uBTC, USDC) pool USDC virtual liquidity to 45k (despite only 10k entering that pool).
Bob swaps 0.5 uBTC for 45k USDC, leaving the merchant with 55k.
Alice now holds 30 uETH (worth 90k USDC) and wants to redeem. She can sell only ~18 uETH via the hook, so she burns the remaining 12 uETH and unlocks 12 ETH from custody (the underlying Carole deposited).
Carole is left with 12 uETH that cannot be sold through the hook (insufficient USDC) and cannot be redeemed through custody (since Alice withdrew the underlying). Her 12 uETH become unbacked and unredeemable until the Merchant sells Bob's 0.5 WBTC for USDC, and uses it to purchase 12 ETH to deposit into custodian.
During the timeframe between the burn and the moment when the Merchant sells Bob’s underlying and uses the proceeds to re-back the 12 uETH, these tokens remain temporarily unbacked.
Recommendation
Consider documenting and defining procedures (e.g., sweeping frequency or minimum liquidity buffers) to minimize temporary under-backing.
Resolution
Universal Hook Team: Acknowledged.
Remediation Review
14 findings-
L-01 Low Swaps May Revert Because Of Clearing DoS Resolved
Description
After the hook settles a swap with its
ERC6909balance, it calls_clearCurrencyClaims()to sweep any remainingERC6909balance of that token back to the merchant. However,UniswapV4uses flash accounting. This means actual token transfers happen at the end of the transaction.So if a few swaps happen in the same transaction, it's possible for the hook to have
ERC6909balance, while at the same time there are not enough funds in the pool manager yet. Because of this, the transaction will revert, since the hook is trying to transfer these tokens from the manager.Recommendation
When executing
_clearCurrencyClaims(), cap the amount being cleared to the balance of the pool manager for that token.Resolution
Universal Hook Team: The issue was resolved in commit 95cce7d.
-
L-02 Low Burns Do Not Decrease Witness Mint Value DoS Resolved
Description
The newly added
_decreaseMintValuefunction lowers the minted amount of an actor uponuAssetburns, preventing the mint limit from exhausting early for the current epoch.When mint requests are executed, both merchant and witness minted amounts are increased. However, for burn requests, only the merchant minted amount is decreased.
This means that for the same witnesses, the mint limit will be reached earlier than expected, blocking further mint requests from executing for the current epoch.
Recommendation
Within
MerchantController::_burnconsider removing_decreaseMintValue(account, value);.Resolution
Universal Hook Team: The issue was resolved in commit 7b1d7f3.
-
L-03 Low Swaps May Revert Because Of Mint Value Rounding Rounding Resolved
Description
MintLimiter.calculateMintValue()was changed to revert if the calculated value rounds down to 0.uint104 value = SafeCastLib.toUint104(FixedPointMathLib.mulWad(amount, price)); if(value == 0) revert InvalidMintValue(amount, price);This function is called in both
mintFromHook()andburnFromHook(). This means both of these actions may revert - either on its own or due to a malicious user.For example, when settling an
uAssetif theERC6909is less than the needed amount, a user can donate the exact amount needed to cause the revert.The same can happen on sweeps and now swaps as well because of the new
_clearCurrencyClaims()function.Recommendation
Consider removing the revert from
calculateMintValue()and instead:- round
valueup if the operation ismint - round
valuedown if the operation isburn
This will avoid reverts and still round against the merchant/hook.
Resolution
Universal Hook Team: The issue was resolved in commit 58d991b.
- round
-
L-04 Low Exact In Swaps Are Leaked To The Uniswap Pool Unexpected Behavior Acknowledged
Description
One of the latest changes was done to the
VirtualPriceLibrary. The functions forexactInswaps don't revert anymore if the amount being swapped is more than the maximum allowed amount, but instead cap it.amount0InAfter = amount0In > maxAmount0 ? maxAmount0 : amount0In;This means there may be a difference between the user provided
amountSpecifiedand the actual swapped amount. For example, consider the case whereamountInSpecified = -1000, butmaxAmount0= 800.In that case
maxAmount0will be returned as delta from the hook and the pool manager will calculate the newamountInSpecifiedto be-200. Until now, all swaps amounts were resulting in 0, the following if was hit inPool.swap()and no actual swap actions were performed in the pool itself.if (params.amountSpecified == 0) return (BalanceDeltaLibrary.ZERO_DELTA, 0, swapFee, result);With the new changes, it's possible to not hit this if statement and continue executing the
swap. If the swap returns a non-zero delta, the user can manipulate the amount paid. However, it's expected that the swap will always return 0 delta, because there is no liquidity added to the pool.There is another side effect - the price moves at the pool level. This may lead to unexpected behaviors. One of them is that if all available tokens of a liquidity band are bought, the price of the pool can be pushed to its boundaries - either minimum or maximum. Following swaps that use price limits may revert.
For example, we have prices A < B < C < D. User starts buying at
C, buys all the tokens and pushes the price at the pool level to its maximum. A new oracle report resupplies liquidity at the same prices (for simplicity). Now, if another buy specifies a limit, the transaction will revert due to the following check in thePool.Having swaps execute at the pool level may have another unexplored risks as well.
Recommendation
For this version of the project, consider reverting if the amount swapped is more than the maximum possible
Resolution
Universal Hook Team: Acknowledged.
-
I-01 Informational Salt Uniqueness Issue Was Fixed In A Wrong File Best Practices Resolved
Description
The issue
[I-21] WrappedAssetV2 Salt May Not Be Uniqueis located inWrapFactoryV2, but the fix was done to theWrapFactoryfile which is out of scope for the review. The issue is still present inWrapFactoryV2.Recommendation
Fix the issue in
WrapFactoryV2.Resolution
Universal Hook Team: Resolved.
-
I-02 Informational setUserBlacklist() NatSpec Not Fixed Documentation Resolved
Description
Issue
[I-20] Misleading NatSpecis present inWrappedAssetV2.sol, but was fixed inWrappedAsset.solinstead. This leaves the issue still present in theWrappedAssetV2.solfile.Recommendation
Fix the NatSpec in the
WrappedAssetV2.solfile.Resolution
Universal Hook Team: Resolved.
-
I-03 Informational mintLimit Should Be Changed To Int104 Best Practices Resolved
Description
Epoch.mintedwas changed toint104, butOperator.mintLimitandActor.mintLimitwere leftuint104. Because of this, themintLimitis casted toint104before it's used.However, the maximum value that can be represented with
uint104is larger thantype(int104).max. Therefore, if themintLimitis set to such a large value, theMerchantControllerwill start reverting.Recommendation
Change
mintLimittoint104. By doing this, you will also get rid of the type casting.Resolution
Universal Hook Team: Resolved.
-
I-04 Informational Ambiguous Revert Messages In _beforeSwap Events Resolved
Description
The
_beforeSwap()function inUniversalJITHookcontains two separate validation checks that both revert with the identical error messageBlacklistedUser. The first check validates that the asset is whitelisted, while the second check validates that the caller is not blacklisted.When either check fails, the caller receives the same revert message despite the failures being caused by two distinct conditions.
A user with a non-whitelisted asset will receive the same error message as a blacklisted user, creating confusion about the actual reason for the transaction failure.
Recommendation
Define and use separate, descriptive revert messages for each validation condition.
This approach provides clear, actionable error information to users and improves the debugging experience for both direct users and external integrators of the protocol.
Resolution
Universal Hook Team: Resolved.
-
I-05 Informational Inconsistent Naming Convention Best Practices Acknowledged
Description
There is an inconsistency between the naming of the
UniversalJitHook.solfile andUniversalJITOracle.sol- the former is named with not uppercasedJIT, even though the contract defined inside,UniversalJITHook, capitalizes it.Recommendation
Rename the file to
UniversalJITHook.solResolution
Universal Hook Team: Acknowledged.
-
I-06 Informational maxDeltaTick Validation Not Resolved Informational Partially resolved
Description
Issue [I-07] reported that the
UniversalJitOraclecontract has defined the following error, which is unused: errorInvalidMaxDeltaTick();. This suggests the intention to properly validate the max delta tick value, as the following configuredmaxDeltaTickvalues can be problematic:Negative value: The check
delta > maxDeltaTick||delta < -maxDeltaTickwill behave unexpectedly. IfmaxDeltaTick = -10, thendelta > -10is true for any positive delta, thus blocking price updates.Zero: Would require
delta == 0for both ticks, completely preventing price updates.Excessively large: Defeats the purpose of bounding price movements.
Recommendation
Consider validating maxDeltaTick in the following function and constructor:
function setMaxDeltaTick(int24 _maxDeltaTick) external onlyRole(DEFAULT_ADMIN_ROLE) { if (_maxDeltaTick <= 0 || _maxDeltaTick > MAX_ALLOWED_DELTA) revert InvalidMaxDeltaTick(); maxDeltaTick = _maxDeltaTick; }Resolution
Universal Hook Team: Partially Resolved.
-
I-07 Informational Non-standard Handling Of sqrtPriceLimit == 0 Best Practices Acknowledged
Description
VirtualPricelibrary treatssqrtPriceLimit == 0as a special case that disables price-limit checks. This differs from Uniswap v4, where a limit of 0 is always invalid and causes a revert because it is below the minimum allowed sqrt price,https://github.com/Uniswap/v4-core/blob/main/src/libraries/Pool.sol#L322-L338.
Recommendation
Consider either reverting when
sqrtPriceLimit == 0to match Uniswap behavior, or clearly documenting that 0 is treated as a “disable limit” flag.Resolution
Universal Hook Team: Acknowledged.
-
I-08 Informational Redundant Invariant Checks In Limit Branches Best Practices Resolved
Description
In the following functions:
quoteExactInputZeroForOneWithLimitquoteExactInputOneForZeroWithLimitquoteExactOutputOneForZeroWithLimit
the limit branch contains an invariant check on
sqrtPriceAfter(e.g.>= sqrtPriceLimitor<=sqrtPriceLimit).However, each of these functions calls its corresponding inner function with
sqrtPriceLimitas the price bound:quoteExactInputZeroForOne(...)quoteExactInputOneForZero(...)quoteExactOutputOneForZero(...)
Those inner functions already enforce the same invariant internally (
sqrtPriceAfter >=sqrtPriceLoweror<= sqrtPriceUpper).Because the limit is passed as the bound, the inner check guarantees the condition before returning, making the outer invariant redundant.
Recommendation
Consider removing the outer invariant checks from:
quoteExactInputZeroForOneWithLimitquoteExactInputOneForZeroWithLimitquoteExactOutputOneForZeroWithLimit
Resolution
Universal Hook Team: Resolved.
-
I-09 Informational Unused Variable In _burn Function Gas Optimization Resolved
Description
In
_burn,calculateMintValueis called and assigned tovalue, but the variable is never used. This results in unnecessary gas consumption.Recommendation
Remove the unused calculation to save gas.
Resolution
Universal Hook Team: Resolved.
-
I-10 Informational Sweeping Issues Due To Validation Warning Acknowledged
Description
The same pool key validation from
_beforeInitialize()was added tosweepToMerchant()if (_isPairCurrency(key.currency0)) { if (!merchantController.isAssetWhitelisted(address(Currency.unwrap(key.currency1)))) revert AssetNotAllowed(); } else if (_isPairCurrency(key.currency1)) { if (!merchantController.isAssetWhitelisted(address(Currency.unwrap(key.currency0)))) revert AssetNotAllowed(); } else { revert PairCurrencyNotAllowed(); }If an asset is paused, it will be impossible to claim any hook
ERC6909balance until that asset is unpaused again. Also, if there are no other pools with a whitelisteduAssets, the USDCERC6909balance will be stuck as well.Even if that check wasn't present there,
unlockCallback()callsburnFromHook()if the hook holds anyERC6909balance of theuAsset. InburnFromHook(), the flow reverts if the asset is paused or the hook is disabled from minting/burning.if (!_isAssetActiveForActor(asset, HOOK_ADDRESS)) revert AssetNotActive();This means any sweeps with positive
uAssetbalance will revert. Even if the hook doesn't have any, a user can forcibly send 1 wei to trigger the call toburnFromHook(). The same way as above, if there are no other pools that can be swept, the USDC balance will be stuck.Recommendation
Keep in mind this behavior.
Resolution
Universal Hook Team: Acknowledged.
Remediation Review V2
3 findings-
L-01 Low _beforeInitialize() Should Have Access Control DoS Resolved
Description
For exactIn swaps, the
amountToSwapreturned from the hook may be non-zero in case the caller specified anexactIn > maxExactInallowed for the current price band.In that case, the
sqrtPriceLimitspecified by the user (orV4Router) is checked against the currentsqrtPrice(Pool.sol).The problem is that anyone can initialize whitelisted USDC/
uAssetpools specifying an arbitrarysqrtPriceX96. An attacker can front-run pool initialization and set it to an extreme value, such asTickMath.MIN_SQRT_PRICE + 1orTickMath.MAX_SQRT_PRICE - 1.This will cause DoS to any 0 to 1 or 1 to 0 swaps (depending on initial price set) where there is a change in the
exactIndelta specified by a user. This will continue until users swap the opposite direction, as it will move the price away from the extreme.Note that a similar issue was reported and acknowledged in the review phase:
[I-15] Pool CreationMay Be Front-ran. At that time there was no issue since pool prices were not used, however that has since changed.Recommendation
The first parameter of
_beforeInitialize()is the address who calledPoolAddress.initialize(). Use it to validate only trusted addresses can create pools.Resolution
Universal Hook Team: The issue was resolved in commit 32aca0b.
-
I-01 Informational Unused Parameter In _clearCurrencyClaims() Gas Optimization Resolved
Description
When settling with claims, the leftover claims is passed into
_clearCurrencyClaims, but never used because the remaining claims amount is fetched again:uint256 claims = poolManager.balanceOf(address(this), currency.toId()); if (claims == 0) return;In fact, the check
if (claims > useAmt)already implies a non-zero claims amount, making the above code redundant.Recommendation
Consider using the passed in
amountparameter instead of re-fetching claims, or remove theamountparameter to save gas:function _clearCurrencyClaims(Currency currency, uint256 amount) internal { uint256 poolBalance = currency.balanceOf(address(poolManager)); uint256 available = amount < poolBalance ? amount : poolBalance; if (available == 0) return; ... }Resolution
Universal Hook Team: The issue was resolved in commit cabbdef.
-
I-02 Informational Unused Error In MintLimiter Best Practices Resolved
Description
The L-03 issue was fixed and the revert was removed. However, the
InvalidMintValueerror is still present but unused.Recommendation
Consider removing the unused
InvalidMintValueerror.Resolution
Universal Hook Team: The issue was resolved in commit d0f845a.
No findings match.
Invariants 14
The review's fuzzing suite asserted 14 invariants. 13 held and 1 did not.
Every invariant tested
| ID | Invariant | Result |
|---|---|---|
INV-GLB-01 | Users must not profit by doing atomic swaps | Held |
INV-MNT-LMTR-01 | Whitelisted assets must have non-zero assetPrices | Held |
INV-MNT-LMTR-02 | The user’s balance cannot increase more than the mint limit after a mint | Held |
INV-HK-01 | operation After sweeping, the whole ERC6909 balance of the hook must be | Held |
INV-HK-02 | transferred to the merchant After a swap, upperVirtualSqrtPriceX96 must be in the range [upperTick; upperTick + | Broken |
INV-HK-03 | upperRange] After a swap, lowerVirtualSqrtPriceX96 must be in the range [lowerTick - lowerRange; | Held |
INV-HK-04 | lowerTick] After oneForZero , the burned ERC6909 balance of the hook must be the minimum between the ERC6909 balance and the delta0 which the user received | Held |
INV-HK-05 | After zeroForOne , the burned ERC6909 balance of the hook must be the minimum between the ERC6909 balance | Held |
INV-HK-06 | and the delta1 which the user received The amounts of zeroToOne swap must match the return value of | Held |
INV-HK-07 | SwapMath.computeSwapStep The amounts of oneForZero swap must match the return value of | Held |
INV-ORCL-01 | SwapMath.computeSwapStep After a price update, the report timestamp must strictly increase | Held |
INV-ORCL-02 | After a price update, the report ranges must be greater than 0 | Held |
INV-ORCL-03 | After a price update, the report end ticks must be in the range [MIN_TICK; | Held |
INV-ORCL-04 | MAX_TICK] After a price update, the report tokens liquidity must be greater than 0 | Held |
More from Universal
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.
