- August 27, 2024
OpenZeppelin Security
OpenZeppelin Security
Security Audits
Table of Contents
- Table of Contents
- Summary
- Scope
- System Overview
- Security Model and Trust Assumptions
- Critical Severity
- Medium Severity
- Low Severity
- Notes & Additional Information
- Inconsistent Use of Multiple Solidity Versions
- Lack of memory-safe Annotation in Assembly Blocks
- Discrepancy Between Implementation and Specification of ERC-6909
- Code Clarity
- Missing Function Parameters Names
- Discrepancies Between Interfaces And Implementation Contracts
- Unused Named Return Variables
- Unused Imports
- Unused Error
- Magic Numbers
- State Variable Visibility Not Explicitly Declared
- Missing Named Parameters in Mappings
- Lack of Indexed Event Parameter
- Missing Docstrings
- Incomplete Docstrings
- Lack of Security Contact
- Client Reported
- Conclusion
Summary
- Type
- DeFi
- Timeline
- From 2024-05-27
- To 2024-06-21
- Languages
- Solidity, Yul
- Total Issues
- 24 (18 resolved, 2 partially resolved)
- Critical Severity Issues
- 1 (1 resolved)
- High Severity Issues
- 0 (0 resolved)
- Medium Severity Issues
- 3 (2 resolved)
- Low Severity Issues
- 3 (2 resolved)
- Notes & Additional Information
- 16 (12 resolved, 2 partially resolved)
- Client Reported Issues
- 1 (1 resolved)
Scope
We audited the Uniswap/v4-core repository at commit d5d4957.
The following files were new and therefore were fully audited:
src
│ ERC6909.sol
│ ERC6909Claims.sol
│ Extsload.sol
│ Exttload.sol
│ NoDelegateCall.sol
│ PoolManager.sol
│ ProtocolFees.sol
├── interfaces/
│ ├── IHooks.sol
│ ├── IExtsload.sol
│ ├── IExttload.sol
│ ├── IPoolManager.sol
│ ├── IProtocolFeeController.sol
│ ├── IProtocolFees.sol
│ ├── external/IERC20Minimal.sol
│ ├── external/IERC6909Claims.sol
│ └── callback/IUnlockCallback.sol
├── libraries/
│ ├── CurrencyDelta.sol
│ ├── CustomRevert.sol
│ ├── FixedPoint128.sol
│ ├── FixedPoint96.sol
│ ├── Hooks.sol
│ ├── LPFeeLibrary.sol
│ ├── LiquidityMath.sol
│ ├── Lock.sol
│ ├── NonZeroDeltaCount.sol
│ ├── ParseBytes.sol
│ ├── Pool.sol
│ ├── ProtocolFeeLibrary.sol
│ ├── Reserves.sol
│ ├── SafeCast.sol
│ ├── StateLibrary.sol
│ ├── TransientStateLibrary.sol
│ └── UnsafeMath.sol
├── types/
│ ├── BalanceDelta.sol
│ ├── BeforeSwapDelta.sol
│ ├── Currency.sol
│ ├── PoolId.sol
│ ├── PoolKey.sol
│ └── Slot0.sol
On the other hand, the following files were checked only for their difference against the version present in the Uniswap/v3-core repository at commit d8b1c63.
├── libraries/
│ ├── BitMath.sol
│ ├── FullMath.sol
│ ├── Position.sol
│ ├── SqrtPriceMath.sol
│ ├── SwapMath.sol
│ ├── TickBitmap.sol
│ └── TickMath.sol
System Overview
Uniswap v4 is an automated market maker (AMM) which uses the concentrated liquidity model by default but can be customized to use any other model. Such customizations are possible because of the hooks that are executed before and after every user action. Other significant changes compared to v3 include singleton architecture, flash accounting, dynamic liquidity providers fee, donation to liquidity providers, and native token support. Finally, the new version uses several techniques to reduce gas usage. The entry point to the Uniswap v4 core is the singleton PoolManager contract which contains all the pools within itself. This contrasts with the previous versions of the protocol which had the factory contract deploying individual pools as separate contracts.
Hooks are functions of the Hooks contract which is specified at the pool initialization time. These functions are called by the PoolManager before and after each swap, position modification, donation, and pool initialization. The available hooks are beforeInitialize and afterInitialize, which are called during pool initialization, beforeAddLiquidity, afterAddLiquidity, beforeRemoveLiquidity and afterRemoveLiquidity which are called when changing liquidity positions, beforeDonate and afterDonate, which are called when donating assets to LPs, and beforeSwap and afterSwap, which are called when performing swaps. Some hooks, namely beforeSwap, afterSwap, and afterModifyLiquidity, return parameters which might influence the PoolManager's control flow. The lower bits of the hook address decide which hooks are enabled. Thus, to have a particular set of hooks enabled, one must brute-force the address whose lower bits are the desired bit sequence.
Flash accounting means that all operations within the protocol are performed on transient storage and should be settled only at the end of the transaction. Settlement can be accomplished by either ERC-20 token transfers or ERC-6909 accounting. The latter allows users to keep their token inside Uniswap v4 for future use which is cheaper in terms of gas compared to ERC-20 transfers. One consequence of the flash accounting mechanism is free flash loans. Furthermore, since all the pools reside in the singleton contract, it is possible to flash borrow the whole balance of a token at once.
The liquidity provider fee is now more customizable and can be of either static or dynamic type which is specified at the pool initialization time. Both fees can take values from 0 to 100% with a precision of 0.01%. The static fee is set once and cannot be changed whereas the dynamic fee can be changed by the hook at any time, either for all transactions or per transaction. Note that a hook might charge its own fees from users and liquidity providers on swaps and position modifications which is done via a separate mechanism. The donate function can be used to send tokens to the singleton contract and distribute those to the liquidity providers whose liquidity is currently in use. Native tokens like ETH are supported, allowing users to avoid wrapping and unwrapping tokens. Gas optimizations include the already mentioned use of transient storage as well as more widespread techniques like extensive use of assembly, custom packed types (both in-storage and in-memory), low-level memory management and unchecked math.
Whether it is adding or removing liquidity, doing swaps, or donating, the user always needs to call the unlock function first. This function will set an unlock variable to true and will call back the msg.sender at a specified function. This callback is what is actually used by the caller to call either modifyLiquidity, donate or swap. Since these operations might produce changes in the amount of assets owed to the protocol or the user through the use of balance deltas, once the callback is done, the unlock function will perform a check on those deltas and will make sure that they have been zeroed out before finishing the transaction. In this way, the protocol ensures that no debt or credit is left behind.
Because of all this, the take and settle functions are needed. The take function will withdraw assets from the protocol so that any asset owed to the user is given to them. Conversely, the settle function will ensure that the user transfers to the protocol all the assets owed to it. If one does not want to take or settle, the ERC-6909 mint and burn functions can be used instead with the same desired effect of balancing the deltas.
Finally, the sync function is used to update the current protocol balance of any particular asset. This is necessary to be performed before transferring tokens to the protocol and calling the settle function.
Security Model and Trust Assumptions
The following observations were made regarding the security model and trust assumptions of the audited codebase:
-
The core contract does not have slippage protection and assumes that the periphery contracts implement it. Hence, having slippage protection on the periphery contracts is essential.
-
ERC-20 tokens with non-standard implementation including rebasing tokens, double entry point tokens, and so forth or outright malicious tokens are not supported by the protocol and will lead to vulnerabilities in the pools they are used in.
-
Malicious hooks might steal tokens from users and liquidity providers in multiple ways. A variety of user risks can be mitigated by implementing slippage protection on periphery contracts, but liquidity provider risks are not mitigated in the same manner. Thus, liquidity providers should examine hooks more closely and put more trust into them. Even though hooks are set at initialization time, they might be upgradeable, and in this case, be able to change their code at later point in time.
-
Since Uniswap v4 is permissionless anyone can create a pool with any token. It is up to the periphery contracts, users and liquidity providers to examine and decide which pools they want to use. The core contract, however, limits the impact of malicious tokens and malicious hooks on the pool they are used in by ensuring that each function operates only within the particular pool and separating the accounting of each pool in storage, separating also the balance deltas produced by hooks and by users.
Privileged Roles
On the protocol level, there are two privileged roles which are trusted to not behave maliciously: the protocol fee controller and the owner. The former can set the protocol fee on any pool but the fee is capped at 0.1%. It also can collect accumulated protocol fees to an arbitrary address. The latter can change the protocol fee controller.
On the individual pool level, the hook can change the liquidity provider fee but only if the pool was initialized with the dynamic fee in the first place.
Critical Severity
ERC-20 Representation of Native Currency Can Be Used to Drain Native Currency Pools
The settle function, responsible for settling a user's debt, increases the account delta of the specified currency. There are two settlement flows: one for the native currency and another for all other currencies. If the currency is native, the amount used for increasing the delta is msg.value. Otherwise, if the currency is a regular ERC-20 token, the amount is the balance difference between the last time sync or settle were called and the current settle invocation.
This implementation is vulnerable to attacks if the given token has two addresses. This is particularly dangerous since the protocol supports native tokens and operates on chains where the native token has a corresponding ERC-20 token. Examples of such chains include Celo with CELO, Polygon with MATIC, and zkSync Era with ETH. There is a caveat concerning MATIC on Polygon and ETH on zkSync Era: while it is possible to manipulate balance deltas, we have not found a way to withdraw them from the pool since the former has the ERC-20 transfer function that fails if msg.value != value and the latter does not have the ERC-20 transfer function at all. However, there is a way to withdraw CELO on Celo from the pool.
To demonstrate the vulnerability, we will use the CELO token as an example. The attack consists of two phases: manipulate the balance deltas and take the tokens out of the pool using these deltas.
The attacker starts with 1000 CELO. To manipulate the balance deltas to their advantage, they would perform the following steps within a single transaction.
- Call
manager.sync(0x471ece3750da237f93b8e339c536989b8978a438), where0x471ece3750da237f93b8e339c536989b8978a438is the address of the CELO token andmanageris thePoolManagercontract. This loads the current CELO balance ofmanagerto transient storage. - Call
manager.settle{value: 1000}(address(0x0)), which increases theaddress(0x0)balance delta of the attacker by 1000 and transfers 1000 CELO tomanager. - Call
manager.settle(0x471EcE3750Da237f93B8E339c536989b8978a438), which increases the0x471EcE3750Da237f93B8E339c536989b8978a438balance delta of the attacker by 1000 without any need of transferring tokens since the balance of the CELO token has increased in step 2.
At this point, the attacker has increased their balance deltas by 2000 for 1000 CELO tokens. To take out the profit, they would perform the following steps within the same transaction.
- Call
manager.take(0x471EcE3750Da237f93B8E339c536989b8978a438, address(0x1337), 1000), whereaddress(0x1337)is the attacker's address. This decreases the0x471EcE3750Da237f93B8E339c536989b8978a438balance delta by 1000 and transfers 1000 CELO tokens to the attacker via the ERC-20transferfunction. - Call
manager.take(address(0x0), address(0x1337), 1000), which decreases theaddress(0x0)balance delta by 1000 and transfers 1000 CELO tokens to the attacker via the value attached to the call. Even though there might be no pool with the native currency, this step still works because thetakefunction just performs a call with a value attached. Since the CELO balance is there, the call succeeds.
The attacker has 2000 CELO and zero balance deltas, allowing them to finish the transaction with a profit of 1000 CELO. By repeating the steps above, it is possible to completely drain the native currency pool.
Note that, at first glance, a native token with an ERC-20 representation might look like a double entry point ERC-20 token from the perspective of this issue. However, there is an important difference: double entry point ERC-20 tokens, along with other non-standard ERC-20 tokens, are not supported by Uniswap v4, as explicitly stated by the Uniswap team. Native tokens, however, are supported since they are considered safe and are preferred due to gas savings.
Consider changing the way native currency pools work on chains where the native currency has a corresponding ERC-20 token. For example, make the NATIVE variable immutable and set it to the ERC-20 token address for chains where native currency has a corresponding ERC-20 token.
Update: Resolved in pull request #779. The team re-worked how sync and settle work. Whenever a sync is called, the very next settle call will settle the previously synced currency, otherwise the transaction will revert. This makes this attack impossible now, because one wouldn't be able to settle the native currency (point 2) before settling the corresponding ERC-20 version (point 3).
Medium Severity
Unsafe Assembly Blocks
Throughout the codebase, some assembly blocks that are marked as safe do not follow the Solidity memory model outlined in the Memory Safety section of the Solidity documentation. This might lead to incorrect and undefined behavior.
- In
Hooks.sol, on lines 138-139, the return data can be longer than 64 bytes, thus potentially overriding the free memory pointer. This might happen if hooks return more than 64 bytes. For example, thebeforeSwaphook returns three values, which means that it will require32 * 3 = 96 bytesas ABI-encoding allocates 32 bytes for each value, which exceeds the scratch space. - In
TickBitmap.sol, on lines 55-60, the error signature and parameters take 96 bytes, which is more than the scratch space. - In
CustomRevert.sol, on lines 45-63, the error signature and parameters take 96 bytes, which is more than the scratch space. - In
Currency.sol, on lines 52-53, the top 4 bytes of the free memory pointer are overridden.
Consider removing the memory-safe annotations from assembly blocks that do not follow the Solidity memory model. Alternatively, consider adjusting the assembly blocks to follow the memory model.
Update: Resolved in pull request #759.
ProtocolFeeController Gas Griefing
The ProtocolFees contract implements the logic of retrieving a fee from the protocolFeeController external contract. The call to protocolFeeController is limited by the defined controllerGasLimit value. In case the call fails (e.g., by consuming all the gas), the returned value of the fee is 0. However, a malicious protocolFeeController can execute a gas griefing attack by returning a large amount of data that will be implicitly loaded into memory, thereby significantly increasing the gas cost or even preventing the transaction from executing. Considering how important it is for the Uniswap team to build unstoppable and permissionless code, this might represent a threat to the protocol.
Consider using assembly to execute the call and manually copy only 32 bytes from the return data to memory.
Update: Resolved in pull request #771 and pull request #825.
Front-Running Pool's Initialization or Initial Deposit Can Lead to Draining Initial Liquidity
The pool's sqrtPriceX96 can be manipulated either by front-running the initialization of the pool or by an initial deposit of liquidity. The initialize function allows initializing a pool whose stored ID is obtained by conversion of the PoolKey struct that is passed as an argument in the function call. Since the sqrtPriceX96 is never used for the ID calculation, anyone can front-run the initialization function call and provide their own value for sqrtPriceX96, which will then be used for setting the tick.
Another way to manipulate the sqrtPriceX96 is by front-running the initial deposit of a recently initialized pool. The swap function does not check for liquidity before a swap occurs and, if this happens, the sqrtPriceX96 value stored will change. Consequently, a malicious actor can move the sqrtPriceX96 of the pool to any value if the current position has zero liquidity. As a result, to take advantage of the LP's liquidity, an attacker could manipulate the sqrtPriceX96 right after the pool initialization and before any deposit is done, establishing a different price in the pool that does not reflect the market price, thereby opening it up to arbitrage opportunities.
The aforementioned two cases lead to an exploitation scenario that allows draining the initial liquidity if it is performed on a separate transaction from the one that initializes the pool. In the case of front-running the initial liquidity deposit, consider the following scenario:
- The LP creates a new pool and sets
sqrtPriceX96to79228162514264337593543950336(1 tokenAper1 tokenB). - The LP calls
modifyLiquidityto add liquidity to the pool for the price range0.5 <=> 3. - The attacker calls the swap function to move the
sqrtPriceX96to a random high value before the LP'smodifyLiquiditytransaction is executed. - The LP's
modifyLiquiditytransaction is then executed and liquidity is added. - Now, the attacker can swap
1 tokenAfor3 tokenBusing the LP's liquidity.
In the case of front-running the pool's initialization, one could front-run step 1 of the above scenario so that the LP creation fails (because it will be already initialized by the front-runner) and then skip step 3. This will lead to the same result.
Consider documenting this issue, specifically, the fact that the initialization of a pool can be front-run. In addition, consider whether it should be allowed for a swap to occur with no liquidity at all in the pool or whether it makes sense to provide initialization with initial liquidity within the same transaction. Under the assumption that the PoolManager contract will be used through periphery contracts, this issue is not easy to exploit. However, such an assumption might not hold in some circumstances, making the issue more severe.
Update: Acknowledged, not resolved. The team stated:
This attack vector is prevented by peripheral contracts adding slippage protection when users add liquidity to a pool.
Low Severity
Unsafe ABI Encoding
It is not uncommon to use abi.encodeWithSignature or abi.encodeWithSelector to generate calldata for a low-level call. However, the first option is not typo-safe and the second option is not type-safe. As such, both of these methods are error-prone and ought to be considered unsafe.
Within Hooks.sol, there are multiple uses of unsafe ABI encodings:
- The use of
abi.encodeWithSelectorwithinbeforeInitializefunction - The use of
abi.encodeWithSelectorwithinafterInitializefunction - The use of
abi.encodeWithSelectorwithinbeforeModifyLiquidityfunction - The use of
abi.encodeWithSelectorwithinbeforeModifyLiquidityfunction - The use of
abi.encodeWithSelectorwithinbeforeDonatefunction - The use of
abi.encodeWithSelectorwithinafterDonatefunction
Consider replacing all the occurrences of unsafe ABI encodings with abi.encodeCall which checks whether the supplied values actually match the types expected by the called function and also avoids errors caused by typos.
Update: Resolved in pull request #770.
Missing Error Messages in require Statements
Throughout the codebase, there are require statements that lack error messages:
- The
requirestatement in line 14 ofBitMath.sol - The
requirestatement in line 56 ofBitMath.sol - The
requirestatement in line 30 ofFullMath.sol - The
requirestatement in line 113 ofFullMath.sol
To improve overall code clarity and facilitate troubleshooting, consider including specific, informative error messages in require statements. Alternatively, consider using custom errors to maintain consistency throughout the codebase.
Update: Acknowledged, not resolved.
Unsafe Casting
CurrencyLibrary creates currency by using the fromId function which creates a currency address from a uint256 value. This might lead to unsafe casting within the mint or burn functions. While this feature might be interesting as it allows traders to create some form of namespaces for the tokens, it could lead to issues during third-party integrations. The corresponding toId function converts the currency into an ID by casting the currency address into a uint256. This means that while the functions fromId and toId are expected to be inverse functions, they are not, since the upper 12 bytes are lost during the conversion to currency in the fromId function.
Consider masking the upper bits in the mint and burn functions to ensure the uint256 ID value fits into uint160. Alternatively, consider thoroughly documenting this behavior.
Update: Resolved in pull request #776. The team decided to add a bit masking to the fromId function so that upper 12 bytes are always shaved off from the id.
Notes & Additional Information
Inconsistent Use of Multiple Solidity Versions
The protocol is inconsistently using multiple Solidity versions. As such, the following issues have been identified:
- There are multiple contracts with floating pragmas such as in
ERC6909.sol. - There are pragma statements that use an outdated version of Solidity such as in
Extsload.solandIExtsload.sol. - There are contracts such as
CustomRevert.solorERC6909that use a pragma statement that spans several minor Solidity versions. This can lead to unpredictable behavior due to differences in features, bug fixes, deprecations, and compatibility between minor versions.
Consider pinning the Solidity version more specifically throughout the codebase to ensure predictable behavior and maintain compatibility across various compilers. It is recommended to take advantage of the latest Solidity version to improve the overall readability and security of the codebase. Regardless of which version of Solidity is used, consistently pin the version throughout the codebase to prevent bugs caused by incompatible future releases.
Update: Resolved in pull request #784. The team addressed standardization. The code has been changed to reflect version ^0.8.0 for any imported contract, library or interface in order to allow integrators flexibility. Contracts that rely on transient storage are marked with version ^0.8.24 while PoolManager, which will be the main contract, has been set to a specific 0.8.26 version.
Lack of memory-safe Annotation in Assembly Blocks
In the codebase, there is extensive use of the memory-safe annotation for assembly blocks. However, the BalanceDelta and BeforeSwapDelta types are missing this annotation.
Consider being consistent with the use of the memory-safe annotation.
Update: Resolved in pull request #778.
Discrepancy Between Implementation and Specification of ERC-6909
There is a discrepancy between the implementation of the ERC-6909 transferFrom function and its specification. In particular, the specification states that it MUST revert when the caller is not an operator for the sender and the caller's allowance for the token id for the sender is insufficient. This implies that if sender is msg.sender and they did not set themselves as an operator then transferFrom should revert. However, this is not the case as transferFrom skips operator and allowance checks in such a case.
Consider adjusting the implementation to follow the specification. Alternatively, consider adjusting the specification if this is a desirable property.
Update: The team reached out to the EIP-6909 creators and a fix was pushed to the specitifcation.
Code Clarity
In the Pool contract, there are several places which might benefit from slight refactoring.
!exactInputcan be turned into justexactInputif thetrueandfalsebranches are swapped.!zeroForOnecan be turned into justzeroForOneif thetrueandfalsebranches are swapped.state.amountCalculatedcan use compound operators to shorten the expressions.
Consider refactoring the above to improve code readability.
Update: Partially resolved in pull request #777. Only the third item has been addressed.
Missing Function Parameters Names
Multiple functions declared in the IProtocolFees interface are missing parameter names. This makes the code less clear and more difficult to understand.
Consider naming all function parameters.
Update: Resolved in pull request #772.
Discrepancies Between Interfaces And Implementation Contracts
Throughout the codebase, multiple discrepancies have been identified between interfaces and implementation contracts.
- Within the
IExtsloadinterface, the first parameter of theextsloadfunction is calledslotwhereas in the implementation contract,startSlotis used. - Within the
IERC6909Claimsinterface, the first parameter of thesetOperatorfunction is calledspenderwhereas in the implementation contract,operatoris used. - Within the
IPoolManagerinterface, the parameter of thesettlefunction is calledtokenwhereas in the implementation contract,currencyis used.
Consider using the same parameter names across interfaces and implementation contracts.
Update: Resolved in pull request #762.
Unused Named Return Variables
Named return variables are a way to declare variables that are meant to be used within a function's body for the purpose of being returned as that function's output. They are an alternative to explicit in-line return statements.
Throughout the codebase, there are unused named return variables:
- The
deltareturn variable in thecallHookWithReturnDeltafunction inHooks.sol - The
sqrtQX96return variable in thegetNextSqrtPriceFromInputfunction inSqrtPriceMath.sol - The
sqrtQX96return variable in thegetNextSqrtPriceFromOutputfunction inSqrtPriceMath.sol - The
amount0return variable in thegetAmount0Deltafunction inSqrtPriceMath.sol - The
amount0return variable in the secondarygetAmount0Deltafunction inSqrtPriceMath.sol - The
amount1return variable in thegetAmount1Deltafunction inSqrtPriceMath.sol - The
slotreturn variable in the_getPositionInfoSlotfunction inStateLibrary.sol
Consider either using or removing any unused named return variables.
Update: Resolved in pull request #758.
Unused Imports
Throughout the codebase, there are imports that are unused and could be removed:
- The import
import {BalanceDelta} from "./BalanceDelta.sol";imports unused aliasBalanceDeltainBeforeSwapDelta.sol. - The import
import {Pool} from "../libraries/Pool.sol";imports unused aliasPoolinIPoolManager.sol. - The import
import {Position} from "../libraries/Position.sol";imports unused aliasPositioninIPoolManager.sol. - The import
import {PoolKey} from "../types/PoolKey.sol";imports unused aliasPoolKeyinLPFeeLibrary.sol. - The import
import {IHooks} from "../interfaces/IHooks.sol";imports unused aliasIHooksinLock.sol. - The import
import {IHooks} from "../interfaces/IHooks.sol";imports unused aliasIHooksinNonZeroDeltaCount.sol. - The import
import {BalanceDelta, BalanceDeltaLibrary, toBalanceDelta} from "./types/BalanceDelta.sol";imports unused aliastoBalanceDeltainPoolManager.sol. - The import
import {Currency} from "../types/Currency.sol";imports unused aliasCurrencyinStateLibrary.sol. - The import
import {PoolId} from "../types/PoolId.sol";imports unused aliasPoolIdinTransientStateLibrary.sol. - The import
import {Position} from "./Position.sol";imports unused aliasPositioninTransientStateLibrary.sol.
Consider removing unused imports to improve the overall clarity and readability of the codebase.
Update: Resolved in pull request #763.
Unused Error
The TickNotInitialized error in Pool.sol is defined but never used.
To improve the overall clarity and readability of the codebase, consider either using or removing any currently unused error.
Update: Resolved in pull request #764.
Magic Numbers
Throughout the codebase, there are a few instances where literal values are used directly for arithmetic operations:
- The
255738958999603826347141literal number inTickMath.sol - The
3402992956809132418596140100660247210literal number inTickMath.sol - The
291339464771989622907027621153398088495literal number inTickMath.sol
In order to improve code readability, consider using a constant to define such values and document its purpose.
Update: Acknowledged, not resolved.
State Variable Visibility Not Explicitly Declared
Throughout the codebase, there are state variables that lack an explicitly declared visibility:
- The
IS_UNLOCKED_SLOTstate variable inLock.sol - The
NONZERO_DELTA_COUNT_SLOTstate variable inNonZeroDeltaCount.sol - The
RESERVES_OF_SLOTstate variable inReserves.sol
For clarity, consider always explicitly declaring the visibility of variables, even when the default visibility matches the intended visibility.
Update: Resolved in pull request #766.
Missing Named Parameters in Mappings
Since Solidity 0.8.18, developers can utilize named parameters in mappings. This means mappings can take the form of mapping(KeyType KeyName? => ValueType ValueName?). This updated syntax provides a more transparent representation of a mapping's purpose.
Within ERC6909.sol, there are multiple mappings without named parameters:
Consider adding named parameters to mappings in order to improve the readability and maintainability of the codebase.
Update: Resolved in pull request #767.
Lack of Indexed Event Parameter
Within IProtocolFees.sol, the ProtocolFeeControllerUpdated event does not have an indexed parameter.
To improve the ability of off-chain services to search and filter for specific events, consider indexing event parameters.
Update: Resolved in pull request #768.
Missing Docstrings
Throughout the codebase, there are multiple code instances that do not have docstrings:
- The
OperatorSet,ApprovalandTransferevents, theisOperator,balanceOfandallowancestate variables, and thetransfer,transferFrom,approve,setOperatorandsupportsInterfacefunctions inERC6909.sol - The
IERC6909Claimsinterface inIERC6909Claims.sol - The
IExtsloadinterface and theIExttloadinterface inIExtsload.sol - The
IPoolManagerinterface inIPoolManager.sol - The
IProtocolFeeControllerinterface inIProtocolFeeController.sol - The
IProtocolFeesinterface, theProtocolFeeControllerUpdated,ProtocolFeeUpdated, and theprotocolFeeControllerfunctions inIProtocolFees.sol - The
IUnlockCallbackinterface inIUnlockCallback.sol - The
LPFeeLibrarylibrary and theDYNAMIC_FEE_FLAG,OVERRIDE_FEE_FLAG,REMOVE_OVERRIDE_MASK, andMAX_LP_FEEstate variables inLPFeeLibrary.sol - The
Poollibrary inPool.sol - The
ProtocolFeeLibrarylibrary and theMAX_PROTOCOL_FEEstate variable inProtocolFeeLibrary.sol - The
ProtocolFeesabstract contract and theprotocolFeesAccruedandprotocolFeeControllerstate variables inProtocolFees.sol - The
Reserveslibrary inReserves.sol - The
Slot0Librarylibrary inSlot0.sol - The
StateLibrarylibrary and thePOOLS_SLOT,FEE_GROWTH_GLOBAL0_OFFSET,FEE_GROWTH_GLOBAL1_OFFSET,LIQUIDITY_OFFSET,TICKS_OFFSET,TICK_BITMAP_OFFSETandPOSITIONS_OFFSETstate variables inStateLibrary.sol - The
TransientStateLibrarylibrary and theNONZERO_DELTA_COUNT_SLOTandIS_UNLOCKED_SLOTstate variables inTransientStateLibrary.sol - The
BalanceDeltaLibrarylibrary and theZERO_DELTAstate variable inBalanceDelta.sol - The
BeforeSwapDeltaLibrarylibrary and theZERO_DELTAstate variable inBeforeSwapDelta.sol - The
NATIVEstate variable inCurrency.sol
Consider thoroughly documenting all functions (and their parameters) that are part of any contract's public API. Functions implementing sensitive functionality, even if not public, should be clearly documented as well. When writing docstrings, consider following the Ethereum Natural Specification Format (NatSpec).
Update: Partially resolved in pull request #773. Only the first item of the list has not been addressed.
Incomplete Docstrings
Throughout the codebase, there are several instances of incomplete docstrings:
- The return value is not documented for the
transfer,transferFrom,approve,setOperatorfunctions inIERC6909Claims.sol. - The
deltaparameter is not documented for theafterAddLiquidityandafterRemoveLiquidityfunctions inIHooks.sol. - The return value is not documented for the
MAX_TICK_SPACINGandMIN_TICK_SPACINGfunctions inIPoolManager.sol. - The
currencyparameter and the return value are not documented for thesyncfunction inIPoolManager.sol. - The
key,sqrtPriceX96, andhookDataparameters and the return value are not documented for theinitializefunction inIPoolManager.sol. - The
key,amount0,amount1, andhookDataparameters and the return value are not documented for thedonatefunction inIPoolManager.sol. - The
currency,to, andamountparameters are not documented for thetakefunction inIPoolManager.sol. - The
to,id, andamountparameters are not documented for themintfunction inIPoolManager.sol. - The
from,id, andamountparameters are not documented for theburnfunction inIPoolManager.sol. - The
tokenparameter and the return value are not documented for thesettlefunction inIPoolManager.sol. - The
keyandnewDynamicLPFeeparameters are not documented for theupdateDynamicLPFeefunction inIPoolManager.sol. - The return value is not documented for the
protocolFeeForPoolfunction inIProtocolFeeController.sol. - The parameter and the return value are not documented for the
protocolFeesAccruedfunction inIProtocolFees.sol. - The
keyand the second parameter are not documented for thesetProtocolFeefunction inIProtocolFees.sol. - The parameter for the
setProtocolFeeControllerfunction inIProtocolFees.solis not documented. - The parameters and the return value are not documented for the
collectProtocolFeesfunction inIProtocolFees.sol. - The
feeparameter is not documented for theisValidHookAddressfunction inHooks.sol. - The return values for all functions in
LPFeeLibrary.solare not documented.
Consider thoroughly documenting all functions/events (and their parameters or return values) that are part of a contract's public API. When writing docstrings, consider following the Ethereum Natural Specification Format (NatSpec).
Update: Resolved in pull request #775.
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, all contracts do not have a security contact specified.
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: Resolved in pull request #774. The team decided to create a SECURITY.md markdown file in the root of the project repository with the necessary security contacts.
Client Reported
License Limitation Can Be Skipped
The purpose of the noDelegateCall modifier in the unlock function is to prevent proxy contracts from making delegate calls to the Uniswap v4 core contracts. This restriction is mainly due to license limitations. However, during the audit, the team was notified that this limitation can be bypassed by creating a custom contract that implements the same unlock function and writes to the same slot for the lock value, thereby allowing delegate calls to all other external functions, effectively circumventing the restriction.
The Uniswap team might consider adding the noDelegateCall modifier to all external functions, rather than only using it in the unlock function.
Update: Resolved in pull request #743. The team added the noDelegateCall modifier to all relevant external functions.
Conclusion
Uniswap v4 is an AMM that uses the concentrated liquidity model by default and can customize the model along with other parts of the protocol through the use of hooks. It also introduces singleton architecture, flash accounting, dynamic liquidity providers fee, donation to liquidity providers, and native token support.
One critical- and several medium-severity vulnerabilities were discovered along with some other issues of lower severity. In addition, suggestions were also made to improve the readability and clarity of the codebase in order to facilitate future audits and development. The codebase was found to be robust and generally well-documented. However, extensive use of assembly and other optimization techniques has significantly increased code complexity, thereby leaving room for undiscovered issues.
The Uniswap team was exceptionally responsive and provided us with extensive documentation about the project.
