SlowMist: Analysis and Audit Considerations of the Uniswap v3 Protocol
As decentralized finance (DeFi) rapidly evolves, Uniswap has remained at the forefront of innovation as a leading decentralized exchange. This article delves into the core mechanisms of the Uniswap v3 protocol, offering a detailed breakdown of its functional design, including concentrated liquidity, multiple fee tiers, token swaps, and flash loans. It also highlights key audit considerations for auditors. (Note: High-resolution images can be viewed at Figma)
Overview of the Architecture
Uniswap v3 consists of four main modules:
1. PositionManager: This is the primary interface for liquidity operations. Users can create token pools, provide or remove liquidity, and receive ERC721 tokens as certificates of their liquidity positions.
2. SwapRouter: This module serves as the entry point for token swaps, allowing users to execute token exchange operations.
3. Pool: This handles token trading, liquidity management, transaction fee collection, and the management of Oracle data. The price range is segmented using a “tick” mechanism, dividing it into fine increments.
4. Factory: Responsible for creating and managing pool contracts.
Process Breakdown
Creating a Token Pair
Users can create a token pair using the `createAndInitializePoolIfNecessary` function. They provide the token pair (`token0`, `token1`), fee, and (√P). The system first checks if the token pair exists through the `getPool` function. If it doesn’t, the `createPool` function is called, deploying the pair using the CREATE2 instruction. Finally, the `initialize` function sets the initial price, fees, tick intervals, and Oracle-related parameters.
Providing Liquidity
Users can create new liquidity positions using the `mint` function, which generates an NFT representing the position. Alternatively, they can increase an existing position’s liquidity using `increaseLiquidity`. The system ensures the transaction is executed within the valid time frame, then calls `addLiquidity`. This calculates the pool’s address and the liquidity amount, updating the user’s position through `_updatePosition`. The `lower` and `upper` tick limits and accumulated fees are adjusted accordingly. Next, `_modifyPosition` adds liquidity and ensures that tick limits are met, returning the amounts of `token0` and `token1`, which are deposited into the pool. The system then updates the user’s position based on their `tokenId`.
Removing Liquidity
Liquidity can be removed using the `decreaseLiquidity` function. The system verifies the validity of the LP certificate and the timing of the transaction. If the pool has sufficient liquidity, it calls the `burn` function to remove liquidity. The system checks whether the removed tokens meet the user’s minimum requirements and updates their position accordingly.
Swapping Tokens
To swap tokens, users can specify the amount they wish to pay and the minimum they expect to receive using the `exactInput` function, or set a maximum payment and desired token output using `exactOutput`. The system parses the swap path and sequentially calls `exactInputInternal` or `exactOutputInternal` to execute each swap step.
During the swap, the system locks the `unlocked` state to prevent interference with state variable updates. It finds the next trade price via ticks and uses `computeSwapStep` to calculate each swap step until the desired token amounts are reached. The system updates fees, liquidity, tick values, and price. If ticks change, Oracle data is also updated. Once these operations are complete, the system transfers `tokenOut` to the user. The user then pays `tokenIn` via the `uniswapV3SwapCallback`, a mechanism similar to a flash swap. The system checks the contract’s balance, and once verified, the `unlocked` state is restored.
The transaction completes successfully when all swap operations in the path are executed as expected.
Flash Loans
Flash loans can be executed through the `flash` function. The system calculates the loan fee and sends the requested tokens to the loan address. It then calls the user’s `uniswapV3FlashCallback`, where the repayment occurs. After the callback, the system verifies that the contract balance matches the loan amount and updates the loan fees. Flash loans can also be achieved through swap operations by borrowing and repaying tokens within a single transaction.
Audit Key Points
1. Check for refundETH call after swap operations
In the `exactInput` function, users must specify the token amount to be paid and the minimum token amount expected. Before calling `uniswapV3SwapCallback`, the system recalculates `amount0` and `amount1` to ensure accurate token transfers. When swapping with ETH, users must send ETH with the transaction. However, any unused ETH will not be automatically refunded during the transaction. The `exactInput` function only returns `amountOut`, leaving users unaware of the exact ETH consumed.
Additionally, anyone can call the `refundETH` function to withdraw unused ETH from the contract. Therefore, it is advisable to verify whether `refundETH` is called after swap operations to prevent unused ETH from remaining in the protocol. Alternatively, use the `MultiCall` function to combine multiple function calls into a single transaction.
function refundETH() external payable override {
if (address(this).balance > 0) TransferHelper.safeTransferETH(msg.sender, address(this).balance);
}
2. Verify TWAP implementation for Oracle pricing
Using Uniswap as a price source directly through `Slot0` to fetch `sqrtPriceX96` poses a risk of price manipulation. Attackers could manipulate the liquidity pool via swaps to gain favorable pricing.
To mitigate this risk, developers are advised to implement a Time-Weighted Average Price (TWAP) to obtain more stable prices. TWAP reduces the impact of short-term price fluctuations, making it more difficult to manipulate prices.
function observe(
Observation[65535] storage self,
uint32 time,
uint32[] memory secondsAgos,
int24 tick,
uint16 index,
uint128 liquidity,
uint16 cardinality
) internal view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s) {
require(cardinality > 0, 'I');
tickCumulatives = new int56[](secondsAgos.length);
secondsPerLiquidityCumulativeX128s = new uint160[](secondsAgos.length);
for (uint256 i = 0; i < secondsAgos.length; i++) {
(tickCumulatives[i], secondsPerLiquidityCumulativeX128s[i]) = observeSingle(
self,
time,
secondsAgos[i],
tick,
index,
liquidity,
cardinality
);
}
}
3. Allow users to set slippage tolerance parameters
When external protocols use Uniswap v3 for swap operations, developers should implement slippage protection based on the use case and allow users to adjust the slippage tolerance parameters to prevent sandwich attacks. In the swap function, the fourth parameter, `sqrtPriceLimitX96`, sets the minimum or maximum price the user is willing to accept for the swap. This effectively protects users from extreme price swings during a transaction, reducing losses due to high slippage.
function swap(
address recipient,
bool zeroForOne,
int256 amountSpecified,
uint160 sqrtPriceLimitX96,
bytes calldata data
) external override noDelegateCall returns (int256 amount0, int256 amount1) {
...
}
4. Introduce a whitelist mechanism for liquidity pools
In Uniswap v3, multiple liquidity pools may exist for the same ERC20 pair with different fee tiers. Typically, a few pools hold the majority of the liquidity, while others have low Total Value Locked (TVL) or may not be created yet. Low TVL pools are more susceptible to price manipulation.
To ensure the reliability of data, project teams should avoid using LP data from these small pools. A whitelist mechanism that selects pools with sufficient liquidity and resistance to manipulation can significantly reduce risks, ensuring that price data remains secure and accurate while avoiding potential losses from manipulated low TVL pools.
function createPool(
address tokenA,
address tokenB,
uint24 fee
) external override noDelegateCall returns (address pool) {
require(tokenA != tokenB);
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0));
int24 tickSpacing = feeAmountTickSpacing[fee];
require(tickSpacing != 0);
require(getPool[token0][token1][fee] == address(0));
pool = deploy(address(this), token0, token1, fee, tickSpacing);
getPool[token0][token1][fee] = pool;
// populate mapping in the reverse direction, deliberate choice to avoid the cost of comparing addresses
getPool[token1][token0][fee] = pool;
emit PoolCreated(token0, token1, fee, tickSpacing, pool);
}
5. Check for the use of `unchecked` in TickMath.sol, FullMath.sol, and Position.sol
The `TickMath`, `FullMath`, and `Position` modules perform complex mathematical calculations in Uniswap v3. These calculations depend on how overflow is handled in Solidity. In earlier Solidity versions (<0.8.0), integer overflow and underflow did not throw exceptions, allowing code to run based on this assumption. However, starting from Solidity 0.8.0, overflows and underflows automatically throw exceptions, potentially disrupting existing code.
To ensure proper functioning in Solidity 0.8.0 and above, developers need to use `unchecked` blocks in specific functions to manually disable overflow checks. This restores the behavior of earlier versions and ensures efficient execution of overflow-sensitive calculations.
Uniswap has made the necessary adjustments for Solidity 0.8.0 and beyond. For more details, refer to the update [here](https://github.com/Uniswap/v3-core/commit/6562c52e8f75f0c10f9deaf44861847585fc8129).
6. Ensure consistent encoding and decoding of the path
In Uniswap v3’s `exactInput` and `exactOutput` functions, users must input the `path` parameter, which is encoded in a fixed format (i.e., `tokenA-fee-tokenB`) for step-by-step token swaps. This structure specifies the two tokens and the fee tier for each swap in the path. If an external protocol decodes the path differently, it may not match Uniswap’s expected format, potentially causing the swap to fail.
Developers integrating Uniswap v3’s swap functionality should ensure that external protocols strictly follow Uniswap’s path encoding rules. To avoid path decoding errors, external protocols must carefully check the `path` parameter format when calling `exactInput` and `exactOutput`.
function decodeFirstPool(bytes memory path)
internal
pure
returns (
address tokenA,
address tokenB,
uint24 fee
)
{
tokenA = path.toAddress(0);
fee = path.toUint24(ADDR_SIZE);
tokenB = path.toAddress(NEXT_OFFSET);
}
7. Verify token order to prevent logic issues
In Uniswap, `token0` refers to the lower-sorted token used as the base token, and `token1` refers to the higher-sorted token used as the quote token. Uniswap sorts token pairs by their addresses in lexicographical order to ensure consistency.
However, the contract addresses of the same token may differ across blockchains, especially in cross-chain deployments. This could result in the roles of `token0` and `token1` being swapped, which may affect price calculations. For instance, a token that serves as `token0` on one chain might become `token1` on another, altering the base-quote relationship and impacting price displays. Developers should check whether token order affects the project logic, particularly in cross-chain environments, to avoid issues with pricing and trading logic.
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
Conclusion
These audit points are based on the current version of Uniswap v3 and are intended to guide auditors when reviewing projects that interact with the protocol. Since every project has unique implementations, auditors must thoroughly understand the protocol and rigorously check for potential vulnerabilities. For ongoing projects, SlowMist’s security team recommends that developers carefully consider these points during the development process to ensure the protocol’s safety and reliability.
About SlowMist
At SlowMist, we pride ourselves on being a frontrunner in blockchain security, dedicating years to mastering threat intelligence. Our expertise is grounded in providing comprehensive security audits and advanced anti-money laundering tracking to a diverse clientele. We’ve established a robust network for threat intelligence collaboration, positioning ourselves as a key player in the global blockchain security landscape. We offer tailor-made security solutions that span from identifying threats to implementing effective defense mechanisms. This holistic approach has garnered the trust of numerous leading and recognized projects worldwide, including names like Huobi, OKX, Binance, imToken, Crypto.com, Amber Group, Klaytn, EOS, 1inch, PancakeSwap, TUSD, Alpaca Finance, MultiChain, and Cheers UP. Our mission is to ensure the blockchain ecosystem is not only innovative but also secure and reliable.
We offers a variety of services that include but are not limited to security audits, threat intelligence, defense deployment, security consultants, and other security-related services. We also offer AML (Anti-money laundering) solutions, Vulpush (Vulnerability monitoring) , SlowMist Hacked (Crypto hack archives), FireWall.x (Smart contract firewall) , Safe Staking and other SaaS products. We have partnerships with domestic and international firms such as Akamai, BitDefender, FireEye, RC², TianJi Partners, IPIP, etc.
By delivering a comprehensive security solution customized to individual projects, we can identify risks and prevent them from occurring. Our team was able to find and publish several high-risk blockchain security flaws. By doing so, we wish to help spread awareness and raise the security standards in the blockchain ecosystem.