Uniswap V4 Limit Order Hook part 1

Smart Contract Source Code Analysis

Justin Gee
Tokamak Network
20 min readNov 21, 2023

--

Uniswap V4 Logo (Source: Uniswap Labs Blog)

Uniswap V4 introduces hooks, which allow custom logic to be executed at various points in the interaction with Uniswap pools. The Uniswap V4 official blog lists dynamic fees, on-chain limit orders, and Time-weighted Automated Market Maker (TWAMM), which breaks large trades into smaller ones and processes them over a longer period of time, as examples of hooks. Uniswap Labs sees Uniswap as a financial infrastructure, not just a decentralized exchange (DEX), where custom plugins and processes can be created.

A project called TONDEX, which will eventually go live on the Titan mainnet, will use Uniswap V4’s hooks to implement limit orders. Draft versions of the Core and Periphery repositories are currently available on the Uniswap GitHub. Testing was done using the Foundry framework. In this article, we’ll read and analyze the code in the draft version to understand how limit orders are implemented on-chain and how it differs from V3.

*This article requires some understanding of Uniswap V3 to read.

  • Korean version of the article can be found below:

The table of contents is as follows

  • Limit orders in V3
  • V4 Contracts Overview
  • V4 Hook Flow
  • Hook contract setup and CREATE2 deployment
  • Creating a pool
  • Placing a limit order

Limit Orders in V3

Before we dive into the code, let’s take a look at an illustrated example of how a limit order works in Uniswap V3.

(1) Current pool status

Let’s say the current tick is 85176. The tick is equal to log_{sqrt(1.0001)}(sqrt(price)). In this graph, height represents liquidity, and the left side of the current tick is filled with USDC liquidity and the right side of the current tick is filled with ETH liquidity.

Now, the user expects ETH to go up in price. More specifically, he expects the tick to go above 85680. So he will provide liquidity in ETH for the tick range of 85620 to 85680. It will look like below.

(2) With liquidity in ETH in the range of 85620 to 85680

Here, 85620 is the result of 85680–60. To understand why we subtracted 60, you need to know what “tickSpacing” is. If you go to the app to add liquidity in Uniswap V3, you’ll see the following fee tiers.

Each value represents a fee for trading in that pool. And each value is matched with a tickSpacing, which is fixed.

0.01% Fee Tier ⇒ 1 TickSpacing

0.05% Fee Tier ⇒ 10 TickSpacing

0.3% Fee Tier ⇒ 60 TickSpacing (our example)

1% Fee Tier ⇒ 200 TickSpacing

TickSpacing is the tick interval that defines the distance between ticks. The wider the tickSpacing is, the more gas efficient but less accurate it is. Vice versa for shorter spacings. Ticks are indexed by multiples of tickSpacing to manage liquidity information. For accuracy reasons, the value of the current tick does not need to be a multiple of tickSpacing.

The 0.05%-1% Fee Tier and respective TickSpacing are hardcoded in v3-core/contracts/UniswapV3Factory.sol, and the activation of the 0.01% fee tier was decided through a Uniswap governance vote.

(3) The tick is now up to 85680

In the image above, there were a lot of swaps selling USDC and buying ETH, and the current tick has reached 85680 as expected. The ETH liquidity provided earlier will be replaced with USDC liquidity, and the USDC will be collected.

At this point, the user (EOA) encodes the “decreaseLiquidity” and “collect” functions in the v3-periphery/contracts/NonfungiblePositionManganager contract, sets them as arguments, and calls the Multicall function. Here’s a quick explanation of what each does.

If you look at v3-core/contracts/libraries/Position.sol, you can see the Info struct.

// info stored for each user's position
struct Info {
// the amount of liquidity owned by this position
uint128 liquidity;
// fee growth per unit of liquidity as of the last update to liquidity or fees owed
uint256 feeGrowthInside0LastX128;
uint256 feeGrowthInside1LastX128;
// the fees owed to the position owner in token0/token1
uint128 tokensOwed0;
uint128 tokensOwed1;
}

decreaseLiquidity

  • Recalculates feeGrowthInsideLast based on the current tick and updates the tokensOwed value with the liquidity information. *tokensOwed is the collectable amount.

collect

  • This function claims as much as tokensOwed.

However, the user must continue to monitor the tick change of the pool and manually call the “decreaseLiquidity” and “collect” functions. This is because smart contracts are not currently designed to watch other smart contracts and trigger transactions on their own. Or this can be done by using an off-chain bot.

However, in V4, they’ve made it possible to make limit orders completely on-chain using the limit order hook.

V4 Contracts Overview

Similar to V3, Uniswap V4 manages contracts in two repositories: v4-core and v4-periphery.

The overall contract structure is as follows

In V4, unlike V3, a contract called PoolManager exists in the v4-core repository, and the logic for initialize, swap, and modifyPosition are all implemented in the v4-core/contracts/libraries/Pool.sol library. The PoolManager contract in v4-core is similar in some ways to the NonfungiblePositionManager contract in v3-periphery, but it stores the state used by all core logic.

And in V4, they make full use of User-defined Value Types. They defined each type in the “types” folder. For example, they defined a User-defined Value Type called “Currency” to manage each token, instead of using the built-in value type “address”.

v4-core/contracts/types/Currency.sol

type Currency is address;

using {greaterThan as >, lessThan as <, equals as ==} for Currency global;
function equals(Currency currency, Currency other) pure returns (bool) {
return Currency.unwrap(currency) == Currency.unwrap(other);
}
function greaterThan(Currency currency, Currency other) pure returns (bool) {
return Currency.unwrap(currency) > Currency.unwrap(other);
}
function lessThan(Currency currency, Currency other) pure returns (bool) {
return Currency.unwrap(currency) < Currency.unwrap(other);
}
/// @title CurrencyLibrary
/// @dev This library allows for transferring and holding native tokens and ERC20 tokens
library CurrencyLibrary {
using CurrencyLibrary for Currency;
...

When you define a type this way, it doesn’t have any member functions. Therefore, we cannot use basic operators such as >, <, and ==, so they implemented “equals”, “greaterThan”, and “lessThan” functions as shown above, and used the “using for global” keyword to make them available in global scope. In each function, you can convert the type from “Currency” to “address” through “unwrap”, and from “address” to “Currency” through “wrap”.

In v4-periphery, there are only hook contracts. These hook contracts all inherit from the abstract contract “BaseHook” and override unimplemented functions to fulfill them. They use the IPoolManager interface to interact with the PoolManager contract. This is not a complete repository yet, and there may be a “positionManager” contract for no hook pools like in V3.

V4 Hook Flow

(source: Uniswap V4 Whitepaper)

The figure above shows the flow for executing custom logic hooks before and after a swap. Before the swap, the beforeSwap hook is executed if the flag is checked and true, and after the swap, the afterSwap hook is executed if the afterSwap flag is checked and true to finish the swap.

You can really make any custom logic in the hook as long as the value of delta is zero (we’ll see what “delta” is later). Even malicious hooks can be developed and made into pools in V4.

Let’s take a look at the PoolKey struct in v4-core/contracts/types/PoolKey.sol.

/// @notice Returns the key for identifying a pool
struct PoolKey {
/// @notice The lower currency of the pool, sorted numerically
Currency currency0;
/// @notice The higher currency of the pool, sorted numerically
Currency currency1;
/// @notice The pool swap fee, capped at 1_000_000. The upper 4 bits determine if the hook sets any fees.
uint24 fee;
/// @notice Ticks that involve positions must be a multiple of tick spacing
int24 tickSpacing;
/// @notice The hooks of the pool
IHooks hooks;
}

In V3, pools were identified by three values: token0, token1 address, and fee. As explained above(“Limit Orders in V3"), there are a total of 4 fee tiers, each matched to a TickSpacing and fixed. Therefore, it was possible to create up to 4 pools with the same token0 and token1.

In V4, it’s different. The fee and tickSpacing are not a set, and a variable “hooks” has been added. This means that there are a total of 5 values that identify pools, therefore an infinite number of pools can be created. For example, a tickSpacing of 61 is possible.

Currently, the smart-order-router finds the best path for the swap, as shown in the following figure.

Multipath on Uniswap V3 Swap

However, with the upgrade to V4, there will be pools with malicious hooks, and it will be too inefficient to calculate the amountOut of a swap across a vast number of pools with different hooks. We expect that there will be some policy to support only pools with no hooks (which is still too many), or only pools with hooks that have been developed and audited by a trusted organization. We also expect that UniswapX will be heavily utilized.

Hook contract setup and CREATE2 deployment

Before we dive into limit orders, let’s take a look at which flags to set in the hook contract and how to set them.

As mentioned above, all hook contracts inherit from the abstract contract BaseHook and implement it by overriding unimplemented functions. One such function is “getHooksCalls”. Let’s look at this function in the limit order contract first.

v4-periphery/contracts/hooks/examples/LimitOrder.sol

function getHooksCalls() public pure override returns (Hooks.Calls memory) {
return Hooks.Calls({
beforeInitialize: false,
afterInitialize: true,
beforeModifyPosition: false,
afterModifyPosition: false,
beforeSwap: false,
afterSwap: true,
beforeDonate: false,
afterDonate: false
});
}

In this function, you can see the types of flags. You can set flags before and after “initialize” (pool initialization), “modifyPosition” (position update), “swap”, and “donate” to execute custom logic.

Note that “donate” is a function that does not exist in V3. This function is designed to increase the feeGrowthGlobal value by donating to the pool contract, so that all liquidity providers in the pool can benefit. This is how new fee structures can be built. In V4, there will be a myriad of pools, and by utilizing functions like “donate”, liquidity will naturally congregate in pools where liquidity providers can earn the most fees.

Returning to the code above, notice that only the flags for afterInitialize and afterSwap are set to true. And in the constructor function of the BaseHook contract, there is a function called validateHookAddress, as shown below.

v4-periphery/contracts/BaseHook.sol

constructor(IPoolManager _poolManager) {
poolManager = _poolManager;
validateHookAddress(this);
}

The BaseHook contract imports a library called Hooks, and the validateHookAddress function is implemented in the Hooks library.

v4-core/contracts/libraries/Hooks.sol

uint256 internal constant BEFORE_INITIALIZE_FLAG = 1 << 159;
uint256 internal constant AFTER_INITIALIZE_FLAG = 1 << 158;
uint256 internal constant BEFORE_MODIFY_POSITION_FLAG = 1 << 157;
uint256 internal constant AFTER_MODIFY_POSITION_FLAG = 1 << 156;
uint256 internal constant BEFORE_SWAP_FLAG = 1 << 155;
uint256 internal constant AFTER_SWAP_FLAG = 1 << 154;
uint256 internal constant BEFORE_DONATE_FLAG = 1 << 153;
uint256 internal constant AFTER_DONATE_FLAG = 1 << 152;
...
function validateHookAddress(IHooks self, Calls memory calls) internal pure {
if (
calls.beforeInitialize != shouldCallBeforeInitialize(self)
|| calls.afterInitialize != shouldCallAfterInitialize(self)
|| calls.beforeModifyPosition != shouldCallBeforeModifyPosition(self)
|| calls.afterModifyPosition != shouldCallAfterModifyPosition(self)
|| calls.beforeSwap != shouldCallBeforeSwap(self) || calls.afterSwap != shouldCallAfterSwap(self)
|| calls.beforeDonate != shouldCallBeforeDonate(self) || calls.afterDonate != shouldCallAfterDonate(self)
) {
revert HookAddressNotValid(address(self));
}
}
...
function shouldCallBeforeInitialize(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & BEFORE_INITIALIZE_FLAG != 0;
}
function shouldCallAfterInitialize(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & AFTER_INITIALIZE_FLAG != 0;
}
function shouldCallBeforeModifyPosition(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & BEFORE_MODIFY_POSITION_FLAG != 0;
}
function shouldCallAfterModifyPosition(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & AFTER_MODIFY_POSITION_FLAG != 0;
}
function shouldCallBeforeSwap(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & BEFORE_SWAP_FLAG != 0;
}
function shouldCallAfterSwap(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & AFTER_SWAP_FLAG != 0;
}
function shouldCallBeforeDonate(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & BEFORE_DONATE_FLAG != 0;
}
function shouldCallAfterDonate(IHooks self) internal pure returns (bool) {
return uint256(uint160(address(self))) & AFTER_DONATE_FLAG != 0;
}

In the code above, the functions that start with shouldCall… are the ones that are used to check the hook flags each time, so you will see them often.

The first argument to the validateHookAddress function, “self”, is the address of the limit order contract, and the second argument, “calls”, is the result of the getHooksCalls function from above. The result of the getHookscalls function is compared to the result of the functions starting with shouldCall… and if there is a discrepancy, it is reverted.

If you look at the functions that start with shouldCall…, they all perform a “&” operation on the hook contract address to check if a specific bit is set to 1. Since we only set the afterInitialize and afterSwap flags above, of the last 8 bits of the address, bits 1<<158 and 1<<154 should be set to 1, and the rest should be set to zero.

In other words, the most significant 8 bits of the address in binary should be set to 01000100. And if the address was represented in hexadecimal, it would be an address starting with 0x44…. Therefore, it should not be deployed to an arbitrary address, and the 8 bits should be set to the value of the hook flag via CREATE2.

The address can be precomputed in CREATE2 with the constructor address, the contract’s bytecode, the constructor argument values, and the salt value. Therefore, after the implementation is finished, you should fix the constructor address, bytecode, and constructor argument values, and brute force the salt value through the loop to create an address starting with 01000100. Just like Bitcoin’s PoW.

Creating a pool

The process of creating a pool is a big difference from V3. While V3 used the Factory pattern to deploy all pools as new contracts, V4 uses the Singleton pattern.

V4 Singleton Pattern (Source: Uniswap Labs Blog)

The above image shows multiple paths to swap ETH to DAI. On the left, the transfer happens 6 times. However, if you look at the singleton pattern on the right, the transfers end with only 2 transfers. This saves a lot of gas. In the right side, each token pair is not managed by a contract, but by a state “pools”, a mapping variable in the v4-core/contracts/PoolManager contract. The key value is calculated as follows.

v4-core/contracts/types/PoolId.sol

type PoolId is bytes32;
function toId(PoolKey memory poolKey) internal pure returns (PoolId) {
return PoolId.wrap(keccak256(abi.encode(poolKey)));
}

Encodes and hash the values of the PoolKey struct we covered above.

The following is a sequence diagram of the actions for creating a pool.

Pool Creation, Sequence Diagram

I’d love to go through all the code line by line, but that would make this post too long, so I’ll leave out the parts that aren’t really relevant to the topic of this post, which is limit orders. Let’s look at the initialize function of the PoolManager contract.

v4-core/contracts/PoolManager.sol

function initialize(PoolKey memory key, uint160 sqrtPriceX96 ...
{
...
tick = pools[id].initialize(sqrtPriceX96, protocolSwapFee, hookSwapFee, protocolWithdrawFee, hookWithdrawFee);

if (key.hooks.shouldCallAfterInitialize()) {
if (
key.hooks.afterInitialize(msg.sender, key, sqrtPriceX96, tick)
!= IHooks.afterInitialize.selector
) {
revert Hooks.InvalidHookResponse();
}
}

emit Initialize(id, key.currency0, key.currency1, key.fee, key.tickSpacing, key.hooks);
}

v4-core/contracts/libraries/Pool.sol

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

First, we can see the structure of Slot0 in the Pool.sol library and notice that the fields are different from V3 below.

v3-core/contracts/UniswapV3Pool.sol

struct Slot0 {
uint160 sqrtPriceX96;
int24 tick;
uint16 observationIndex;
uint16 observationCardinality;
uint16 observationCardinalityNext;
uint8 feeProtocol;
bool unlocked;
}

In V4, “observation”-related variables are gone, meaning price oracle functionality is not by default. In V3, even if no one was using oracles, updating oracle-related variables when a position needed to be updated was an unnecessary waste of gas. In V4, if you need oracle functionality, you can implement it as a hook and deploy the pool.

FeeProtocol in V3 is split into protocolSwapFee and protocolWithdrawFee in V4. Note that in V3, protocolFee was always 0 and could be changed by a governance vote. Also in V4, the variables hookSwapFee and hookWithdrawFee were added. These can be used to assign a fee to a swap or withdrawal, which is where hook developers can take some profit from.

The tick and sqrtPriceX96 are still there, but the variable “unlocked” is missing in V4. V4 uses lock, a callback pattern, and a value called delta, which we’ll cover in more detail later.

If you look at the initialize function of the poolManager contract in v4-core, after initializing the pool, it checks the flag through the shouldCallAfterInitialize function and calls the afterInitialize function of the hook contract.

v4-periphery/contracts/hooks/examples/LimitOrder.sol

function afterInitialize(address, PoolKey calldata key, uint160, int24 tick, bytes calldata)
external
override
poolManagerOnly
returns (bytes4)
{
setTickLowerLast(key.toId(), getTickLower(tick, key.tickSpacing));
return LimitOrder.afterInitialize.selector;
}

“afterInitialize” function is really simple. Let’s say the tickSpacing is 60 and the current tick is 80, then a rounding operation is performed to store 60 as the value of tickLowerLast. The value of tickLowerLast is always updated when the tick changes after swap and is the value used when a limit order is filled.

Also, all hook functions always return a selector at the end, and the poolManager contract always checks that the response of the hook is the selector of the hook function.

Placing a limit order

The following is a sequence diagram of the “placing a limit order”.

Placing a limit order, sequence diagram

Before we get into the placing a limit order, let’s take a look at the limit order data structure.

struct EpochInfo {
bool filled;
Currency currency0;
Currency currency1;
uint256 token0Total;
uint256 token1Total;
uint128 liquidityTotal;
mapping(address => uint128) liquidity;
}

mapping(bytes32 => Epoch) public epochs;
mapping(Epoch => EpochInfo) public epochInfos;
...
function getEpoch(PoolKey memory key, int24 tickLower, bool zeroForOne) public view returns (Epoch) {
return epochs[keccak256(abi.encode(key, tickLower, zeroForOne))];
}

Notice that the struct name uses the word “Epoch” instead of order or limitOrder. The word epoch is commonly used in blockchains to mean an interval or unit of time. In this case, it refers to each tick range that is spaced as far apart as the tickSpacing. It has multiple users placing limit orders in the same range, and it keeps the liquidity value of each user as a liquidity mapping variable inside the EpochInfo struct. And all the limit orders in the same range are processed at once.

The function to get the key value of epochInfo is “getEpoch”. “zeroForOne” is a boolean value that determines the direction, i.e. whether the position is long or short. And there can be multiple users’ limit orders with the same key.

Now let’s look at the “place” function, which places a limit order. We’ll cover the callback pattern and a value called delta while covering this function.

v4-periphery/contracts/hooks/examples/LimitOrder.sol

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))
);
...
}
...
function lockAcquiredPlace(
PoolKey calldata key,
int24 tickLower,
bool zeroForOne,
int256 liquidityDelta,
address owner
) external selfOnly {
BalanceDelta delta = poolManager.modifyPosition(
key,
IPoolManager.ModifyPositionParams({
tickLower: tickLower,
tickUpper: tickLower + key.tickSpacing,
liquidityDelta: liquidityDelta
}),
ZERO_BYTES
);

if (delta.amount0() > 0) {
if (delta.amount1() != 0) revert InRange();
if (!zeroForOne) revert CrossedRange();
// TODO use safeTransferFrom
IERC20Minimal(Currency.unwrap(key.currency0)).transferFrom(
owner, address(poolManager), uint256(uint128(delta.amount0()))
);
poolManager.settle(key.currency0);
} else {
if (delta.amount0() != 0) revert InRange();
if (zeroForOne) revert CrossedRange();
// TODO use safeTransferFrom
IERC20Minimal(Currency.unwrap(key.currency1)).transferFrom(
owner, address(poolManager), uint256(uint128(delta.amount1()))
);
poolManager.settle(key.currency1);
}
}

The “place” function calls the “lock” function of the poolManager contract, encoding the lockAcquiredPlace function and its argument values and passing them as arguments to the lock function.

Note that abi.encodeCall encodes a call to the function pointer. The result is the same as the commonly used encodeWithSelector, but the first parameter is a function pointer instead of a function selector.

There is a callback from PoolManager’s lock function to LimitOrder’s lockAcquired function. It then interacts with the poolManager contract through the “modifyPosition” and “settle” functions.

In V4, interacting with the PoolManager contract to change the state would have the following pattern.

Callback Pattern(Source: Exploring the Core Mechanism of UniswapV4)

So when the callback contract acquires the lock, ends its interaction with the poolManager, and leaves the lock, the value of delta must be zero. The value that the PoolManager owes the callback contract during the lock period, or that the callback contract owes the PoolManager, is called delta. This means that when they leave the lock, they must owe each other nothing for the transaction to succeed.
The functions that interact with the PoolManager are “swap”, “modifyPosition”, “donate”, “take”, “settle”, and “mint”.

In the lockAcquiredPlace function above, the modifyPosition function of the poolManager contract returns a value called delta. Note that unlike V3, in V4, modifyPosition is an external function, not a private function, and can be called directly from the callback contract (locker). What it does is update the position information and pay off what they owe each other.

The value returned by the poolManager, called delta, looks like this.

function toBalanceDelta(int128 _amount0, int128 _amount1) pure returns (BalanceDelta balanceDelta) {
/// @solidity memory-safe-assembly
assembly {
balanceDelta :=
or(shl(128, _amount0), and(0x00000000000000000000000000000000ffffffffffffffffffffffffffffffff, _amount1))
}
}

The delta is int256, where the left 128 bits represent amount0 and the right 128 bits represent amount1. And you can get the respective values with the functions amount0 and amount1.

function amount0(BalanceDelta balanceDelta) internal pure returns (int128 _amount0) {
/// @solidity memory-safe-assembly
assembly {
_amount0 := shr(128, balanceDelta)
}
}

function amount1(BalanceDelta balanceDelta) internal pure returns (int128 _amount1) {
/// @solidity memory-safe-assembly
assembly {
_amount1 := balanceDelta
}
}

“amount0” and “amount1” are the respective amounts of a pair of tokens. If the value is positive, the callback contract owes the PoolManager, and if it is negative, the PoolManager owes the callback contract.

The lockAcquiredPlace function in LimitOrder.sol checks to see if amount0 or amount1 is positive, and if it is, it transfers the corresponding token to the PoolManager and calls the “settle” function. The “modifyPosition”, “transfer”, and “settle” functions make the delta value of the PoolManager go from zero to zero. Now let’s see how the delta value is handled inside the poolManager.

v4-core/contracts/PoolManager.sol

/// @inheritdoc IPoolManager
function lock(bytes calldata data) external override returns (bytes memory result) {
lockData.push(msg.sender);

// the caller does everything in this callback, including paying what they owe via calls to settle
result = ILockCallback(msg.sender).lockAcquired(data);
if (lockData.length == 1) {
if (lockData.nonzeroDeltaCount != 0) revert CurrencyNotSettled();
delete lockData;
} else {
lockData.pop();
}
}
...
function _accountDelta(Currency currency, int128 delta) internal {
if (delta == 0) return;

address locker = lockData.getActiveLock();
int256 current = currencyDelta[locker][currency];
int256 next = current + delta;

unchecked {
if (next == 0) {
lockData.nonzeroDeltaCount--;
} else if (current == 0) {
lockData.nonzeroDeltaCount++;
}
}

currencyDelta[locker][currency] = next;
}

The lock function pushes the callback contract address in the lockData variable.

Then, the _accountDelta function updates the currencyDelta variable with the callback contract address and the token address as the key value through a function such as “modifyPosition” or “swap” to update the value of how much is owed. If that value is non-zero, it increments a variable called nonzeroDeltaCount. If next is calculated to be zero, meaning that the value owed has been paid, it decrements the nonzeroDeltaCount variable.

In the lock function, the callback contract’s lockAcquired function ends and at the end of the lock function, if the nonzeroDeltaCount of lockData is non-zero, it is reverted; if it is zero, the lock function succeeds and the transaction succeeds.

Back to the lockAcquiredPlace function of the limit order, let’s summarize the process of changing the delta value by interacting with the PoolManager three times: modifyPosition → transferFrom → settle.

(1) modifyPosition

In a limit order, “modifyPosition” is a function that chooses between currency0 and currency1 and provides liquidity to one side. Let’s assume we want to provide liquidity in currency0.
currency[<limitOrderAddress>][<currency0Address>] = +x (positive)
nonzeroDeltaCount will be 1.

(2) transferFrom

transfer currency0 an amount of x to the poolManager contract

(3) 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()));
}
  • Here, it retrieves the last saved value through reservesOf[<currency0Address>] and updates it by retrieving the current poolManager’s currency0 balance value.
  • It compares the last saved value with the current updated value and makes the difference, x, negative and passes it as an argument to the _accountDelta function.
  • The result is currency[][] = +x -x = 0 and nonzeroDeltaCount is zero, allowing it to break out of the lock.

The modifyPosition function is not much different from V3 except that it calculates delta and returns delta, and it takes into account different fee policies. The logic for updating accumulated fees and initializing ticks using tickBitmap is the same.

Additionally, lockData related functions are implemented in v4-core/contracts/libraries/LockDataLibrary.sol.

library LockDataLibrary {
uint256 private constant OFFSET = uint256(keccak256("LockData"));

/// @dev Pushes a locker onto the end of the queue, and updates the sentinel storage slot.
function push(IPoolManager.LockData storage self, address locker) internal {
// read current value from the sentinel storage slot
uint128 length = self.length;
unchecked {
uint256 indexToWrite = OFFSET + length; // not in assembly because OFFSET is in the library scope
/// @solidity memory-safe-assembly
assembly {
// in the next storage slot, write the locker
sstore(indexToWrite, locker)
}
// update the sentinel storage slot
self.length = length + 1;
}
}
...

This library implements custom storage for “Queue”. It implements push and pop functions just like a queue. It stores the address of the locker starting at slot uint256(keccak256(“LockData”)). For reference, the value of uint256(keccak256(“LockData”)) is 53391651520919458808831464411208423921832704174599079787929421271630035089670.

And if you look at v4-core/contracts/libraries/Pool.sol, the TickInfo struct is as follows.

struct TickInfo {
uint128 liquidityGross;
int128 liquidityNet;
// fee growth per uint of liquidity on the _other_ side of this tick (relative to the current tick) only has relative meaning, not absolute - the value depends on when the tick is initialized
uint256 feeGrowthOutside0x128;
uint256 feeGrowthOutside1x128;
}

Here, liquidityGross and liquidityNet together are 256 bits, so they take up one slot. Then, we use the shift operation in the assembly block to fetch and update each value.

You can see the updateTick function in v4-core/contracts/libraries/Pool.sol.

function updateTick(State storage self, int24 tick, int128 liquidityDelta, bool upper)
internal
returns (bool flipped, uint128 liquidityGrossAfter)
{
TickInfo storage info = self.ticks[tick];

uint128 liquidityGrossBefore;
int128 liquidityNetBefore;
assembly {
// load first slot of info which contains liquidityGross and liquidityNet packed
// where the top 128 bits are liquidityNet and the bottom 128 bits are liquidityGross
let liquidity := sload(info.slot)
// slice off top 128 bits of liquidity (liquidityNet) to get just liquidityGross
liquidityGrossBefore := shr(128, shl(128, liquidity))
// shift right 128 bits to get just liquidityNet
liquidityNetBefore := shr(128, liquidity)
}
liquidityGrossAfter = liquidityDelta < 0
? liquidityGrossBefore - uint128(-liquidityDelta)
: liquidityGrossBefore + uint128(liquidityDelta);

flipped = (liquidityGrossAfter == 0) != (liquidityGrossBefore == 0);

if (liquidityGrossBefore == 0) {
// by convention, we assume that all growth before a tick was initialized happened _below_ the tick
if (tick <= self.slot0.tick) {
info.feeGrowthOutside0X128 = self.feeGrowthGlobal0X128;
info.feeGrowthOutside1X128 = self.feeGrowthGlobal1X128;
}
}
// when the lower (upper) tick is crossed left to right (right to left), liquidity must be added (removed)
int128 liquidityNet = upper ? liquidityNetBefore - liquidityDelta : liquidityNetBefore + liquidityDelta;
assembly {
// liquidityGrossAfter and liquidityNet are packed in the first slot of `info`
// So we can store them with a single sstore by packing them ourselves first
sstore(
info.slot,
// bitwise OR to pack liquidityGrossAfter and liquidityNet
or(
// liquidityGross is in the low bits, upper bits are already 0
liquidityGrossAfter,
// shift liquidityNet to take the upper bits and lower bits get filled with 0
shl(128, liquidityNet)
)
)
}
}

This reduces storage operations and saves gas. liquidityGross and liquidityNet are the same values used in V3.

In the next post, I’ll cover processing and withdrawing limit orders, as well as EIP-1153 and testing hook contracts in the Foundry environment.

Thanks for reading.

References

Thanks to Aaron, Kevin, Zena for feedback on this post.

--

--