Uniswap V4 Limit Order Hook part 2

Smart Contract Source Code Analysis

Justin Gee
Tokamak Network
21 min readNov 21, 2023

--

Uniswap V4 Logo (Source: Uniswap Labs Blog)

In the first post, I covered limit orders in V3, V4 Contracts Overview, V4 hook flow, hook contract setup and CREATE2 deployment, pool creation, and placing pending orders.

In this post, I’ll cover processing and withdrawing limit orders, as well as some other things I haven’t covered yet.

*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

  • Swap & After Swap
  • Withdraw
  • Hook Contract Test Setup in the Foundry Environment
  • Testing Limit Orders
  • EIP-1153: Transient Storage

Swap & After Swap

The following is a sequence diagram for the Swap & AfterSwap.

Swap & After Swap, Sequence Diagram

Now I’ll cover the swap to change the tick and the afterSwap hook to fulfill the limit order.

First, swap updates slot0 without much change from V3 other than checking the hook flag before and after the swap. Of course, it will acquire a lock, make the delta value zero, and move out of the lock.

You can see the lockAcquired function in v4-core/contracts/test/PoolSwapTest.sol.

function lockAcquired(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, new bytes(0));
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()));
}
}
} else {
...
}

The poolManager’s swap function returns delta. For tokenIn, the delta value will be positive in the poolManager contract, and transferFrom and settle will make the delta value zero, just like when providing liquidity. For tokenOut, the delta value is negative in the poolManager contract, and a function called take is used to make the delta value zero.

We covered transferFrom and settle when providing liquidity, so let’s take a look at the take function.

v4-core/contracts/PoolManager.sol

function take(Currency currency, address to, uint256 amount) external override noDelegateCall onlyByLocker {
_accountDelta(currency, amount.toInt128());
reservesOf[currency] -= amount;
currency.transfer(to, amount);
}

It’s very simple. The _accountDelta function adds an amount to the delta value to make it go from negative to zero, updates the reservesOf variable, and transfers it.

And in the swap function of the PoolManager contract, we check the afterSwap flag and execute the afterSwap function of the limitOrder contract as shown below.

v4-core/contracts/PoolManager.sol

function swap(
PoolKey memory key,
IPoolManager.SwapParams memory params,
bytes calldata hookData
) external override noDelegateCall onlyByLocker returns
...
if (key.hooks.shouldCallAfterSwap()) {
if (key.hooks.afterSwap(msg.sender, key, params, delta, hookData) != IHooks.afterSwap.selector) {
revert Hooks.InvalidHookResponse();
}
}

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

function afterSwap(
address,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
BalanceDelta,
bytes calldata
) external override poolManagerOnly returns (bytes4) {
(int24 tickLower, int24 lower, int24 upper) = _getCrossedTicks(key.toId(), key.tickSpacing);
if (lower > upper) return LimitOrder.afterSwap.selector;

// note that a zeroForOne swap means that the pool is actually gaining token0, so limit
// order fills are the opposite of swap fills, hence the inversion below
bool zeroForOne = !params.zeroForOne;
for (; lower <= upper; lower += key.tickSpacing) {
_fillEpoch(key, lower, zeroForOne);
}

setTickLowerLast(key.toId(), tickLower);
return LimitOrder.afterSwap.selector;
}

First, it uses the poolManagerOnly modifier to check if msg.sender is a poolManager.

In the afterSwap function, the user doing the swap is going through a “for” loop and filling all the limit orders that meet the conditions of the “for” loop through the _fillEpoch function. In the “for” loop, the condition variable increments by tickSpacing, from tickLowerLast to the new tick that was changed by the swap. This leads to the swap costing more gas.

*We mentioned at the beginning that pool deployer can freely set the tickSpacing and fee tier. If you make the tickSpacing somewhat larger, you can make less “for” loops, but the tick range of the limit order will be wider and the UX will be worse. Choosing a smaller value for tickSpacing does the opposite. If you set the “fee” low, the order router or UniswapX will catch this pool and process the limit order faster, and if you set it high, the users who placed the limit order will receive an accumulated extra fee, which means they end up getting filled at a better price. It’s important to consider a number of factors to determine the appropriate tickSpacing and fee.

Let’s look at the _fillEpoch function in v4-periphery/contracts/hooks/examples/LimitOrder.sol.

function _fillEpoch(PoolKey calldata key, int24 lower, bool zeroForOne) internal {
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);
}
}

In the function above, it iscalling the lock function and getting a callback from the poolManager by using the lockAcquiredFill function encoded as an argument. Let’s take a look at the lockAcquiredFill function.

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

function lockAcquiredFill(PoolKey calldata key, int24 tickLower, int256 liquidityDelta)
external
selfOnly
returns (uint128 amount0, uint128 amount1)
{
BalanceDelta delta = poolManager.modifyPosition(
key,
IPoolManager.ModifyPositionParams({
tickLower: tickLower,
tickUpper: tickLower + key.tickSpacing,
liquidityDelta: liquidityDelta
}),
ZERO_BYTES
);
if (delta.amount0() < 0) poolManager.mint(key.currency0, address(this), amount0 = uint128(-delta.amount0()));
if (delta.amount1() < 0) poolManager.mint(key.currency1, address(this), amount1 = uint128(-delta.amount1()));
}

Since the lockAcquiredFill function fulfills limit orders, it removes liquidity from the pool that users have put in. This is similar to the decreaseLiquidity function in the previous article of “Limit Orders in V3”, i.e. it doesn’t withdraw, but modifyPosition.

The delta value of the token being removed from the poolManager contract will be negative. Now, should we use the “take” function to get the token back and make the delta value zero? We’re not doing that here, which means we’re not doing the “collect” from the previous article of “Limit Orders in V3”. Then how do we get out of the lock? V4 utilizes ERC1155, which was not in V3. The “if” statement specifies that if the delta value is negative, it will mint an amount of ERC1155 tokens to the limitOrder contract address, which is itself.

Let’s look at the mint function in v4-core/contracts/PoolManager.sol.

/// @inheritdoc IPoolManager
function mint(Currency currency, address to, uint256 amount) external override noDelegateCall onlyByLocker {
_accountDelta(currency, amount.toInt128());
_mint(to, currency.toId(), amount, '');
}

The _accountDelta function adds the amount to the delta value to make the delta equal to 0. Then, it mints ERC1155. ERC1155 can be minted multiple with the same token address as the id value as shown above. Unlike ERC721, ERC1155 tokens with the same id are fungible.

In this way, even if the value owed by the poolManager to the hook contract or user is not immediately taken by the hook contract or user, it is guaranteed to be withdrawn later by minting and holding ERC1155 tokens. It is also stated in the Uniswap V4 whitepaper that it is called ERC1155 Accounting as it can handle accounting between poolManager and users without transferring tokens.

And v4-periphery/contracts/hooks/examples/LimitOrder.sol implements the onERC1155Received function.

function onERC1155Received(address, address, uint256, uint256, bytes calldata) external view returns (bytes4) {
if (msg.sender != address(poolManager)) revert NotPoolManagerToken();
return IERC1155Receiver.onERC1155Received.selector;
}

These non-EOA contracts must implement onERC1155Received in order to receive ERC1155 tokens. By standard, contracts that do not implement onERC1155Received cannot receive ERCC1155 tokens.

The onERC1155Received function above checks if msg.sender is a poolManager, which prevents from maliciously obtaining ERC1155 tokens elsewhere and withdrawing the poolManager’s tokens.

Withdraw

The following is a sequence diagram of the Withdraw.

Withdraw, Sequence Diagram

Let’s look at the withdraw function in v4-periphery/contracts/hooks/examples/LimitOrder.sol.

function withdraw(Epoch epoch, address to) external returns (uint256 amount0, uint256 amount1) {
EpochInfo storage epochInfo = epochInfos[epoch];
if (!epochInfo.filled) revert NotFilled();

uint128 liquidity = epochInfo.liquidity[msg.sender];
if (liquidity == 0) revert ZeroLiquidity();
delete epochInfo.liquidity[msg.sender];

uint256 token0Total = epochInfo.token0Total;
uint256 token1Total = epochInfo.token1Total;
uint128 liquidityTotal = epochInfo.liquidityTotal;

amount0 = FullMath.mulDiv(token0Total, liquidity, liquidityTotal);
amount1 = FullMath.mulDiv(token1Total, liquidity, liquidityTotal);

epochInfo.token0Total = token0Total - amount0;
epochInfo.token1Total = token1Total - amount1;
epochInfo.liquidityTotal = liquidityTotal - liquidity;
poolManager.lock(
abi.encodeCall(this.lockAcquiredWithdraw, (epochInfo.currency0, epochInfo.currency1, amount0, amount1, to))
);

emit Withdraw(msg.sender, epoch, liquidity);
}

The above function gets the liquidity value of the withdrawing user, divides it by liquidityTotal and multiplies it by tokenTotal to withdraw the proportional amount. It then updates the epochInfo variable.

As always, it calls the lock function of the poolManager contract and interacts with the poolManager contract via the lockAcquiredWithdraw function.

Let’s look at the lockAcquiredWithdraw function in v4-periphery/contracts/hooks/examples/LimitOrder.sol.

function lockAcquiredWithdraw(
Currency currency0,
Currency currency1,
uint256 token0Amount,
uint256 token1Amount,
address to
) external selfOnly {
if (token0Amount > 0) {
poolManager.safeTransferFrom(
address(this), address(poolManager), uint256(uint160(Currency.unwrap(currency0))), token0Amount, ""
);
poolManager.take(currency0, to, token0Amount);
}
if (token1Amount > 0) {
poolManager.safeTransferFrom(
address(this), address(poolManager), uint256(uint160(Currency.unwrap(currency1))), token1Amount, ""
);
poolManager.take(currency1, to, token1Amount);
}
}

Here, it transfers an amount of ERC1155 tokens equal to tokenAmount to withdraw to the poolManager contract and call the take function.

You may remember the take function from swap. The withdrawer must make the delta value of the token negative in the poolManager so that it can be withdrawn by calling the “take” function. Withdrawer can make the delta negative by transferring the ERC1155 token to the poolManager contract. Since PoolManager is also a contract, they implemented onERC1155Received. Let’s look at that function.

v4-core/contracts/PoolManager.sol

function onERC1155Received(address, address, uint256 id, uint256 value, bytes calldata) external returns (bytes4) {
if (msg.sender != address(this)) revert NotPoolManagerToken();
_burnAndAccount(CurrencyLibrary.fromId(id), value);
return IERC1155Receiver.onERC1155Received.selector;
}

function _burnAndAccount(Currency currency, uint256 amount) internal {
_burn(address(this), currency.toId(), amount);
_accountDelta(currency, -(amount.toInt128()));
}

The PoolManager burns the ERC1155 it received and uses the _accountDelta function to make the token’s delta value negative and increments the nonzeroDeltaCount by one. Then, withdrawer withdraws the token through the “take” function, which we covered in “swap” section, and set the delta value and nonzeroDeltaCount to 0 to get out of the lock.

This is how I analyzed UniswapV4 in general and limit orders in particular. Now let’s move on to testing on real data.

Setting up a hook contract test in the Foundry environment

First of all, the Uniswap V4 code is developed in the Foundry framework environment, and an example of the test code is publicly available. In this section, we’ll cover setting up a development environment to develop hook contracts. Before we get started, you’ll need to download Foundry. You can follow this Foundry Docs guide to download it.

Next, create a directory and run the following commands to set up your Foundry environment.

forge init .

If you get an error like this…

Error: 
Cannot run `init` on a non-empty directory.

Add --force to the end of the command.

If you look at the folders and files after the command is executed, you can see the Counter.sol file in the src folder and a simple test code in the test folder. Also, a library called forge-std is downloaded along with it.

Let’s download the v4-periphery package which contains the BaseHook contract.

forge install https://github.com/Uniswap/periphery-next

This command will clone the GitHub repository of v4-periphery to the lib folder. You can then import the contract. In a foundry environment, the contract files are imported from the lib folder, not from node_modules. Let’s import the BaseHook contract.

import {BaseHook} from "../lib/periphery-next/contracts/BaseHook.sol";

To omit the prefixes ../lib and /contracts, you can set up something called remappings in the foundry environment. Run the following command

forge remappings

result

@ensdomains/=lib/periphery-next/lib/v4-core/node_modules/@ensdomains/
@openzeppelin/=lib/periphery-next/lib/openzeppelin-contracts/
@uniswap/v4-core/=lib/periphery-next/lib/v4-core/
ds-test/=lib/forge-std/lib/ds-test/src/
erc4626-tests/=lib/periphery-next/lib/openzeppelin-contracts/lib/erc4626-tests/
forge-gas-snapshot/=lib/periphery-next/lib/forge-gas-snapshot/src/
forge-std/=lib/forge-std/src/
hardhat/=lib/periphery-next/lib/v4-core/node_modules/hardhat/
openzeppelin-contracts/=lib/periphery-next/lib/openzeppelin-contracts/
openzeppelin/=lib/periphery-next/lib/openzeppelin-contracts/contracts/
periphery-next/=lib/periphery-next/contracts/
solmate/=lib/periphery-next/lib/solmate/src/
v4-core/=lib/periphery-next/lib/v4-core/contracts/

The foundry will refer to the lib folder and automatically output the remappings. Note that the third line from the end says that periphery-next is the same as lib/periphery-next/contracts. Create a remappings.txt file in your root folder and paste the console output into it. You can then modify the import statement to look like this

import {BaseHook} from "periphery-next/BaseHook.sol";

In this environment, you can develop hook contracts, write test code, and test them. See v4-periphery/hooks/examples/LimitOrder.sol for the limit order hook contract and v4-periphery/test/LimitOrder.t.sol for the test code.

“beforeEach” in the Mocha test environment is “setUp” in the Foundry. The setUp function is called before each test case is executed. The setUp code looks like this

function setUp() public {
initializeTokens();
token0 = TestERC20(Currency.unwrap(currency0));
token1 = TestERC20(Currency.unwrap(currency1));
manager = new PoolManager(500000);
...
}

When we first create the PoolManager contract, we pass 500000 as an argument. That value is initialized by assigning it to a variable called “controllerGasLimit” in Fees.sol, which PoolManager inherits from. The controllerGasLimit is used in the _fetchPorotoclFees function of Fees.sol.

v4-core/contracts/Fees.sol

function _fetchProtocolFees(
PoolKey memory key
) internal view returns (uint8 protocolSwapFee, uint8 protocolWithdrawFee) {
if (address(protocolFeeController) != address(0)) {
// note that EIP-150 mandates that calls requesting more than 63/64ths of remaining gas
// will be allotted no more than this amount, so controllerGasLimit must be set with this
// in mind.
if (gasleft() < controllerGasLimit) revert ProtocolFeeCannotBeFetched();
try protocolFeeController.protocolFeesForPool{gas: controllerGasLimit}(key) returns (
uint8 updatedProtocolSwapFee,
uint8 updatedProtocolWithdrawFee
) {
protocolSwapFee = updatedProtocolSwapFee;
protocolWithdrawFee = updatedProtocolWithdrawFee;
} catch {
console.log('what!?');
}

_checkProtocolFee(protocolSwapFee);
_checkProtocolFee(protocolWithdrawFee);
}
}

First, it checks to see if the “protocolFeeController” is set. Only the owner can set the “protocolFeeController” to any address, which will probably be determined by a governance vote. And when we call the “protocolFeesForPool” function of the “protocolFeeController” that returns “protocolSwapFee” and “protocolWithdrawFee”, we specify the gas as “controllerGasLimit” to prevent more gas from being used in that function.

EIP-150 requires that you do not allocate more than 63/64 of the remaining gas (gasLeft()), so you need to set the “controllerGasLimit” with that in mind. The official Solidity documentation does not recommend explicitly specifying a gas value for an opcode, as it may change in the future, but this is how it is implemented, and I don’t even see an onlyOwner function that allows owner to change the “controllerGasLimit” state. If the gas cost of an opcode changes in the future and the “protocolFeesForPool” function of the “protocolFeeController” can no longer be called, the “protocolFeeController” address will need to be set up anew.

You can find an example of the protocolFeesForPool function in v4-core/contracts/test/ProtocolFeeControllerTest.sol.

function protocolFeesForPool(PoolKey memory key) external view returns (uint8, uint8) {
return (swapFeeForPool[key.toId()], withdrawFeeForPool[key.toId()]);
}

For this function, 500000 gas seems to be sufficient, and since they are currently setting controllerGasLimit to 500000 in all test code examples in v4-periphery, we will use this value.

The “_fetchPorotoclFees” function is used in the “initialize” and “setProtocolFees” functions of the PoolManager contract. Pools that are initialized before the protocol fee is set can be set using the setProtocolFees function.

v4-core/contracts/PoolManager.sol

function setProtocolFees(PoolKey memory key) external {
(uint8 newProtocolSwapFee, uint8 newProtocolWithdrawFee) = _fetchProtocolFees(key);
PoolId id = key.toId();
pools[id].setProtocolFees(newProtocolSwapFee, newProtocolWithdrawFee);
emit ProtocolFeeUpdated(id, newProtocolSwapFee, newProtocolWithdrawFee);
}

Since there will be so many pools, they’ve made it so that anyone can run it at the expense of gas.

Let’s continue to finalize the setUp function.

In the previous post, “Hook Contract Setup and CREATE2 Deployment”, I mentioned that we need to set the first 8 bits of the hook contract to a specific bit to deploy the hook contract using CREATE2. There is a way to test in the Foundry environment without doing this.

First, the LimitOrder contract has true flags for “afterInitialize” and “afterSwap”.

AFTER_INITIALIZE_FLAG

⇒ 1 << 158 ⇒ 2¹⁵⁸

⇒ to hex ⇒ 4000000000000000000000000000000000000000 (39 zeros)

AFTER_SWAP_FLAG

⇒ 1<<154 ⇒ 2¹⁵⁴

⇒ to hex ⇒ 400000000000000000000000000000000000000 (38 zeros)

Therefore, the address of the LimitOrder contract should start with 0x44, and in our test, we can set it to 0x4400000000000000000000000000000000000000000000000000 without any problem.

So, let’s create the LimitOrder variable in the test code as follows.

LimitOrder limitOrder = LimitOrder(address(0x4400000000000000000000000000000000000000));

In the above, there is no “new” keyword, so it is not a contract, just a variable of type LimitOrder.

Then let’s override the validateHookAddress function to make it an empty function and create a contract called LimitOrderImplementation that inherits from the LimitOrder contract. It looks like this.

test/shared/implementation/LimitOrderImplementation.sol

contract LimitOrderImplementation is LimitOrder {
constructor(IPoolManager _poolManager, LimitOrder addressToEtch) LimitOrder(_poolManager) {
Hooks.validateHookAddress(addressToEtch, getHooksCalls());
}

// make this a no-op in testing
function validateHookAddress(BaseHook _this) internal pure override {}
}

The reason for this is to avoid modifying the existing LimitOrder contract’s valdiateHookAddress function, just for testing purposes.

Next, in the setUp function, we can set up the limitOrder contract like this

function setUp() public {
...
vm.record();
LimitOrderImplementation impl = new LimitOrderImplementation(manager, limitOrder);
(, bytes32[] memory writes) = vm.accesses(address(impl));
vm.etch(address(limitOrder), address(impl).code);
// for each storage key that was written during the hook implementation, copy the value over
unchecked {
for (uint256 i = 0; i < writes.length; i++) {
bytes32 slot = writes[i];
vm.store(address(limitOrder), slot, vm.load(address(impl), slot));
}
}
...

vm.record

  • Instruction to start recording all storage reads and writes.
  • LimitOrderImplementation impl = new LimitOrderImplementation(manager, limitOrder) *recording storage writes

(, bytes32[] memory writes) = vm.accesses(address(impl));

  • In the writes array, we can get all the storage slots that LimitOrderImplementation wrote to.

vm.etch(address(limitOrder), address(impl).code);

  • Append the LimitOrderImplementation.sol code to the address 0x4400000000000000000000000000000000000000000000000000.

The “for” loop

  • The storage writes(e.g. poolManager address) that were made when deploying the LimitOrderImplementation are applied to the limitOrder address. This is done by using vm.load to get the value in the slot corresponding to the slot value of the impl and store it in the same slot.

This way, in the Foundry environment, we can attach code to any address and manipulate the slot without creating a contract.

Let’s continue with the setUp function.

function setUp() public {
...
key = PoolKey(currency0, currency1, 3000, 60, limitOrder);
id = key.toId();
manager.initialize(key, SQRT_RATIO_1_1, ZERO_BYTES);
...

We declare a key value of 3000 for the Fee, 60 for the tickSpacing, and a hook for the LimitOrder contract, and deploy the pool via the initialize function of the PoolManager contract.

When we deploy the pool, we pass the key value, SQRT_RATIO_1_1, and ZERO_BYTES as arguments.

SQRT_RATIO_1_1 is 79228162514264337593543950336.

In Uniswap, tick is a finite integer and can be both positive and negative. When storing a price, we store the sqrt(price) as a fixed-point number called Q64.96, which is a rational number with 64 bits for the integer part and 96 bits for the fractional part.
For example, assuming that token x has a reserve of 3 and token y has a reserve of 5, the process to convert x’s price to sqrtPriceX96 is as follows

In our test case, for simplicity’s sake, we’ll assume that the reserve is 1:1 and set the price to sqrt(1/1)*2⁹⁶ which is SQRT_RATIO_1_1. The last argument value is the value that will be passed to the callback function when the hook flag is checked and callbacked, which we will set to 0 since we don’t need it in this test case.

The “initialize” of the PoolManager was covered in the “Creating a Pool” section in the previous article. After calling the PoolManager’s initialize function, the result is as follows

slot0 = Slot0({

sqrtPriceX96: 79228162514264337593543950336,

tick: 0,

protocolSwapFee: 0,

hookSwapFee: 0,

protocolWithdrawFee: 0,

hookWithdrawFee: 0

});

The tick can be obtained by the following formula.

tick = log(sqrtPriceX96 / ²⁹⁶)

=> log(79228162514264337593543950336 / 2⁹⁶) ⇒ 0

The setUp function concludes like below.

function setUp() public {
...
swapRouter = new PoolSwapTest(manager);

token0.approve(address(limitOrder), type(uint256).max);
token1.approve(address(limitOrder), type(uint256).max);
token0.approve(address(swapRouter), type(uint256).max);
token1.approve(address(swapRouter), type(uint256).max);
}

For SwapRouter, you can currently use v4-core/contracts/test/PoolSwapTest.sol. The test setup is done by approving the limitOrder and swapRouter contracts beforehand in order to place a limit order.

Testing Limit Orders

With the test environment set up, it’s time to test the limit order. We have kept the test scenario as simple as possible and refer to v4-periphery/test/LimitOrder.t.sol. We’ll be testing the following sequence: place a limit order ⇒ swap ⇒ withdrawal.

We’ve covered the code for each scenario in detail before, so we’ll only cover how the state changes based on real data. Note that the poolManager manages states in a State struct for each pool.

v4-core/contracts/libraries/Pool.sol

struct State {
Slot0 slot0;
uint256 feeGrowthGlobal0X128;
uint256 feeGrowthGlobal1X128;
uint128 liquidity;
mapping(int24 => TickInfo) ticks;
mapping(int16 => uint256) tickBitmap;
mapping(bytes32 => Position.Info) positions;
}

place a limit order

placing a limit order, this illustration is a summary that completely omits the intermediate steps, so please refer to the following
function testLimitOrder() public {
int24 tickLower = 0;
bool zeroForOne = true;
uint128 liquidity = 1000000;
limitOrder.place(key, tickLower, zeroForOne, liquidity);
...

(modifyPoistion ⇒ transferFrom ⇒ settle)

(1) modifyPoistion

— PoolManager —

If tickLower is 0 and zeroForOne is true, the tick range will be 0 to 0+tickSpacing. We will provide 1000000 liquidity in the range of 0 to 60 ticks. Since the current tick is 0, we will only provide liquidity with token0.

feeGrowthGlobal0x128 = 0
feeGrowthGlobal1x128 = 0
liquidity = 1000000

updateTick

  • ticks[0]: { liquidityGross: 1000000, liquidityNet:1000000, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 0 }
  • ticks[60]: { liquidityGross: 1000000, liquidityNet: -1000000, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 0 }

tickBitmap.flipTick

  • tickBitmap[0] = …00011 (binary, 254 zeros)
  • tickBitmap is a 256-bit mapping variable and each value is a word, and the index in tickBitmap[index] is wordPos. Each position in a word is called a bitPos. The bitPos starts at 0 and increments leftward to 255.

position update

  • positions[keccak256(abi.encodePacked(limit order hook address, 0, 60))] = { liquidity : 1000000, feeGrowthInside0LastX128: 0, feeGrowthInside1LastX128: 0 }

return delta.amount0 = 2996

  • 2996 is the value obtained by the getAmount0Delta function in v4-core/contracts/libraries/SqrtPriceMath.sol.
  • amount0Delta = (Liquidity * ²⁹⁶) * (UpperSqrtPriceX96 — LowerSqrtPriceX96) / UpperSqrtPriceX96 / LowerSqrtPriceX96
  • = (1000000 * 79228162514264337593543950336) * (79466191966197645195421774833–79228162514264337593543950336 ) / 79466191966197645195421774833 / 79228162514264337593543950336
  • = 2996

(2) transferFrom

user transfers 2996 token0 to poolManager

(3) settle

reservesOf[token0] = 2996

Delta Value Change (modifyPosition + transferFrom & settle)

modifyPosition

  • currencyDelta[limit order hook address][token0Address] = 2996
  • nonzeroDeltaCount increases from 0 to 1

transferFrom & settle

  • currencyDelta[limit order hook address][token0Address] = 2996–2996 = 0
  • nonzeroDeltaCount decreases from 1 to 0

— LimitOrder —

EpochInfos[epochs[keccack256(abi.encode(key,0,true))]] = {

filled:false,

currency0,

currency1,

token0Total:0,

token1Total:0,

liquidityTotal:1000000,

liquidity[msg.sender]:1000000

}

After placing the limit order, you can proceed to assertion testing like this.

function testLimitOrder() public {
...
assertTrue(
EpochLibrary.equals(
limitOrder.getEpoch(key, tickLower, zeroForOne),
Epoch.wrap(1)
)
);
assertEq(
manager.getLiquidity(
id,
address(limitOrder),
tickLower,
tickLower + 60
),
liquidity
);
...
}

Note that epoch stores values starting at 1.

Swap & SwapAfter

Swap, this illustration is a summary that completely omits the intermediate steps, so please refer to the following
function testLimitOrder() public {
...
swapRouter.swap(
key,
IPoolManager.SwapParams(
false,
1e18,
TickMath.getSqrtRatioAtTick(60)
),
PoolSwapTest.TestSettings(true, true)
);

The swapRouter calls the PoolManager’s lock function.

The SwapParams can be found in v4-core/contracts/interfaces/IPoolManager.sol and look like this.

struct SwapParams {
bool zeroForOne;
int256 amountSpecified;
uint160 sqrtPriceLimitX96;
}

Since zeroForOne is false, we are swapping token1 for token0. We set amountSpecified to 1e18, and there are currently only 2996 token0s in the pool. Since we set sqrtPriceLimitX96 to getSqrtRatioAtTick(60), we will not transfer all 1e18 (since there is not as much liquidity between 0 and 60 as 1e18), and a partial swap will occur.

The TestSettings can be found at v4-core/contracts/test/PoolSwapTest.sol.

struct TestSettings {
bool withdrawTokens;
bool settleUsingTransfer;
}

Setting “withdrawTokens” to true will mint ERC1155 without “take”. false does the opposite.

If “settleUsingTransfer” is set to true, it will settle with token transferFrom, and if set to false, it will transfer ERC1155 to PoolManager.

For this test, we set both to true.

— PoolManager —

tickBitmap.nextInitializedTickWithinOneWord

v4-core/contracts/libraries/TickBitmap.sol

function nextInitializedTickWithinOneWord(
mapping(int16 => uint256) storage self,
int24 tick,
int24 tickSpacing,
bool lte
) internal view returns (int24 next, bool initialized) {
unchecked {
int24 compressed = tick / tickSpacing;
...
(int16 wordPos, uint8 bitPos) = position(compressed + 1);
// all the 1s at or to the left of the bitPos
uint256 mask = ~((1 << bitPos) - 1);
uint256 masked = self[wordPos] & mask;

...
function position(int24 tick) private pure returns (int16 wordPos, uint8 bitPos) {
unchecked {
wordPos = int16(tick >> 8);
bitPos = uint8(int8(tick % 256));
}
}

This mask operation finds the next initialized tick. Since zeroForOne is true and token0’s price is increasing, we need to find a larger tick.

Current tick: 0, word:0, bitPos:1

  • comprssed is 0/60 = 0. Then we add 1 to compressed, so that wordPos is 0 and bitPos is 1. The reason for the addition of 1 is that the current bitPos value is excluded when looking for a larger bitPos value.

Current tickBitmap value: tickBitmap[0] = …00011 (binary, 254 zeros)

  • Since zeroForOne is true, we find a tick initialized from the left including itself, based on the current 1 bitPos. The mask is ~((1 << bitPos) — 1) ⇒ 111….1110 and we can use the wordPos & mask operation to find a tick that is initialized in the same word, which is 1 bitPos. Multiplying 1 bitPos by 60 tickSpacing gives us a tickNext value of 60.

computeSwapStep

  • amountIn = RoundingUp(Liquidity * (UpperSqrtPriceX96 — LowerSqrtPriceX96) /2⁹⁶)
    => 1000000 * (79466191966197645195421774833–79228162514264337593543950336) / 2⁹⁶ = 3005
  • amountOut = (Liquidity *2⁹⁶) * (UpperSqrtPriceX96 — LowerSqrtPriceX96) / UpperSqrtPriceX96 / LowerSqrtPriceX96
    => (1000000 *2⁹⁶) * (79466191966197645195421774833–79228162514264337593543950336) / 79466191966197645195421774833 / 79228162514264337593543950336 = 2995
  • feeAmount = RoundingUp(amountIn * 3000 / (1000000–3000)) = 10
  • feeGrowthGlobal1X128 = feeAmount *2¹²⁸ / liquidity = 3402823669209384634633746074317682
  • ticks[0]: { liquidityGross: 1000000, liquidityNet:1000000, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 0 }
  • ticks[60]: { liquidityGross: 1000000, liquidityNet: -1000000, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 0 }
    => ticks[60]: { liquidityGross: 1000000, liquidityNet: -1000000, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 3402823669209384634633746074317682 }

Delta Value Change (swap)

  • currencyDelta[swapRouter address][token0Address] = -2995
  • currencyDelta[swapRouter address][token1Address] = 3015
  • nonzeroDeltaCount increases from 0 to 2

Call the AfterSwap hook function

AfterSwap, this illustration is a summary that completely omits the intermediate steps, so please refer to the following

The LimitOrder contract calls the PoolManager’s “lock” function.

Remove liquidity by 1000000(EpochInfos[epochs[keccack256(abi.encode(key,0,true))]].liquidityTotal) in the range 0 to 60 ticks via modifyPosition of lockAcquiredFilled.

— PoolManager —

feeGrowthGlobal0x128 = 0

feeGrowthGlobal1x128 = 0 ⇒ 3402823669209384634633746074317682

liquidity = 1000000 ⇒ 0

updateTick

  • ticks[0]: { liquidityGross: 1000000, liquidityNet:1000000, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 0 }
    ⇒ ticks[0]: { liquidityGross: 0, liquidityNet:0, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 0 }
  • ticks[60]: { liquidityGross: 1000000, liquidityNet: -1000000, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 0 }
    => ticks[60]: { liquidityGross: 0, liquidityNet: 0, feeGrowthOutside0X128: 0, feeGrowthOutside1X128: 3402823669209384634633746074317682 }

tickBitmap.flipTick

  • tickBitmap[0] = …00011 (binary, 254 zeros) ⇒ …00000 (256 zeros)

position update

  • positions[keccak256(abi.encodePacked(limit order hook address, 0, 60))] = { liquidity : 1000000, feeGrowthInside0LastX128: 0, feeGrowthInside1LastX128: 0 }
    ⇒ positions[keccak256(abi.encodePacked(limit order hook address, 0, 60))] = { liquidity : 0, feeGrowthInside0LastX128: 0, feeGrowthInside1LastX128: 3402823669209384634633746074317682 }

feesOwed = 9

  • feesOwed1 = (feeGrowthInside1x128 — _self.feeGrowthInside1LastX128) * liquidity / 2¹²⁸
    = (3402823669209384634633746074317682–0) * 1000000 / 340282366920938463463374607431768211456 = 9

return delta.amount1 = -3004 + (-9) = -3013

  • delta.amount1 = -(Liquidity * (UpperSqrtPriceX96 — LowerSqrtPriceX96) / 2⁹⁶) + (-feesOwed)
  • = -(1000000 * (79466191966197645195421774833–79228162514264337593543950336 ) / 79228162514264337593543950336) + (-9) = -3013

— LimitOrder —

ERC1155 mint

ERC1155 mint by 3013 to LimitOrder address

Delta Value Change (modifyPosition + mint ERC1155)

modifyPosition

  • currencyDelta[limit order hook address][token1Address] = -3013
  • nonzeroDeltaCount는 increases from 2 to 3

mint ERC1155

  • currencyDelta[limit order hook address][token0Address] = -3013 + 3013 = 0
  • nonzeroDeltaCount decreases from 3 to 2

EpochInfos[epochs[keccack256(abi.encode(key,0,true))]] = {

filled:false, ⇒ true

currency0,

currency1,

token0Total:0,

token1Total: 0 ⇒ 3013,

liquidityTotal:1000000,

liquidity[msg.sender]:1000000

}

Finalizing the Swap

take (token0)

  • reservesOf[token0] = 2996–2995 = 1
  • currencyDelta[swapRouter address][token0Address] = -2995 + 2995 = 0
  • nonzeroDeltaCount decreases from 2 to 1
  • transfer to swapper

transfer token1 to PoolManager

  • reservesOf[token1] = 3015

settle token1

  • currencyDelta[swapRouter address][token1Address] = 3015–3015 = 0
  • nonzeroDeltaCount decreases from 1 to 0

Once the swap is done, you can proceed to assertion testing like this.

function testLimitOrder() public {
...
assertEq(limitOrder.getTickLowerLast(id), 60);
(, int24 tick, , , , ) = manager.getSlot0(id);
assertEq(tick, 60);

(
bool filled,
,
,
uint256 token0Total,
uint256 token1Total,

) = limitOrder.epochInfos(Epoch.wrap(1));
assertTrue(filled);
assertEq(token0Total, 0);
assertEq(token1Total, 3013);
assertEq(
manager.getLiquidity(
id,
address(limitOrder),
tickLower,
tickLower + 60
),
0
);
...
}

Withdraw

Withdraw, this illustration is a summary that completely omits the intermediate steps, so please refer to the following
function testLimitOrder() public {
...
limitOrder.withdraw(Epoch.wrap(1), <address to receive>);
...

The first argument is the epcoh index and the second argument is the address to receive.

EpochInfos[epochs[keccack256(abi.encode(key,0,true))]] = {

filled:false, ⇒ true

currency0,

currency1,

token0Total:0,

token1Total: 0 ⇒ 3013 ⇒ 3013–3013 = 0

liquidityTotal:1000000 ⇒ 1000000–1000000 = 0

liquidity[msg.sender]:1000000 ⇒ delete liquidity[msg.sender] ⇒ 0

}

ERC1155 safeTransferFrom

  • The LimitOrder contract sends 3013 ERC1155 tokens to the PoolManager.
  • The PoolManager burns all the ERC1155 tokens received.
  • currencyDelta[limit order hook address][token0Address] = -3013
  • nonzeroDeltaCount increases from 0 to 1

Take

  • reservesOf[token1] = 3015–3013 = 2
  • nonzeroDeltaCount decreases from 1 to 0
  • transfers 3013 token1 from poolManager to <receiver address>

Once we’re done with the withdrawal, we can proceed with the assertion test like this.

function testLimitOrder() public {
...
(, , , token0Total, token1Total, ) = limitOrder.epochInfos(
Epoch.wrap(1)
);

assertEq(token0Total, 0);
assertEq(token1Total, 0);
}

Finished the test.

There’s a lot I haven’t covered yet in Uniswap V4. I’ll conclude with a quick look at EIP-1153 Transient Storage.

EIP-1153: Transient Storage

EIP-1153 is included in the scope of the Ethereum “Dencun” upgrade in January 2024, and the Uniswap V4 whitepaper states that it will use Transient Storage to reduce gas costs. I’ll cover this briefly.

Source: Pascal Marco Caversaccio

In addition to the existing “Calldata”, “Memory”, or “Storage”, Transient Storage is introduced as shown above. Transient Storage is volatile at the end of the transaction like “memory”, and can be read and written from the global scope like storage(world state).

There will be new opcodes “tload” and “tstore” for reading and writing, which are 95% cheaper than “sload” and “sstore”. This can be very useful in the following situation.

This is the delta value commonly used in Uniswap V4. The delta will eventually start at zero and end at zero. So there is no problem with it being volatile at the end of the transaction. And since the value is not just used within a single function, but across functions, it should have a global scope, just like world state. Therefore, it’s perfect to store delta values in Transient Storage. By eliminating unnecessary storage operations, we can save a lot of gas.

*In a future TONDEX project, we will work on migrating Uniswap V3 to V4. We will introduce a new fee policy that will greatly benefit liquidity providers using hook contracts, and we will continue to explore and develop ideas for new DeFi products.

I would like to close this article. Thanks for reading and feedback is welcome.

Reference

Thanks to Kevin, Zena for feedback on this post.

--

--