Table of Contents
Summary
- Type
- DeFi
- Timeline
- From 2025-01-07
- To 2025-01-17
- Languages
- Solidity
- Total Issues
- 22 (16 resolved, 2 partially resolved)
- Critical Severity Issues
- 1 (1 resolved)
- High Severity Issues
- 2 (2 resolved)
- Medium Severity Issues
- 6 (5 resolved, 1 partially resolved)
- Low Severity Issues
- 4 (3 resolved, 1 partially resolved)
- Notes & Additional Information
- 9 (5 resolved)
Scope
We audited the OpenZeppelin/uniswap-hooks repository at commit f051c14.
In scope were the following files:
src/
├── base/
│ ├── BaseCustomAccounting.sol
│ ├── BaseCustomCurve.sol
│ ├── BaseHook.sol
│ └── BaseAsyncSwap.sol (renamed from BaseNoOp.sol)
├── fee/
│ ├── BaseDynamicFee.sol
│ ├── BaseOverrideFee.sol
│ └── DynamicAfterFee.sol
└── lib/
└── CurrencySettler.sol
System Overview
The OpenZeppelin Uniswap Hooks library offers various abstract contracts that can be inherited and used as a base to implement hooks, offering a structured foundation for performing specific actions across one or more Uniswap V4 liquidity pools.
BaseHook
This hook consists of the entire hook interface that is only callable from the PoolManager
contract. In addition, it requires the inheriting contract to specify the permitted hook calls in the getHookPermissions()
function and override the corresponding internal hook call functions. The last 14 bits of the deployed address need to have the right flags on for each hook permission granted. All of the following hooks inherit this base hook.
BaseDynamicFee
This hook consists of a single permission on afterInitialize
callbacks to update the dynamic LPFee inside the underlying pools to a value specified by the inheriting contract. Inheriting contracts need to override the _getFee
function in order to return a fee for each supported pool. In addition, anyone can poke pools that have been initialized with this hook contract to update its dynamic LPFee to the value returned by _getFee
.
BaseOverrideFee
This hook has permissions for the afterInitialize
and beforeSwap
callbacks. Initialization will be reverted if the pool is not set to have dynamic fees. Before each swap, an overridden fee value is provided to be used as the LPFee for that swap. Inheriting contracts specify the new fee amount in their _getFee
function.
DynamicAfterFee
This hook has the afterSwap
and afterSwapReturnDelta
permissions. After each exact-input swap, a fee denominated in the output token is donated to the pool. The amount of the donation is the difference between the output amount and a pre-specified target amount. Inheriting contracts need to have a way to set the target amount for each supported pool before each swap as this hook will reset the target amount each time.
BaseAsyncSwap
This hook, renamed from BaseNoOp
, has the beforeSwap
and beforeSwapReturnDelta
permissions. Prior to each exact-input swap, the entire specified amount will be taken by the hook and the swap logic in the underlying Uniswap V4 pool, including charging the LPFee and the protocol fee is completely skipped.
BaseCustomAccounting
This hook is expected to be used with only one pool. As a result, it has the permission for the beforeInitialize
callback, where it initializes its poolKey
state variable to the pool's address. This hook is meant to own the entire liquidity in the underlying pool and the liquidity modifications are only possible through this hook. In order to achieve that, the hook specifies the permissions for the beforeAddLiquidity
and beforeRemoveLiquidity
callbacks, which revert every time when anyone other than the hook itself tries to modify the liquidity through the PoolManager
contract.
Users can only provide liquidity through the hook directly, by calling either the addLiquidity
or the removeLiquidity
functions. Inheriting contracts need to override the _getAddLiquidity
and _getRemoveLiquidity
functions which allow for custom calculation logic to convert input amounts to liquidity. Inheriting contracts also need to override the _mint
and _burn
functions which allow custom logic to convert Uniswap amounts/liquidity to hook shares for hook users.
BaseCustomCurve
This hook inherits the BaseCustomAccounting
hook but overrides some of its already implemented internal functions such as _modifyLiquidity
and _unlockCallback
. In this hook, when liquidity is being added, the hook calls the mint
function of the PoolManager
contract to convert a custom amount of an ERC-20 token (specified by the virtual _getAmountIn
function) supplied by the caller to underlying ERC-6909 claim tokens in the hook's account and then mints shares for the caller. Similarly, when liquidity is being removed, it calls the PoolManager.burn
function to burn a custom amount (specified by the virtual _getAmountOut
function) of ERC-6909 tokens it owns, after which it withdraws the underlying ERC-20 tokens to the caller from the pool and finally burns the shares from the caller.
The inheriting contracts need to put custom calculations and access control for share conversion in the _mint
and _burn
functions. In addition, this hook has the beforeSwap
and beforeSwapReturnDelta
permissions, which allow the hook to zero out the specified amount for every swap and return a custom unspecified amount via the virtual _getAmount
function, hence achieving the effect of a custom curve on the swaps. The swaps are settled using the claim tokens owned by the hook contract.
Security Model and Trust Assumptions
The OpenZeppelin Uniswap Hooks library consists mostly of abstract contracts that are not meant to be deployed on their own. As such, the burden of security eventually falls on the inheriting contracts to add access control to sensitive functions, impose guards on external permissionless functions, and implement correct logic in overridden functions.
Integration Guide
There are several general security considerations that inheriting contracts should pay attention to.
Single Pool vs. Multiple Pools
A hook address can be used by any pool without the hook's consent unless it is rejected deliberately. If the hook intends to only interact with a single pool, consider explicitly restricting other pools from using the hook. If the hook intends to support multiple pools, particular care needs to be taken to avoid overriding pool states from multiple allowed pools. This would require the inheriting contract to separate the poolId
space for the relevant hook states.
Skipped Callbacks
The hooks library skips some permissioned hook callbacks when the caller is the hook itself. For example, if a hook initiates a call to the PoolManager.modifyLiquidity
function, the beforeAddLiquidity
and beforeRemoveLiquidity
hook callbacks will be skipped. If a hook initiates a swap, the beforeSwap
and beforeSwapReturnsDelta
callbacks will be skipped. Thus, if the inheriting contract intends to allow initiating calls to the PoolManager
, some of its permissioned callbacks may be skipped. However, its permissioned callbacks would still be active for other users calling PoolManager
directly.
Crafting unlock
Calldata
Hooks that intend to interact with the poolManager
contain an unlockCallback
function with user-defined input. This function will be called by the poolManager
on the hook contract. Depending on the calldata of the unlock function, this could be used to call any function on the hook. Hence, any inheriting contract should limit their users from being able to craft the unlock
calldata, which could expose unintended functions on the hook.
Hooks Modify Liquidity
When a hook attempts to modify liquidity inside a position in a pool, the returned delta contains fees accrued from that position. This affects the usual signs one would assume when adding liquidity. Hence, any logic processing the returned delta should consider all possible returned values. Furthermore, since the pool's current tick could be updated unfavorably before the liquidity actions, consider also having slippage protection in both cases. If the hook supports native token pools, it should be able to accept and settle native tokens.
Hooks Manage Liquidity
When a hook manages tokens with shares, it is particularly important to differentiate Uniswap liquidity from hook shares. Consider reserving the "liquidity" naming to refer to Uniswap liquidity exclusively and use other terms, such as "shares" to refer to any hook-owned share tokens. If a hook contract owns liquidity, there should exist functions to allow for the removal of locked tokens with either access control or correct accounting.
Dynamic Fee Hooks
Hooks supporting dynamic fees have to implement a function, which allows for setting a new fee for the swaps. If such a function relies on some external factors, which could be temporarily manipulated (e.g., liquidity or the amount of tokens inside a pool), it should have access control so that it is not possible to maliciously manipulate the fee.
Custom Swapping Logic
Since Uniswap V4 supports pools with native tokens, it introduces a possibility of reentrancy, either to the PoolManager
contract or to the hook itself. These scenarios should be taken into account while designing any custom swapping logic that may depend on the underlying token balance to avoid potential price manipulation.
Specific In-Scope Hook Notes
The DynamicAfterFee
hook donates the entire after-swap fee to the underlying pool. Since the donations are provided to all the positions including the current tick, it allows arbitragers to provide just-in-time liquidity before the swap to the pool, narrowly around the final price tick after the swap. As such, they would benefit from the donations, although the liquidity they provided was barely involved (or not involved at all) in the swap. This should be taken into account while inheriting from the DynamicAfterFee
hook.
Contracts inheriting from the BaseCustomAccounting
hook need to correctly implement the functions responsible for calculating the shares and liquidity modification parameters, such as _getRemoveLiquidity
and _getAddLiquidity
. These functions should take into account that the amounts of tokens in the underlying pool may change over time, which can introduce rounding errors.
The BaseAsyncSwap
and DynamicAfterFee
hooks only apply the special logic on the exact input swaps. On exact output swaps, they perform normal swaps, according to the Uniswap V4 logic. This could change the price and token amounts inside the underlying pool. If this is not the desired behavior, the inheriting contracts should also apply the custom logic to the exact output swaps.
Critical Severity
Non-Explicit Multiple-Pool Support Allows Overwriting Hook State
The BaseCustomAccounting
hook is meant to only support a single UniswapV4 pool defined by the poolKey
hook state variable. The poolKey
is initialized in the _beforeInitialize
hook function when the pool is created and is the only place where the poolKey
variable may be set.
However, the poolKey
variable can be overwritten when this hook contract instance is registered for another pool. Due to the underlying asymmetry between pool-hook awareness, anyone can use any valid hook address to any pool without the hook's consent unless it is deliberately rejected by the hook. As a result, all the liquidity provided by users via the hook to the initial pool will be locked, as the BaseCustomAccounting
contract will now only operate on a newly registered pool, which can be again overwritten in the future.
In the _beforeInitialize
function, consider ensuring that only one pool can be registered, and revert for any subsequent calls if a hook intends to support only one pool. In addition, for each hook, consider clearly documenting the underlying non-explicit multiple pool support whereby a hook is allowed to be registered for multiple pools. This could help the inheriting contracts be cautious about any hook states being unintentionally overwritten from multiple linked pools.
Update: Resolved in pull request #31 at commit 388fcfb.
High Severity
Hooks Do Not Support Native Tokens
Uniswap V4 pools support native tokens, such as ETH, without needing to wrap them. The CurrencySettler
library also supports native-token transfer inside its settle
function which is called by the hooks whenever there is a need to transfer tokens to the Uniswap V4's PoolManager
contract. In the case of native token transfers, the specified amount of native token is transferred using the payable
PoolManager.settle
function.
However, none of the hook contracts is capable of receiving native currency. This means that the hook contract will always have 0 native token balance. As a result, pools containing ETH as one of the tokens will not be supported. For example, it will not be possible to provide liquidity to pools through the BaseCustomAccounting
hook.
Consider allowing hooks to receive correct amounts of native currency so that it can be used for the settle
calls to the PoolManager
contract.
Update: Resolved in pull request #32 at commit 4be1870.
Insufficient Slippage Check
The BaseCustomAccounting
hook requires users to modify the associated UniswapV4 pool's liquidity only via the hook itself. The liquidity may be modified by using the permissionless external addLiquidity
and removeLiquidity
functions which interact with the UniswapV4 PoolManager
's modifyLiquidity
function. The slippage checks on these two functions are insufficient because such liquidity-altering functions can be front-run by swaps which could change the pool's current tick, affecting the amount of currency0
and currency1
required or returned for the liquidity action.
In particular, there is no slippage check when removing liquidity. Furthermore, while there is a slippage check when adding liquidity, it also includes the fees accrued from existing positions, as the delta returned by the PoolManager.modifyLiquidity
function is a sum of the principal delta and the fees accrued. In the case when the fees accrued are greater than the absolute value of the principal delta, the resulting delta amount may be positive. Thus, unsafe casting of the negation would result in a large positive value, entirely bypassing the slippage check.
Consider performing a slippage check when adding or removing liquidity using only the principal delta.
Update: Resolved in pull request #33 at commit 6887feb.
Medium Severity
Misleading Comments
Throughout the codebase, multiple instances of misleading comments were identified:
- This comment along with this comment suggests that the fee returned by the
_getFee
function should be denominated in hundredths of a percent. However, inside Uniswap V4, the fee is represented in hundredths of a bip, which is a unit 100 times smaller than the one specified in the comment. - This comment implies that the
onlyValidPools
modifier ensures that a function using it is only callable by valid pools, whereas, in reality, it does not validatemsg.sender
. Instead, it verifies that such a function is called for a valid pool. - This comment states that the
LockFailure
error is thrown when the hook is not unlocked. However, it is not clear what unlocking means in terms of a hook, and the error is thrown when a call inside theunlockCallback
fails with no error data. The description of theLockFailure
error could be changed to better reflect this use case. - This comment suggests that in the
_mint
function, the liquidity units are minted to the sender. However, theparams
argument passed to that function contains theto
member, which is meant to be used as the liquidity units' recipient instead of themsg.sender
. The comment could be changed to reflect that the liquidity units are minted to theparams.to
address, or alternatively, thatAddLiquidityParams.to
could be removed if it is not meant to be used. - This comment and this comment do not mention all the permissions of the underlying hooks.
- The overriding
_getAddLiquidity
function does not return an encoded version ofModifyLiquidityParams
as specified by the comments above the_getAddLiquidity
virtual
function. The same is true for the_getRemoveLiquidity
function. - This comment suggests that liquidity is either added or removed from the pool in the
BaseCustomCurve._unlockCallback
function. However, no Uniswap V4 pool liquidity is involved in the process, only the tokens are being sent to and from thePoolManager
contract. - This comment states that the delta returned by the
BaseCustomCurve._modifyLiquidity
function originates from thePoolManager
. However, the delta is determined without any interaction with thePoolManager
. - This comment implies that upon exact-input swaps,
amount0
is specified andamount1
is unspecified. However, this is not necessarily true as it also depends on the value ofzeroToOne
. The same thing is also true for this comment.
Consider correcting the aforementioned comments to improve the clarity and maintainability of the codebase.
Update: Resolved in pull request #34 at commit a40dd63.
Dynamic After Swap Fees May Not Work as Intended
The DynamicAfterFee
contract introduces an after-swap fee, which is calculated based on the swap delta and on the target delta specified before the swap. However, users can easily avoid paying the fee by specifying the desired output token amount, as the fee-charging logic only works for the exact-input swaps.
Furthermore, the target delta is reset after each swap and there is no built-in way of updating it on this contract. There could be some unintended consequences if it is not updated promptly. For example, it is possible to front-run a swap with a tiny input amount, which would result in the entire output amount of the swap made by the subsequent user being consumed as a fee. When the targetDelta
is updated in a transparent manner before each swap (e.g., in a beforeSwap
hook), it is possible to set a swap amount to be less than the target delta in order to entirely avoid paying the after-swap fee.
Consider imposing a fee on exact-output swaps as well, possibly via a beforeSwap
hook function. Moreover, consider providing comprehensive documentation about the target delta, particularly about when and how it should be updated.
Update: Resolved in pull request #34 at commit 081d92a.
Unsafe Casting Due to Accrued Fees
When adding or removing liquidity from the BaseCustomAccounting
hook, the hook initiates calls to the PoolManager.modifyLiquidity
function. The returned delta is a sum of both principalDelta
and feesAccrued
. Hence, it is a legitimate scenario that when adding liquidity to an existing position, the returned delta
does not necessarily have negative values for both amount0
and amount1
. Depending on the amount of accrued fees as well as the liquidity to be minted, one can end up with amount0
and amount1
either being positive or negative. Hence, when one of these amounts is positive, this casting will result in wrong values. Two scenarios may occur:
- The wrongly cast
uint256(int256(-amount))
to be settled can be very large whenamount
is a small positive value, and it may revert when transferring this large amount fails. - When the accrued fee is large enough (for a large amount of liquidity) and the wrongly cast value can be transferred, this will result in users paying way more instead of receiving a handsome fee.
Consider adding additional logic to accommodate this legitimate scenario without assuming it. This can be achieved by checking the sign of the returned delta amount directly, and calling take
when it is positive and calling settle
when it is negative.
Update: Resolved in pull request #33 at commit 6d1e94b.
BaseDynamicFee
Hook Can Be Poked Arbitrarily
The BaseDynamicFee
hook contract has a virtual
external
poke
function that allows any pool initiated with this hook to (re)set an updated LPFee by anyone at any time. The updated LPFee comes from the _getFee(poolKey)
virtual function, which could return any fee based on either the current pool state or any other external conditions. However, if the _getFee
implementation depends on some external factors, it opens up the possibility of manipulating the LPFee and swapping in a single transaction.
For example, suppose that _getFee
for a V4 pool is dependent on the token balance of a Uniswap V3 pool having the same token pair. Suppose also that the returned fee for the V4 pool is inversely correlated to the corresponding V3 pool token balance. That is, _getFee
returns a lower value when the V3 pool has a large balance and otherwise returns a higher value. Now, an attacker could take a flash loan, deposit into the V3 pool temporarily, and then call poke
on the V4 pool. Since the V3 pool balance would have increased, the swap fee will drop and the attacker can pay lesser fees for subsequent swaps. If the subsequent users do not poke
in advance, they can enjoy cheap fees without having to do any manipulation.
Consider imposing access control on the poke
function so that it cannot be called arbitrarily. Otherwise, the poke
function can be made internal
, allowing the inheriting contracts to decide how to appropriately incorporate it with their logic. Alternatively, consider updating the documentation of the poke
function so that the risk described above is clear to the inheriting contracts.
Update: Resolved in pull request #34 at commit 7e81272.
BaseAsyncSwap
Operation Is Unclear
The BaseAsyncSwap
hook skips any swap actions in the Uniswap pool by minting the corresponding ERC-6909 claim tokens to itself. However, this only applies to exact input swaps. There is no documentation describing how this amount in the hook can be distributed, used, withdrawn, or refunded back to the user. Consider adding more documentation regarding this or indicating a clear intention for virtual
functions to be overridden by inheriting contracts.
Furthermore, the BaseAsyncSwap
hook can receive ERC-6909 tokens on each exact input swap from multiple pools. However, it does not implement any mechanism to separate the same tokens received from different pools for different users. For example, it could record the amount of tokens, the address of the pool, and the address of the user who initiated the swap in order to be able to perform subsequent logic related to the owned tokens.
In addition, the BaseAsyncSwap
hook can be skipped by specifying the output amount in a swap. Thus, to implement the corresponding logic for exact-output swap, the beforeSwapDelta
callback needs to return a negative amount to zero out the output amount. In this case, the hook has to settle the corresponding amount. This may require some additional mechanism to correctly account for the inflow and outflow of the tokens involved.
Consider having a clearer utility for the BaseAsyncSwap
hook so that the inheriting contracts can clearly identify the use case for incorporating their own logic.
Update: Partially resolved in pull request #34 at commit 1605117. The documentation has been extended, but the address of the user who initiates a swap is still not recorded and the hook still works only on exact input swaps. There are plans to modify this contract in the future in order to resolve these concerns.
Unintuitive Return Delta
The BaseCustomCurve
hook allows users to add and remove liquidity for a particular pool, but only via the hook itself. It also only allows swapping with the available liquidity in the hook. This hook inherits the BaseCustomAccounting
and overrides its implementations of the _modifyLiquidity
and _unlockCallback
functions in order to reuse the logic in the addLiquidity
and removeLiquidity
functions.
The overridden _unlockCallback
function always returns two negative amounts in its delta regardless of whether it is called from addLiquidity
or removeLiquidity
. This behavior is unintuitive and could lead to mistakes when the delta is consumed by the to-be-implemented _mint
or _burn
functions. In fact, the _mint
function expects the returned delta to have the same sign as poolManager.modifyLiquidity
(i.e., negative amounts if not considering fees) while the _burn
function expects the returned delta to have positive amounts.
Consider returning positive amounts when removing liquidity to be consistent with the signs expected by the inheriting contracts. This is so that any inheriting contracts can implement the _mint
and _burn
functions in a more intuitive way.
Update: Resolved in pull request #36 at commit 45edf24.
Low Severity
Redundant virtual
Function Modifier
The validateHookAddress
function of the BaseHook
contract ensures that the hook has the expected permissions. This function is based on the validateHookAddress
function from Uniswap's v4-periphery
repository and has the virtual
modifier. This allows the contracts inheriting from the BaseHook
to override the function. However, the virtual
function modifier was introduced to the validateHookAddress
function from the v4-periphery
repository in order to facilitate testing and is not necessary otherwise.
Consider removing the virtual
modifier from the validateHookAddress
function in order to improve the readability of the codebase and avoid situations where the function is incorrectly overridden.
Update: Resolved in pull request #35 at commit 1c1f186.
Insufficient Documentation
Throughout the codebase, multiple instances of insufficient documentation were identified:
- The values returned by the
_getAddLiquidity
and_getRemoveLiquidity
functions of theBaseCustomAccounting
contract are passed to thePoolManager.modifyLiquidity
function as arguments. In order to prevent users from being able to withdraw liquidity specified by different users, these arguments (specifically, theModifyLiquidityParams
object) should contain the salt uniquely set for each liquidity provider. Otherwise, when the liquidity is removed, anyone could specify the liquidity to remove and the tick range, and as long as they have enough liquidity units inside the hook, they could remove liquidity provided by someone else, possibly stealing the accrued fees. This fact could be documented in the documentation for the_getAddLiquidity
and_getRemoveLiquidity
functions. - The
DynamicAfterFee
contract allows for imposing an additional fee which will be accounted for after the swaps. The fee is based on the target and actual tokens' delta values and is donated to the underlying pool. Whenever a donation is made to the pool, it is awarded for all the in-range liquidity providers. It could be exploited by an attacker, who could provide just-in-time liquidity to a narrow range around the final tick after the swap. Such liquidity would barely be involved (or not involved at all) in a swap, but the attacker would receive a huge part of the donation. Consider documenting this possibility, perhaps in a way similar to how it is done in Uniswap V4. - The
BaseCustomCurve
contract would benefit from additional documentation. In particular, thetake
andsettle
operations inside the_beforeSwap
and_unlockCallback
functions could be explained in more detail so that the operations they perform are easier to understand. - The
unlockCallback
function of theBaseHook
contract does not provide any documentation for its returned value. Furthermore, severalinternal
functions, such as the_getAmountOutFromExactInput
function of theBaseCustomCurve
, contract do not document their parameters or their return values. - The intended use cases for the
BaseAsyncSwap
andDynamicAfterFee
hooks are not included in their documentation, and in order to use them properly, the implementing contracts will need to define additional functions. Consider documenting the intended use cases of these contracts, possibly providing a tangible example of how they could be used. Moreover, consider providing example implementations of the functions that will need to be implemented by inheriting contracts.
Consider fixing all the instances of insufficient documentation listed above in order to improve the clarity and maintainability of the codebase.
Update: Resolved in pull request #35 at commit 315e0e4.
Insufficient Arguments
As all the hooks from the library are meant to be general-purpose template hooks, some virtual
functions from these hooks that are meant to be overridden by inheriting contracts can benefit from having more arguments. Some examples are:
- In the
BaseDynamicFee
hook, the_getFee
virtual
function could havecurrentTick
andsqrtPricex96
parameters. - The
feesAccrued
delta returned from thePoolManager.modifyLiquidity
call could be returned by the_modifyLiquidity
function and passed on to the_mint
and_burn
virtual
functions. - In the
BaseCustomAccounting
hook, asalt
value could be added to theAddLiquidityParams
struct so that it could be passed to the_getAddLiquidity
function in order to allow multiple positions on the same tick range for the same user. Similarly, asalt
member could be added to theRemoveLiquidityParams
struct.
Consider implementing the changes suggested above in order to provide a broader and more general functionality for the hooks.
Update: Partially resolved in pull request #35 at commit 4be1870. The first suggestion hasn't been implemented as the currentTick
and sqrtPricex96
values can be fetched from the pool.
BaseHook's unlockCallback
Not Needed
A minimal hook does not need to have an unlockCallback
function since only the hooks intending to initiate calls to the PoolManager
contract would need to unlock it. Hence, it is not necessary to have the unlockCallback
function and the internal
_unlockCallback
function in the BaseHook
implementation.
In addition, the _unlockCallback
function uses a generic low-level call and is overridden by all contracts in scope, as it is hard to use it without utilities for encoding/decoding arguments to interact with the Pool Manager. Furthermore, leaving the current default unlockCallback
implementation may have unintended consequences of opening up a way to re-enter the hook for inheriting contracts.
Consider removing the unlockCallback
function from the BaseHook
contract and instead allowing the inheriting contracts to add this function if needed.
Update: Resolved in pull request #35 at commit 9ee7d09.
Notes & Additional Information
Typographical Errors
Throughout the codebase, multiple instances of typographical errors were identified:
- In line 22 of
BaseCustomAccounting.sol
, "implementator" should be "implementer" and "Aditionally" should be "Additionally". - In line 40 of
BaseCustomAccounting.sol
, "An" should be changed to "A". - In line 13 of
CurrencySettler.sol
, "make" should be changed to "may" or "may make".
Consider correcting all instances of typographical errors in order to improve the clarity and readability of the codebase.
Update: Resolved in pull request #36 at commit 130875c.
Non-Existent Named Return Variable
The documentation of the unlockCallback
function refers to the delta
return parameter, but such a return parameter does not exist in this function.
Consider introducing a return parameter named delta
to the unlockCallback
function in order to comply with the documentation.
Update: Resolved in pull request #35 at commit 9ee7d09. This item was addressed by removing unlockCallback
in finding L-04.
Optimizable State Reads
In the BaseCustomCurve
contract, the poolKey
storage reads could be optimized by caching the poolKey
and operating on a memory
object instead.
Consider reducing unnecessary SLOAD operations and saving gas by caching the value of the poolKey
variable in a memory
variable.
Update: Resolved in pull request #36 at commit 24d8c6c.
Unused Modifier
The onlySelf
modifier that is defined in the BaseHook
contract is not used anywhere in the codebase.
To improve the overall clarity, intentionality, and readability of the codebase, consider either using or removing any unused modifier.
Update: Acknowledged, not resolved. The team stated:
This was implemented as helpers, similar to the functionality found in
onlyValidPools
.
Lack of Security Contact
Providing a specific security contact (such as an email or ENS name) within a smart contract significantly simplifies the process for individuals to communicate if they identify a vulnerability in the code. This practice is quite beneficial as it permits the code owners to dictate the communication channel for vulnerability disclosure, eliminating the risk of miscommunication or failure to report due to a lack of knowledge on how to do so. In addition, if the contract incorporates third-party libraries and a bug surfaces in those, it becomes easier for their maintainers to contact the appropriate person about the problem and provide mitigation instructions.
Throughout the codebase, none of the contracts has a security contact.
Consider adding a NatSpec comment containing a security contact above each contract definition. Using the @custom:security-contact
convention is recommended as it has been adopted by the OpenZeppelin Wizard and the ethereum-lists.
Update: Acknowledged, not resolved. The team stated:
Implementers should add their own security contacts.
Different Pragma Version From PoolManager
The all the base contracts in scope use a floating pragma (^0.8.24
).
Consider having a fixed pragma version that is the same as the one used in the PoolManager
contract, which is 0.8.26. This will help improve consistency and avoid unexpected compiler behavior.
Update: Acknowledged, not resolved. The team stated:
This codebase is using
IPoolManager
which has ^0.8.24, matching our pragma versions.
Naming Suggestions
Throughout the codebase, multiple opportunities for improved naming were identified:
- The manager variables inside the
CurrencySettler
library could be renamed topoolManager
, as that name is used to refer to the Uniswap V4 monolithicPoolManager
contract in other in-scope contracts. - The
DynamicAfterFee
contract name could be changed toBaseDynamicAfterFee
to better reflect the fact that it refers to an abstract contract and to be consistent with other abstract contracts' names. - The variables named
liquidity
related to the internal hook's liquidity units could be renamed toshares
and the relevant comments updated in order to make a clear distinction from the Uniswap V4 liquidity. - The
amountIn
parameter of the_getAmount
function refers to the amount of the specified token, which does not necessarily have to be the input token. In fact, the same parameter is calledamountOut
in the_getAmountInForExactOutput
function. Consider renaming theamountIn
parameter of the_getAmount
function toamountSpecified
.
Consider implementing the above-mentioned naming suggestions to improve the readability and clarity of the codebase.
Update: Resolved in pull request #36 at commit 9cef475.
Functions Are Updating the State Without Event Emissions
Throughout the codebase, multiple instances of functions updating the state without an event emission were identified:
- The
_beforeInitialize
function inBaseCustomAccounting.sol
. - The
constructor
function inBaseHook.sol
.
Consider emitting events whenever there are state changes to improve the readability of the codebase and make it less error-prone.
Update: Acknowledged, not resolved. The team stated:
This will not be fixed in this codebase in order to allow implementers to add their own events, if necessary.
Unnecessary Restriction On the _getAmount
virtual
Function
The _getAmount
function of the BaseCustomCurve
hook contains the entire logic for calculating the swap amounts, which should also include logic for the fee mechanism, input amount, token computation, swap direction, etc. Thus, this function could be complex in its general form. However, the impact of the parameters and internal restrictions on how to implement this virtual
function may cause unnecessary confusion.
The parameters of the _getAmount
function are not succinct. If the input
and output
currencies are specified, there is no need to specify zeroForOne
. In fact, it is not necessary to specify input
and output
currencies at all as this contract, by inheriting the BaseCustomAccounting
, is meant to only work with one Uniswap V4 pool. Thus, it is enough to only have the zeroForOne
parameter to determine the swap direction. It would be enough to have all the original swap parameters passed in. Furthermore, it is unnecessary to impose restrictions on the logic by splitting the function based on exactInput
. As a complex function, the inheriting contracts may need to break it down anyway according to how it suits the implementation.
Consider passing in the original IPoolManager.SwapParams
directly to the _getAmount
function and remove the unnecessary restrictions (i.e., the _getAmountOutFromExactInput
and _getAmountInForExactOutput
virtual
functions). Consider also documenting the need for fee mechanism since the swapping needs to happen inside the hook.
Update: Resolved in pull request #36 at commit 31a8430.
Conclusion
The audited codebase provides base contracts that can be inherited to implement customized hooks for UniswapV4 pools. As such, it is important for the inheriting contracts to implement all the required virtual
functions correctly. The guidelines and potential risk areas have been included in the introduction of this report.
The codebase could benefit from further development and additional documentation aimed at improving the utility, clarity, and security of the current and any additional hook archetypes. In particular, a more comprehensive testing suite could be implemented in order to test a wider range of interactions between the hooks and the PoolManager. Furthermore, the BaseAsyncSwap
and DynamicAfterFee
hook contracts could be extended with an additional code, responsible for handling all use cases, such as swaps specifying the exact output amount, in order to provide a complete base functionality for the inheriting contracts. Finally, example practical hook implementations utilizing the base hook contracts could be provided in order to demonstrate their potential use cases and the way on how their virtual functions could be overridden in a safe way.