1. Background - History of Uniswap
Uniswap, launched in 2017, has evolved its protocol over approximately 6 years from V1 to the current V3. The progression can be summarized as follows:
When first introduced, Uniswap V1 was a very simple AMM (Automated Market Maker). It only supported pairs between ETH and ERC20 tokens, resulting in inefficiencies where trading between Token A and Token B required going through ETH.
Uniswap V2 introduced the ability to create pools for ERC20-to-ERC20 trades. It addressed the problem of V1, by wrapping ETH into an ERC20 token called WETH. However, liquidity was evenly distributed across all price points, leading to the issue of "Lazy Liquidity" where only a tiny fraction of the pool's liquidity was used for swaps.
Uniswap V3 utilized concentrated liquidity, allowing liquidity providers to determine the range where their liquidity would be applied. This achieved the result of gathering liquidity around the current price, enhancing capital efficiency.
To achieve this, V3 introduced a complex mechanism using "Ticks". While this increased capital efficiency, it had the side effect of raising gas fees.
In June of this year, Uniswap announced V4. What problems does Uniswap V4 address? What benefits or side effects does V4 bring, and what challenges does it need to tackle in the future?
This article aims to answer these questions by conducting a detailed analysis based on the publicly available codebase. It is divided into two parts: the first part analyzes the features of Uniswap V4 based on its codebase, and the second part discusses controversies and prospects surrounding V4. If you're not familiar with Solidity, it's recommended to skip to the second part using the provided link.
2. V4 Architecture
2.1. Prerequisite : Basic Terminology
Before delving into the V4 mechanism analysis, let's first understand the concepts based on the terms used in V4.
Tick
A Tick represents the unit of price in Uniswap V3's concentrated liquidity. In Uniswap V3, the tick is represented as:
\(p(i) = 1.0001^{i} (p: price, i: tick number)\)Here, the base 1.0001 represents a Basis point in finance. It's designed so that the price moves by 1 Basis Point for every tick movement. Solidity cannot represent values below decimal point, Uniswap V3 uses Q64.96 data type, which saves integer part at the first 64 bits and fractional part at the next 96 bits. For a detailed discussion on tick operations, refer to the UniswapV3 Book.

(Tick Bitmap Indexing | Source: Uniswap V3 Book) Ticks not only represent prices but also serve as reference points for fee calculations and liquidity. In Uniswap V3, ticks are used to track fees calculated outside of that tick and the liquidity within the tick. This structure continues in V4.
Singleton
Singleton is one of the code design patterns, meaning a particular class has only one instance. Originally, Uniswap used the Factory pattern up to V3, where if you wanted to create a pair, you would deploy a new pool contract through the Factory contract. However, this approach had the drawback of incurring high gas fees for pool deployment and swap routing.
The current Uniswap V4 code houses all pools within the
PoolManagercontract. Through this Singleton architecture, significant gas fees can be saved during pool deployment and swap routing.Hook
A hook is a kind of plugin that can be added to a pool. In general, a hook refers to a function containing logic that is executed when a specific event occurs. In Uniswap, the events that trigger hooks are divided into three categories: Pool Deployment(Initialization), Liquidity Provision & Withdrawal, and Swap. Accordingly, eight hooks are defined:
Through this, liquidity pools can offer more than just swaps. They can provide various features like limit orders, MEV profit sharing, and LP profit maximization. V4 builds an ecosystem based on this.
Flash Accounting
Flash Accounting refers to performing pool-related operations in a cheap and safe manner through EIP-1153. The upcoming Ethereum hard fork, Cancun-Deneb(DenCun), plans to implement EIP-1153, which introduces transient storage. Transient storage operates just like the existing storage, but the data stored is reset as soon as the transaction ends. Since the data is deleted after each transaction, this storage consumes only computing resources without increasing the storage capacity of Ethereum clients. It uses up to 20 times less gas (100 GAS) than the opcode for regular storage. Uniswap V4 utilizes this to perform calculations and verification operations during transactions in a cost-effective and safe manner.
Previously, every pool operation involved exchanging tokens between pools. In V4, based on the singleton architecture and flash accounting, only internal balances are adjusted, and transfers are made all at once at the end. This method significantly reduces gas fees and makes operations like Routing and atomic swaps much easier.
Callback
A callback function is a function that fetches and executes a part of the logic from outside. It is primarily used for code variability and abstraction.
In Uniswap, various external contracts can call the swap function within the pool. Notable examples include DeFi protocols like Arrakis Finance, Gamma, and Bunni, which automatically adjust Uniswap V3's liquidity positions. Here, Uniswap implements a callback function, allowing these protocols to implement their own callback contracts with their logic, enhancing the contract's usability. In other words, it allows anyone to leverage Uniswap's core logic, enhancing modularity.
2.2. Data Structures used in Uniswap V4
Uniswap V4 has many data structures, but in this article, I've selected and analyzed three of the most essential structs.
PoolKey struct (PoolManager.sol)
This struct is used within the PoolManager contract to identify each pool. It contains information about which tokens are in the pool, what the swap fee is, the tick spacing, and which hook is used. The ID of the pool is determined by hashing the PoolKey struct, and this is used to distinguish each pool.
A notable point here is that the pool identifier includes the interface (IHooks). This means that each pool in Uniswap V4 can have exactly one hook. Moreover, pools with identical configurations but different hooks can coexist. For instance, a USDC-ETH pool with an on-chain oracle hook can coexist with a USDC-ETH pool with a limit order hook.
Unlike Uniswap V3, which limited pool fees to 0.05%, 0.3%, and 1%, Uniswap V4 has no such restrictions on fees. Thus, countless pools with identical configurations but different fees can exist.
Slot0 struct (Pool.sol)
This struct represents the state of the pool. Compared to V3, oracle-related information and the unlocked flag to prevent re-entrancy attacks have been removed, and fee-related information has been added. This struct is initialized when the pool is deployed (initialize). The fee-related variables represent the denominator value (e.g., if protocolSwapFee = 20, then the protocol swap fee is set at 1/20 = 5%).
Two points are noteworthy. First, the protocolWithdrawFee is a new addition in V4. The Uniswap DAO conducted two votes on the Fee Switch proposal to collect protocol fees in December of the previous year and May of this year. Both were rejected by some voters concerned about legal issues. With the introduction of the protocolWithdrawFee in V4, it remains to be seen whether this discussion will continue.
The second point is that there's a feature to collect fees during swaps or liquidity withdrawals for the hook. This will be discussed in detail in Part 2.
LockState struct (PoolManager.sol)
LockState is a struct that indicates how much a user owes to the pool. The concept of "owing" is related to V4's core mechanism of flash accounting.
Uniswap V4 performs only calculations in functions like swap, modifyPosition, etc., without directly transferring tokens. All token transfers are processed according to a predefined logic within the lockAcquired callback function.
Thus, after executing functions like swap, modifyPosition, etc., there exists an amount "to be sent to the pool" or "to be received from the pool." This is stored in the currencyDelta within LockState, and it's later settled through the take and settle functions.
Here, nonzeroDeltaCount indicates the total number of unsettled token types, and currencyDelta indicates how many of each unsettled token remain.
2.3. Architecture & Function Analysis
2.3.1. PoolManager
The purpose of the PoolManager.sol can be summarized in one sentence: "To ensure that both the pool and the user owe no tokens to each other when the operation is completed." To achieve this, the PoolManager contract divides the operation process into two parts: 1) Calculation and 2) Debt settlement. Accordingly, the purpose of each method is divided as follows:
Methods Containing Calculation Logic
Most of the operations for these methods occur through calls to the Pool Library. This encapsulation of the core calculation logic into a single instance is intentional. Libraries are similar to contracts, but they are deployed only once to a specific address and are reused through DELEGATECALL. Uniswap V4 includes complex logic in its calculations, and by placing this in a separate Pool Library, the intention is to improve code readability and centralize the logic.
initialize()
function initialize(PoolKey memory key, uint160 sqrtPriceX96) external override returns (int24 tick) {
...
PoolId id = key.toId();
(uint8 protocolSwapFee, uint8 protocolWithdrawFee) = _fetchProtocolFees(key);
(uint8 hookSwapFee, uint8 hookWithdrawFee) = _fetchHookFees(key);
tick = pools[id].initialize(sqrtPriceX96, protocolSwapFee, hookSwapFee, protocolWithdrawFee, hookWithdrawFee);
...
}
This method is called when deploying a pool. It takes the previously described PoolKey struct as an input, retrieves the state of the pool, and initializes the pool. Pool initialization refers to the process of initializing the Tick corresponding to the input price. This operation is performed by the initialize function within the Pool Library.
function initialize(
State storage self,
uint160 sqrtPriceX96,
uint8 protocolSwapFee,
uint8 hookSwapFee,
uint8 protocolWithdrawFee,
uint8 hookWithdrawFee
) internal returns (int24 tick) {
if (self.slot0.sqrtPriceX96 != 0) revert PoolAlreadyInitialized();
tick = TickMath.getTickAtSqrtRatio(sqrtPriceX96);
self.slot0 = Slot0({
sqrtPriceX96: sqrtPriceX96,
tick: tick,
protocolSwapFee: protocolSwapFee,
hookSwapFee: hookSwapFee,
protocolWithdrawFee: protocolWithdrawFee,
hookWithdrawFee: hookWithdrawFee
});
}
In the provided code, you can see that the pool is initialized by populating the Slot0 information based on the price and fee information determined at the time of pool deployment. Unlike V3, where a new pool contract had to be deployed, the logic for deploying a pool in V4 is very straightforward.
swap()
This function executes the core logic by calling the swap function in the Pool Library. The swap function within the Pool Library is called to perform the calculation.
function swap(PoolKey memory key, IPoolManager.SwapParams memory params)
external
override
noDelegateCall
onlyByLocker
returns (BalanceDelta delta)
{
...
Pool.SwapState memory state;
PoolId id = key.toId();
(delta, feeForProtocol, feeForHook, state) = pools[id].swap(
Pool.SwapParams({
fee: totalSwapFee,
tickSpacing: key.tickSpacing,
zeroForOne: params.zeroForOne,
amountSpecified: params.amountSpecified,
sqrtPriceLimitX96: params.sqrtPriceLimitX96
})
);
_accountPoolBalanceDelta(key, delta);
// the fee is on the input currency
...
}
In the provided code, similar to V3, the swap function in the Pool Library performs calculations until the swap is completed (using a while loop). The conditions for ending the swap are:
The entire swap input amount is consumed (swap completed).
The set price limit (Slippage limit) is reached.
What's different in V4 is that once the swap is completed, it returns a delta value. delta represents the change in the pool's balance when operations like swaps, liquidity provisions, etc., are performed in the pool. It is structured as follows:
function toBalanceDelta(int128 _amount0, int128 _amount1) pure returns (BalanceDelta balanceDelta) {
/// @solidity memory-safe-assembly
assembly {
balanceDelta :=
or(shl(128, _amount0), and(0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff, _amount1))
}
}
The PoolManager.sol contract in Uniswap V4 is designed to ensure that after any operation, neither the pool nor the user owes any tokens to each other. This is visualized in the diagram below, where the adjustments required for amount0 and amount1 are filled into an int256 data type.
Based on this value, the _accountPoolBalanceDelta is invoked. This function records the delta value (the number of tokens that need to be repaid or received from the pool due to the operation) for the two tokens that form a pair.
/// @dev Accumulates a balance change to a map of currency to balance changes
function _accountPoolBalanceDelta(PoolKey memory key, BalanceDelta delta) internal {
_accountDelta(key.currency0, delta.amount0());
_accountDelta(key.currency1, delta.amount1());
}
function _accountDelta(Currency currency, int128 delta) internal {
if (delta == 0) return;
LockState storage lockState = lockStates[lockedBy.length - 1];
int256 current = lockState.currencyDelta[currency];
int256 next = current + delta;
unchecked {
if (next == 0) {
lockState.nonzeroDeltaCount--;
} else if (current == 0) {
lockState.nonzeroDeltaCount++;
}
}
lockState.currencyDelta[currency] = next;
}
The _accountDelta function adjusts the lockState struct based on the input delta value. Since the delta after the swap will not be 0, nonzeroDeltaCount will be incremented by 1, and currencyDelta will be incremented by the delta value.
The recorded nonzeroDeltaCount and currencyDelta values will later be settled in the settle and take functions.
modifyPosition()
This function executes the core logic for supplying, modifying, and withdrawing liquidity. Like the swap function, it calls the modifyPosition function in the Pool Library to return the delta value and reflects it in the pool information.
Activating the specified tick: It checks the range where the user wants to supply/withdraw liquidity and activates the corresponding ticks (
flipTick).Updating fee information: It retrieves the accumulated fee information within the LP position, which is necessary when withdrawing or modifying the range.
Calculating the pool's
deltavalue: It then calculates the pool'sdeltavalue and adds it to the result value.
The returned delta value, like in the swap function, is recorded in the lockState struct through the _amountPoolBalanceDelta function and is later settled through the settle and take functions.
donate
This function is a new feature in V4, allowing users to donate tokens to the pool, providing an incentive to LPs. Like swap and modifyPosition, it focuses on calculating the delta value and executes the core logic in the donate function within the Pool Library.
function donate(State storage state, uint256 amount0, uint256 amount1) internal returns (BalanceDelta delta) {
if (state.liquidity == 0) revert NoLiquidityToReceiveFees();
delta = toBalanceDelta(amount0.toInt128(), amount1.toInt128());
unchecked {
if (amount0 > 0) {
state.feeGrowthGlobal0X128 += FullMath.mulDiv(amount0, FixedPoint128.Q128, state.liquidity);
}
if (amount1 > 0) {
state.feeGrowthGlobal1X128 += FullMath.mulDiv(amount1, FixedPoint128.Q128, state.liquidity);
}
}
}
The amount donated by the donor is added to the accumulated pool fees. This can be used in the future to provide tips to LPs supplying liquidity necessary for TWAMM or to create new fee structures.
The swap, modifyPosition, and donate functions all only perform the function of calculating the number of tokens that need to be adjusted due to the operation, without actually transferring tokens. Let's explore how the calculated delta value undergoes a process to be settled.
Methods for Settling and Transferring Tokens
settle()
function settle(Currency currency) external payable override noDelegateCall onlyByLocker returns (uint256 paid) {
uint256 reservesBefore = reservesOf[currency];
reservesOf[currency] = currency.balanceOfSelf();
paid = reservesOf[currency] - reservesBefore;
// subtraction must be safe
_accountDelta(currency, -(paid.toInt128()));
}
settle is a function where users repay the amount they owe. This function is invoked after users send tokens to the PoolManager contract. To illustrate the computation process:
Suppose a user owes 1 ETH to the PoolManager, and the current ETH balance (reserveOf[currency]) of the pool is 5 ETH. The user sends 1 ETH to the PoolManager and calls the settle function. The following computations occur:
reserveBefore = 5(sincereserveOf[currency]hasn't been updated yet).The
balanceOfSelf()function updates the PoolManager's balance. Now,reserveOf[currency]will be 6 ETH.paid = 6 - 5 = 1._accountDeltais called with currency = ETH and paid = 1 as input values.
The _accountDelta function called here settles the delta value that was recorded in the lockState due to operations like swap or modifyPosition.
Once this function is invoked, both the pool and the user reach a state where neither owes tokens to the other (delta = 0). This ensures the pool operation can conclude normally.
function _accountDelta(Currency currency, int128 delta) internal {
if (delta == 0) return;
LockState storage lockState = lockStates[lockedBy.length - 1];
int256 current = lockState.currencyDelta[currency];
int256 next = current + delta;
unchecked {
if (next == 0) {
lockState.nonzeroDeltaCount--;
} else if (current == 0) {
lockState.nonzeroDeltaCount++;
}
}
lockState.currencyDelta[currency] = next;
}
After this function is called, there will be no debt owed for pool and the user, and the pool operation can be completed without throwing an error.
take()
function take(Currency currency, address to, uint256 amount) external override noDelegateCall onlyByLocker {
_accountDelta(currency, amount.toInt128());
reservesOf[currency] -= amount;
currency.transfer(to, amount);
}
This function settles and transfers the tokens that users should receive from the pool. Like settle, it uses the _accountDelta function to settle the tokens and then transfers the tokens from the pool to the user.
lock & lockAcquired
/// @notice All operations go through this function
/// @param data Any data to pass to the callback, via `ILockCallback(msg.sender).lockCallback(data)`
/// @return The data returned by the call to `ILockCallback(msg.sender).lockCallback(data)`
function lock(bytes calldata data) external override returns (bytes memory result) {
uint256 id = lockedBy.length;
lockedBy.push(msg.sender);
// the caller does everything in this callback, including paying what they owe via calls to settle
result = ILockCallback(msg.sender).lockAcquired(id, data);
...
}
The lock function is the starting point for all operations in Uniswap V4. This function adds the caller to the lockedBy array. The lockedBy represents a list of users who have a 'debt' relationship with the pool. Once the transaction concludes successfully, the caller is removed from this array.
Subsequently, the lockAcquired function from the ILockCallback is invoked.
The lockAcquired function is where the core logic interacting with the user is executed. This function is implemented in the callback contract. Currently, there isn't a standard callback contract, so it's best to refer to the test code available on Uniswap V4's GitHub for a deeper understanding.
function lockAcquired(uint256, bytes calldata rawData) external returns (bytes memory) {
require(msg.sender == address(manager));
CallbackData memory data = abi.decode(rawData, (CallbackData));
BalanceDelta delta = manager.swap(data.key, data.params);
if (data.params.zeroForOne) {
if (delta.amount0() > 0) {
if (data.testSettings.settleUsingTransfer) {
if (data.key.currency0.isNative()) {
manager.settle{value: uint128(delta.amount0())}(data.key.currency0);
} else {
IERC20Minimal(Currency.unwrap(data.key.currency0)).transferFrom(
data.sender, address(manager), uint128(delta.amount0())
);
manager.settle(data.key.currency0);
}
} else {
// the received hook on this transfer will burn the tokens
manager.safeTransferFrom(
data.sender,
address(manager),
uint256(uint160(Currency.unwrap(data.key.currency0))),
uint128(delta.amount0()),
""
);
}
}
if (delta.amount1() < 0) {
if (data.testSettings.withdrawTokens) {
manager.take(data.key.currency1, data.sender, uint128(-delta.amount1()));
} else {
manager.mint(data.key.currency1, data.sender, uint128(-delta.amount1()));
}
}
...
The provided code represents the lockAcquired function within the PoolSwapTest contract, which acts as a callback contract. Here, the lockAcquired function initially calls the swap function inside the PoolManager contract. As previously discussed, the swap function returns a delta value representing the change in balance.
For instance, consider an ETH-USDC pool where a swap of 1 ETH for 2000 USDC is executed. Assuming there's no slippage limit exceeded, the delta values computed by the swap function would be:
ETH delta (delta.amount0) USDC delta (delta.amount1) +1 -2000
Subsequent operations are:
1 ETH is transferred to the PoolManager via the
IERC20.transferFromfunction.delta.amount0is settled to 0 through thesettlefunction.The
takefunction transfers 2000 USDC to the user, anddelta.amount1is settled to 0.
The settled delta will be 0, and the lockAcquired function returns this value back to the lock function. Once the lockAcquired callback concludes and returns a result, the following code is executed:
// function lock()
unchecked {
LockState storage lockState = lockStates[id];
if (lockState.nonzeroDeltaCount != 0) revert CurrencyNotSettled();
}
lockedBy.pop();
}
Here, there's a safety mechanism that reverts the transaction if the nonzeroDeltaCount in the lockState is not 0. This indicates that there are still unsettled tokens, suggesting an error or malicious attack during the lockAcquired execution process. In our example, since the swap was executed correctly, both nonzeroDeltaCount and currencyDelta in the lockState are 0.
Finally, the caller is removed from the lockedBy array, concluding the transaction.
Assuming the callback contract is the PoolSwapTest.sol from Uniswap V4's GitHub, the entire Call Flow from when a user requests a swap in the pool to when the swap concludes is as follows:
A user wants to swap 1 ETH for 2000 USDC. The user sends 1 ETH to the callback contract and calls the
swapfunction within it.This function then calls the
lockfunction inside the PoolManager contract.The
lockfunction, in turn, calls thelockAcquiredfunction within the callback contract.Inside
lockAcquired, three functions from the PoolManager contract are called. First, theswapfunction, which stores thedeltavalues resulting from the swap (+1 ETH, -2000 USDC).Next, 1 ETH is sent to the PoolManager contract, and the
settlefunction is called, settling the ETHdeltato 0.Lastly, the
takefunction is called. This function settles the USDCdeltato 0 and transfers 2000 USDC to the user.The user receives the USDC, and once the variables inside the
lockfunction's lockState are confirmed to be all 0, the swap is completed.
As can be seen from the Call Flow, the lockAcquired function is the directive for the operation. In Uniswap V4, anyone can write and deploy this function as a callback contract, allowing for customization. This means that various projects can be launched based on Uniswap V4.
2.3.2 Hook
Hooks in V4 define the logic that is executed before and after events such as pool creation, swaps, liquidity provision, and retrieval. Hooks are executed within the initialize, swap, and modifyPosition functions of the PoolManager contract. At the end of each function's execution, there's a part that executes the hooks, as shown below:
// In swap() method from PoolManager.sol
if (key.hooks.shouldCallBeforeSwap()) {
if (key.hooks.beforeSwap(msg.sender, key, params) != IHooks.beforeSwap.selector) {
revert Hooks.InvalidHookResponse();
}
}
// Swap Logic
if (key.hooks.shouldCallAfterSwap()) {
if (key.hooks.afterSwap(msg.sender, key, params, delta) != IHooks.afterSwap.selector) {
revert Hooks.InvalidHookResponse();
}
}
As previously mentioned, the PoolKey struct contains the interface for the hooks. This interface is used to find and execute the logic held by the hooks, such as beforeSwap and afterSwap.
Uniswap V4 employs a clever method to easily distinguish and locate the functions held by each hook. They designate a flag for each function contained in the hook and store this flag information in the first two characters of the hook's address, as shown below:

For instance, if the hook contract's address is 0x9000000000000000000000000000000000000000, the first two characters are 0x90, which in binary is 1001000. This indicates that the hook has the beforeInitialize and afterModifyPosition functions. This method enhances the efficiency of hook search and swap routing in Uniswap V4.
To deepen our understanding of hooks, let's analyze the LimitOrder hook from the Uniswap V4 Github. In V4, limit orders operate by providing liquidity within a very narrow range (a single Tick unit). Limit order submissions occur through the place method:
function place(PoolKey calldata key, int24 tickLower, bool zeroForOne, uint128 liquidity)
external
onlyValidPools(key.hooks)
{
if (liquidity == 0) revert ZeroLiquidity();
poolManager.lock(
abi.encodeCall(this.lockAcquiredPlace, (key, tickLower, zeroForOne, int256(uint256(liquidity)), msg.sender))
);
...
Every address placing a limit order has its order information stored in the EpochInfo struct. Based on the input arguments, each individual's EpochInfo is stored, and the place function concludes:
EpochInfo storage epochInfo;
Epoch epoch = getEpoch(key, tickLower, zeroForOne);
epochInfo = epochInfos[epoch];
unchecked {
epochInfo.liquidityTotal += liquidity;
epochInfo.liquidity[msg.sender] += liquidity;
}
emit Place(msg.sender, epoch, key, tickLower, zeroForOne, liquidity);
Let's assume in an ETH-USDC pool, the current price of ETH is at Index 0, and my limit orders are at Index 2 and 4:
Next, suppose a large-scale swap occurs, causing the ETH price to rise to Index 9. Both of my orders would be executed, converting all the supplied ETH to USDC. I would need to retrieve all the liquidity and finalize the order. Therefore, after the swap operation concludes, the afterSwap function of the LimitOrder contract is called.
function afterSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata params, BalanceDelta)
external
override
poolManagerOnly
returns (bytes4)
{
(int24 tickLower, int24 lower, int24 upper) = _getCrossedTicks(key.toId(), key.tickSpacing);
if (lower > upper) return LimitOrder.afterSwap.selector;
bool zeroForOne = !params.zeroForOne;
for (; lower <= upper; lower += key.tickSpacing) {
Epoch epoch = getEpoch(key, lower, zeroForOne);
if (!epoch.equals(EPOCH_DEFAULT)) {
EpochInfo storage epochInfo = epochInfos[epoch];
epochInfo.filled = true;
(uint256 amount0, uint256 amount1) = abi.decode(
poolManager.lock(
abi.encodeCall(this.lockAcquiredFill, (key, lower, -int256(uint256(epochInfo.liquidityTotal))))
),
(uint256, uint256)
);
unchecked {
epochInfo.token0Total += amount0;
epochInfo.token1Total += amount1;
}
setEpoch(key, lower, zeroForOne, EPOCH_DEFAULT);
emit Fill(epoch, key, lower, zeroForOne);
}
}
This function notifies that all limit orders between the swapped price and the current price have been processed and withdraws liquidity. Withdrawal is executed by calling the lock function in the PoolManager.
In Uniswap V3, such limit orders were possible, but there was an inconvenience that users had to manually execute the supply and retrieval of liquidity. In V4, the LimitOrder hook automates this process, improving user UX and providing infrastructure for limit orders.
Moreover, the hook can have a wide range of features. Official examples of hooks written in Uniswap V4 Github include:
Geomean Oracle: An oracle that returns the geometric mean of prices over time.
TWAMM: A trading method that divides a large swap into smaller ones over a set period.
Volatility Oracle: An oracle that returns price volatility (not yet fully implemented).
There can also be hooks that offer features like returning MEV profits, depositing liquidity that has moved out of range into lending protocols to maximize LP profits, and many other types of hooks.
3. Feature of Uniswap V4
Based on the code analysis, we can categorize the features of Uniswap V4 into four main categories.
3.1. Flash Accounting
One of the core features of Uniswap V4 is Flash Accounting. When a specific operation is performed on a pool, changes in the pool's asset balance are stored in the Lockstate struct's nonzeroDeltaCount and currencyDelta. These data must be settled to 0 both before and after the operation. In other words, Lockstate is a struct that has the same value before and after the operation, regardless of the operation path (path-independent).
The introduction of this data type is to apply Flash accounting based on EIP-1153. Currently, EIP-1153 is not yet implemented, so Lockstate is stored in storage. However, Uniswap plans to declare the Lockstate struct and lockedBy array as variables stored in transient storage after the Dencun update. This will save gas costs during calculations and ensure the completeness of swaps and liquidity supplies, as the values of these variables are maintained at 0 before and after the transaction.
This means that security costs are much cheaper compared to V3.
In V3, a Reentrancy Guard like the one below was used to prevent re-entry attacks on swaps and liquidity retrievals:
modifier lock() {
require(slot0.unlocked, 'LOK');
slot0.unlocked = false;
_;
slot0.unlocked = true;
}
Here, slot0.unlocked is a variable stored in storage, and updating it consumes up to 22,100 GAS. On the other hand, modifying transient storage costs 100 GAS, which is very cheap. Therefore, by declaring LockState and lockedBy in transient storage, security costs can be reduced by over 95%.
For a more detailed analysis of Transient storage and EIP-1153, please refer to this article.
3.2. Singleton Architecture
Unlike the previous Uniswap code, which was divided into Factory/Pool and created a new contract for each pool, V4 manages all pools with a single PoolManager contract. As a result, routing between pools has become much cheaper.
For example, consider routing ETH to USDC and then USDC to STG token. Before V4, you would deposit ETH into the ETH-USDC pool, receive USDC, and then send it to the USDC-STG pool to receive STG. In V4, you simply record the internal balance change information, the delta value, for each pool. Since tokens are only transferred at the beginning and end, and storage is only modified then, the Routing process has become much simpler and cheaper.
Also, pool deployment costs have been significantly reduced. Previously, deploying a pool required deploying a separate pool contract, but in V4, calling the initialize function in the PoolManager contract deploys the pool, saving about 99% of deployment costs.
3.3. Hook
Each pool in V4 can offer various features to users through hooks. As seen in the limit order example above, hooks can enhance user UX, but they can also offer the following features:
Improving liquidity provider profits (e.g., lending feature for out-of-range liquidity)
Dynamic fees
MEV Protection
LP fee auto-compounding
Lending protocols without oracles
Based on these hooks, Uniswap V4 pools can offer additional profits and features to both traders and liquidity providers.
However, there are concerns about increased liquidity fragmentation depending on which hook is chosen. We will delve deeper into this in part two.
3.4. Gas Optimization
The results of the gas test are as follows, based on the code of Uniswap V3 and V4. Contrary to what was mentioned above, excluding pool deployment costs, V4 consumes higher or similar levels of gas for swaps and liquidity supplies compared to V3.
This is because EIP-1153 has not yet been applied. Within the lock function, the SSTORE opcode, which stores values in storage, is included every time the lockedBy array and Lockstate are calculated. The cost of this operation is currently very high. The SSTORE cost is determined as follows depending on whether the value to be stored is zero or not, and whether it's accessed for the first time or not:
Currently, in V4, the Lockstate struct and lockedBy array are declared in storage. During the swap process, the number of SSTORE operations within the lock and lockAcquired functions totals 12 times, consuming approximately 138,500 GAS.
However, if the opcode TSTORE for transient storage is used for these variables instead of SSTORE, how would the consumed gas change? Since the gas required for a single TSTORE operation is fixed at 100 GAS, the consumed gas would decrease from 138,500 GAS to 1,200 GAS. This means a reduction in the cost of variable storage by over 99%, and the total swap cost would be up to 52% cheaper than in V3. Therefore, it's anticipated that if EIP-1153 is implemented in the Ethereum client, swaps and liquidity supplies/retrievals in V4 can be executed at a much cheaper gas cost than in V3.
3.5. Summary
To summarize Uniswap V4 in three lines:
The core logic is based on Flash Accounting, managing 'debt' between the pool and users (
lock,lockAcquired).Builders can create and choose hooks and callback contracts, making it easier to build protocols based on V4.
At the same time, improvements in architecture and data storage methods offer users a more cost-effective trading environment.
However, there are criticisms that these features already exist in other protocols and that liquidity fragmentation will become more severe. In the next article, we will explore various controversies surrounding V4 and discuss how the ecosystem around V4 is expected to evolve in the future.
Reference














