Uniswap V4 — Part1: Understanding V4 Through the Code

Chaisomsri
21 min readJan 22, 2024

--

1. Background — The Evolution of Uniswap

Launched in 2017, Uniswap has developed its protocol over about six years, from V1 to the current V3. The evolution process is summarized as follows:

The initial Uniswap V1 was a very simple Automated Market Maker (AMM), supporting only pairs between ETH and tokens. This created inefficiencies as trading between Token A and Token B required going through ETH.

Uniswap V2 allowed for direct token-to-token trades by creating pools and solved V1’s problem by using WETH (Wrapped ETH), wrapping ETH into an ERC20 token. However, there was an issue of Lazy Liquidity where liquidity was evenly distributed across all price ranges, meaning that only a small portion of the pool’s liquidity was actually used for swaps.

Uniswap V3 introduced concentrated liquidity, allowing liquidity providers to determine the range in which their liquidity would be applied. This approach gathered liquidity around the current price and improved capital efficiency.

Concentrated Liquidity in Uniswap V3

However, to achieve this, V3 introduced a complex mechanism centered around Ticks. While this increased capital efficiency, it also had the side effect of raising gas fees.

And in June this year, Uniswap announced V4. What problems does Uniswap V4 solve? What utility or side effects will Uniswap V4 bring, and what issues need to be addressed in the future?

This article aims to answer these questions with an in-depth and technical analysis based on the publicly available code. It is composed of two parts: the first part analyzes the features of Uniswap V4 based on the code, and the second part discusses the controversies, prospects, and other aspects surrounding Uniswap V4. If you are not familiar with Solidity, it is recommended to proceed to the second part at the following link.

2. V4 Architecture

2.1. Prerequisite: Basic Terminology Definitions

Before delving into the analysis of the V4 mechanism, let’s first understand the concepts necessary for understanding the terms used in V4.

  • Tick

A Tick in Uniswap V3 represents a unit of price in its concentrated liquidity. Prices in Uniswap V3 are expressed as follows:

The 1.0001 used here represents a Basis Point in finance, meaning that the price moves by one Basis Point per tick. Since Solidity cannot represent values below the decimal, Uniswap V3 uses a Q64.96 data type that stores the integer part in the first 64 bits and the fractional part in the next 96 bits. For more detailed information about Tick operations, refer to the Uniswap V3 Book.

Tick Bitmap Indexing

Ticks not only express price but also serve as reference points for calculating fees and liquidity. In Uniswap V3, ticks are used to track fees computed outside a given tick and the liquidity inside it, a structure that continues in V4.

  • Singleton

Singleton is a code design pattern where only one instance of a specific class exists. Originally, until V3, Uniswap used a Factory pattern where new pool contracts were deployed through a Factory contract if someone wanted to create a pair. However, this approach had the problem of high gas costs for pool deployment and swap routing.

In Uniswap V4, all pools are contained within a PoolManager contract. This Singleton architecture allows for significant gas savings in pool deployment and swap routing operations.

  • Hook

A Hook is a kind of plugin that can be added to a pool. Generally, a Hook is a function containing logic that executes when a specific event occurs. In Uniswap, events that trigger Hooks are divided into three categories: pool deployment, liquidity provision and withdrawal, and swaps, defining eight types of Hooks accordingly.

This allows liquidity pools to offer more than just swaps, such as limit orders, MEV revenue sharing, and maximizing LP earnings, thus enabling V4 to build a kind of ecosystem based on this.

  • Flash Accounting

Flash Accounting refers to carrying out pool-related operations in a cheap and safe manner through EIP-1153. Scheduled to be implemented in the next Ethereum hard fork, Cancun-Deneb (DenCun), EIP-1153 is a proposal to introduce transient storage. Transient storage operates like conventional storage but resets stored data at the end of each transaction. Since data is deleted after each transaction, this storage doesn’t increase the storage capacity of Ethereum clients while consuming only computing resources and using up to 20 times less gas (100 GAS) than regular storage opcodes. Uniswap V4 utilizes this to perform calculations and verifications during transactions cheaply and safely.

Previously, each pool operation involved exchanging tokens between pools, but V4, based on the Singleton architecture and flash accounting, adjusts internal balances only, transferring them all at once at the end. This approach greatly reduces gas costs and simplifies operations like routing and atomic swaps.

  • Callback

A callback function is a function that executes a part of the logic received from an external source. It is primarily used for code variability and abstraction.

In Uniswap, various external contracts can call the swap function within a pool. For instance, DeFi protocols like Arrakis Finance, Gamma, Bunni, etc., which automatically adjust liquidity positions in Uniswap V3, can be cited. Uniswap implements a callback function and allows these protocols to develop their callback contracts with their unique logic, thereby enhancing the versatility of the contract. In essence, it enables anyone to leverage Uniswap’s core logic for increased modularity.

2.2. Data Structures used in Uniswap V4

Uniswap V4 has many data structures, but this article will focus on analyzing only three essential structs.

  • PoolKey struct (PoolManager.sol)

This struct is used within the PoolManager contract to identify each pool. It includes information about what tokens are in the pool, the swap fees, tick spacing, and which Hook is being used. The PoolKey struct is hashed to determine the pool’s ID, which is then used to differentiate each pool.

It’s important to note that the pool identifier includes the Hook’s interface (IHooks). This means that each pool in Uniswap V4 can have only one Hook, but multiple pools with the exact configurations but different Hooks can coexist. For example, 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. Therefore, there can be countless pools with the same configuration but different fees.

  • Slot0 struct (Pool.sol)

This struct represents the state of a pool. Compared to V3, it no longer contains oracle-related information and the unlocked flag to prevent reentrancy attacks, but now includes fee-related information. This struct is initialized when the pool is deployed. The fee-related variables represent the denominator value (e.g., protocolSwapFee = 20 implies a 5% protocol swap fee).

Two points are noteworthy. First, a new protocolWithdrawFee, absent in V3, has been introduced. Uniswap DAO voted twice, in December last year and May this year, on a Fee Switch to collect protocol fees, which was rejected both times due to legal concerns. It remains to be seen whether this discussion will continue due to the newly introduced protocolWithdrawFee in V4.

Second, Hooks also have the functionality to collect fees during swaps or liquidity withdrawals. This will be discussed in more detail in part two.

  • LockState struct (PoolManager.sol)

LockState is a struct representing how much a user owes to a pool. This is linked to V4’s key mechanism, flash accounting.

In Uniswap V4, functions like swap and modifyPosition don’t perform direct token transfers but update state variables and return results by ‘calculation only’. All token transfers occur within the lockAcquired callback function following a pre-defined logic.

Thus, after executing functions like swap and modifyPosition, there will be amounts ‘to be sent to’ or ‘to be received from’ the pool. This is stored in the currencyDelta within LockState, to be settled later via **take** and **settle ** functions.

Here, nonzeroDeltaCount indicates the total number of different unsettled tokens, and currencyDelta shows how many of each unsettled token remains.

2.3. Architecture & Function Analysis

2.3.1. PoolManager

(Overview of V4 PoolManager)

To summarize the purpose of PoolManager.sol in a phrase: ‘Ensure that no tokens are owed between the pool and users when a task is completed.’ For this, the PoolManager contract divides the workflow into 1) calculation and 2) debt settlement. Accordingly, each method is divided by purpose as follows:

  1. Methods containing core calculation logic — initialize, swap, modifyPosition, donate
  2. Methods that settle calculation results and actually facilitate the exchange of tokens — settle, take, lock

Methods containing calculation logic

Most tasks are performed through calls to the Pool library in these methods. This encapsulation of core calculation logic in a single instance is intentional. A library is similar to a contract but is deployed only once at a specific address and reused continuously through DELEGATECALL. Uniswap V4, which involves complex logic in its calculation process, aims to enhance code readability and unify logic by placing it in a separate Pool library.

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 PoolKey struct, mentioned earlier, as an input value, retrieves the pool’s state, and then initializes the pool. Pool initialization involves initializing the Tick corresponding to the price value received as input. This process is carried out 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 above code, we can see that the pool is initialized by filling in 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 this version is significantly simplified.

Swap

This function is executed to call the swap function in the Pool library and perform the core logic. The swap function within the Pool library carries out the calculations.

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

...
}

As in V3, the swap function in the Pool library performs calculations until the swap is completed (using a while loop). The swap ends under two conditions:

  1. The swap input amount is fully exhausted (swap completion).
  2. The set price limit (Slippage limit) is reached.

What changes in V4 is that, upon completion of the swap, it returns a delta value. Delta represents the change in the pool’s balance when operations like swaps and liquidity provisioning are performed. This 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))
}
}

As shown in the image below, it fills in the amount0 and amount1 that need to be adjusted due to the operation in an int256 data type.

Based on this value, the _accountPoolBalanceDelta is called. This function records the delta values (the number of tokens that need to be paid to & received from the pool due to the operation) for each of the two paired tokens.

/// @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;
}

In the _accountDelta function, the lockState struct is adjusted based on the input delta values. Since the delta after a swap will not be zero, here, the nonzeroDeltaCount will be incremented by 1, and currencyDelta will be increased by the amount of delta.

The nonzeroDeltaCount and currencyDelta values recorded here are settled in the future through the settle and take functions.

modifyPosition

This function executes the core logic for liquidity provision/modification/withdrawal. Like the swap function, it calls the modifyPosition function in the Pool library to return a delta value and reflect it in the pool information.

The modifyPosition function in the Pool library consists of three tasks: initializing the specified tick, updating fee information, and returning the pool’s delta value.

  1. Activating specified ticks
    It activates (flipTick) the ticks in the range where the user wants to provide/withdraw liquidity.
  2. Updating fee information
    It retrieves accumulated fee information within that LP position. This brings in the necessary fee information when operations like liquidity withdrawal or range modification occur.
  3. Calculating the pool’s delta value
    Then, it calculates and adds the pool’s delta value to the return value, result.

The returned delta value, like in the swap function, is recorded in the lockState struct through the _amountPoolBalanceDelta function and is settled through the settle and take functions.

donate

This function is a newly added feature in V4. It allows tokens to be donated to the pool, providing incentives to LPs. Like swap and modifyPosition, it focuses on calculating the delta value, and the core logic is executed in the donate function within the Pool library as follows:

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 sent by the donor is added to the accumulated pool fees. This can be used in cases such as tipping LPs providing liquidity for the necessary TWAMM or creating a new fee structure.

swap, modifyPosition, and donate functions all perform the function of calculating the delta value, the number of tokens that need to be adjusted due to the operation, without performing any actual token transfers. Let’s understand how the calculated delta value is settled through various processes.

Methods to Settle Calculated Results and Exchange 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. The settle function is called after the user has sent tokens to the PoolManager contract. The calculation process of the above function can be exemplified as follows:

Let’s assume a user owes 1 ETH to PoolManager, and the current ETH balance (reserveOf[currency]) of the pool is 5 ETH. The user sends 1 ETH to PoolManager and calls the settle function. Then, the following calculation takes place:

First line: Since the reserveOf[currency] value has not been updated yet, it would still be 5 ETH, thus reserveBefore = 5.

Second line: The balanceOfSelf() function, which updates the balance of PoolManager, is called. Now, the value of reserveOf[currency] will be updated to 6 ETH.

Third line: paid = 6–5 = 1.

Fourth line: _accountDelta is called with currency = ETH and paid = 1 as input values.

The _accountDelta called here serves to settle the delta values recorded in lockState due to swap or modifyPosition.

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;
}

When this function is called, it ensures that neither the pool nor the user owes each other tokens (delta = 0), setting the condition for normal termination of the pool operation.

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 tokens that the user should receive from the pool. Like settle, it settles tokens through the _accountDelta function and performs the function of transferring 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. lockedBy represents a list of users who have a ‘debt’ relationship with the pool, and the caller is removed from this array once the transaction is completed successfully.

Then, the lockAcquired function in ILockCallback is called.

In the lockAcquired function, the core logic interacting with the user is executed, and this function is implemented in the callback contract. Currently, as there are no standard callback contracts implemented, let’s explore based on the test code available on Uniswap V4 GitHub.

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 above code is the lockAcquired function in the PoolSwapTest contract, acting as a callback contract. Here, the lockAcquired function first calls the swap function in the PoolManager contract. As seen above, the swap function returns the change in balance, the delta value.

For instance, consider an ETH-USDC pool where a swap of 1 ETH for 2000 USDC is executed. If it doesn’t hit the set slippage limit, the calculated delta value through the swap function might look like the following (the sign is + if owed to the pool, — if to be received from the pool).

Then, the following steps are executed in order:

  • 1 ETH is moved to PoolManager through the IERC20.transferFrom function.
  • delta.amount0 is settled to 0 through the settle function.
  • 2000 USDC is transferred to the user through the take function, and delta.amount1 is settled to 0.

The settled delta will be 0, and the lockAcquired function returns this value to the lock function. Once the lockAcquired callback finishes and returns the result, the code below 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 nonzeroDeltaCount in lockState is not zero. This indicates that there are unsettled tokens left and that there might have been an error or malicious attack during the execution of lockAcquired. In the example above, as the swap was successfully completed, both nonzeroDeltaCount and currencyDelta in lockState will be 0.

Finally, the caller is removed from the lockedBy array, concluding the transaction.

Assuming the callback contract is PoolSwapTest.sol from Uniswap V4 GitHub, the entire Call Flow from the user’s swap request to the completion of the swap is as follows:

(Swap Call Flow at Uniswap V4)
  1. The user wants to swap 1 ETH for 2000 USDC. They send 1 ETH to the callback contract and call the swap function within it.
  2. This function calls the lock function in the PoolManager contract.
  3. The lock function then calls the lockAcquired function in the callback contract.
  4. Inside lockAcquired, three functions in the PoolManager contract are called. First, the swap function is called. This function stores the changed delta value (+1 ETH, -2000 USDC) due to the swap.
  5. Next, 1 ETH is sent to the PoolManager contract, calling the settle function. This function settles the ETH delta value to 0.
  6. Finally, the take function is called. This function settles the USDC delta value to 0 and then transfers 2000 USDC to the user.
  7. The user receives the USDC, and once the lock function confirms that all variables in lockState are 0, the swap is completed.

As we can see from the Call Flow, the place that instructs the execution of the task is the lockAcquired function. In Uniswap V4, anyone can write this function and deploy it as a callback contract for customization, meaning various projects can be launched based on Uniswap V4.

2.3.2 Hook

Hook in V4 defines the logic to be executed before and after events such as pool creation, swaps, and liquidity provision and withdrawal occur. Hook runs inside the initialize, swap, modifyPosition functions in the PoolManager contract. If we look at the code right after and before the end of each function’s execution, there is a part that executes the Hook as follows.

// 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 mentioned earlier, the PoolKey struct includes the Hook’s interface. This method finds and executes the logic held by Hook, such as beforeSwap, afterSwap, etc., from that interface.

Uniswap V4 employs a clever method to distinguish and easily identify the functionality of each Hook, using the first two digits of the Hook contract’s address to specify its functionality. As follows, a flag is set for each function contained in the Hook, and flag information is stored in the first two digits of the Hook address.

Uniswap Hook Address Identification

For example, if the address of a Hook contract is 0x9000000000000000000000000000000000000000, the first two digits are 0x90, which in binary is 1001000, indicating that the Hook has two functions: beforeInitialize and afterModifiyPosition. This allows Uniswap V4 to enhance the efficiency of Hook search and swap Routing.

To better understand Hook, let’s analyze the LimitOrder (limit order) Hook available on Uniswap V4 GitHub. In V4, a limit order operates by supplying liquidity in a very narrow range (a single Tick unit). Submitting a limit order is done 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))
);
...

All addresses placing limit orders have their order information in an EpochInfo struct. Based on the input arguments, each 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, respectively.

Then, suppose a large-scale swap occurs, causing the price of ETH to rise to Index 9. Then both of my orders would be executed, converting all supplied ETH into USDC, and I would need to withdraw all liquidity to conclude the orders. Therefore, after the swap operation ends, the afterSwap function in 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 changed and current prices have been processed and executes liquidity withdrawal. Withdrawal is executed by calling the lock function in PoolManager.

Although this form of limit order was possible in V3, it was inconvenient as users had to manually execute liquidity provision and withdrawal. V4 automates this process through the LimitOrder Hook, improving user UX and providing infrastructure for limit orders.

Furthermore, Hook can incorporate various functionalities. In addition to LimitOrder, other Hooks officially exemplified on Uniswap V4 GitHub include:

  • Geomean Oracle: An oracle that returns the geometric mean of prices over time.
  • TWAMM: A trading method that breaks down large-scale swaps into smaller ones over a set period.
  • Volatility Oracle: An oracle that returns price volatility (not yet fully implemented).

Additionally, Hooks can offer features such as returning MEV profits or depositing liquidity outside the range into lending protocols to maximize LP earnings, among various other forms of Hooks.

3. Features of Uniswap V4

Based on the code analysis, the features of Uniswap V4 can be summarized into four categories.

3.1. Flash Accounting

One of the core features of Uniswap V4 is Flash Accounting. When specific operations are 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 path-independent struct that maintains the same value before and after the operation.

The introduction of this data type is to apply Flash accounting based on EIP-1153. Currently, Lockstate is stored in storage as EIP-1153 has not yet been implemented, but 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 the calculation process and ensure the integrity of swap and liquidity provisioning as the variables will remain at 0 before and after the transaction.

This represents a significant reduction in security costs compared to V3.

In V3, a Reentrancy Guard like the one below was used to prevent reentrancy attacks during swaps and liquidity withdrawals.

modifier lock() {
require(slot0.unlocked, 'LOK');
slot0.unlocked = false;
_;
slot0.unlocked = true;
}

Here, slot0.unlocked is a variable stored in storage, consuming up to 22,100 GAS for each update. Meanwhile, modifying transient storage costs only 100 GAS, making it much cheaper. Thus, declaring LockState and lockedBy in transient storage can reduce security costs by over 95%.

More detailed analysis on transient storage and EIP-1153 can be found in this article.

3.2. Singleton Architecture

Unlike previous Uniswap versions, which created a new contract for each pool using the Factory / Pool pattern, V4 manages all pools with a single PoolManager contract. This significantly reduces the cost of Routing between pools.

For example, consider routing from ETH to USDC, then to STG tokens. Previously, this required transferring ETH to an ETH-USDC pool, receiving USDC, and then sending it to a USDC-STG pool to receive STG. However, in V4, it’s possible to perform this by simply adjusting and recording the internal balance change information, the delta value, for each pool. As tokens are only transferred at the beginning and end, the Routing process becomes much simpler and cheaper.

Additionally, the cost of deploying pools has been greatly reduced. Previously, deploying a pool required deploying a separate pool contract, but in V4, calling the initialize function within the PoolManager contract deploys a pool, saving over 99% of the deployment cost.

3.3. Hook

Each pool in V4 can provide various functions to users through Hooks. As seen in the limit order example, Hooks can enhance user UX but can also provide functionalities like:

  1. Improving liquidity provider earnings (e.g., lending function for liquidity outside the range)
  2. Dynamic fees
  3. MEV Protection
  4. LP fee auto-compounding
  5. Oracle-less lending protocols

Based on these Hooks, Uniswap V4 pools can offer additional revenues and functionalities to both traders and liquidity providers.

However, there is concern that choosing different Hooks could further fragment liquidity. This will be discussed in more detail in part two.

3.4. Gas Optimization

(Gas Used in Pool Actions of Uniswap V3 & V4)

Gas test results based on Uniswap V3 and V4 codes are as follows. Contrary to what was mentioned earlier, apart from pool deployment costs, V4 consumes more or similarly high gas costs for swaps and liquidity provisioning compared to V3.

This is because EIP-1153 has not yet been applied. The process of calculating lockedBy array and Lockstate in the lock function includes the SSTORE opcode, which stores values in storage, and currently, this operation is very costly. The cost of SSTORE is determined as follows, depending on whether the value being stored is 0 or not and whether it’s the first access or not.

Currently, the Lockstate struct and lockedBy array in V4 are declared in storage. The number of SSTORE operations in the lock and lockAcquired functions during a swap is 12 in total, consuming about 138,500 GAS.

But what if TSTORE opcode for transient storage, instead of SSTORE, is used for these variables? Since TSTORE requires only 100 GAS per operation, the gas cost would decrease from 138,500 GAS to 1,200 GAS. This means a cost reduction of over 99% for variable storage, and the total swap cost becomes up to 52% cheaper than V3. Thus, if EIP-1153 is implemented in Ethereum clients, it is expected that swaps and liquidity provisioning/withdrawal in V4 will be much cheaper than in V3.

3.5. Conclusion

Uniswap V4 can be summarized in three lines as follows:

  1. The core logic is based on Flash Accounting, managing ‘debts’ between the pool and users (lock, lockAcquired).
  2. Builders can create and choose Hooks and callback contracts, making it easier to build protocols based on V4.
  3. Simultaneously, improvements in architecture and data storage methods offer users a more affordable trading environment.

However, there are critical voices that such functionalities already exist in other protocols and that liquidity fragmentation may worsen. In the next article, we will explore various controversies surrounding V4 and how the V4 ecosystem is expected to evolve.

Reference

--

--