Limit Order
The Limit Order hook for Uniswap V4 enables users to place conditional orders that execute automatically when specific price conditions are met. It implements a traditional limit order mechanism within the Uniswap V4 ecosystem, allowing traders to specify the exact price at which they want to trade.
Key features:
-
Supports both buying and selling directions (zeroForOne and oneForZero)
-
Manages orders through an epoch system
-
Allows multiple users to place orders at the same price level
-
Provides order lifecycle management (place, execute, cancel, withdraw)
-
Integrates seamlessly with Uniswap V4's hook system
This implementation serves as a fundamental building block for more sophisticated trading strategies on Uniswap V4.
The analysis of the Limit Order Hook in this document is entirely based on the Limit Order contract.
Epoch
A new concept introduced for designing limit order contracts is called Epoch. In this context, an Epoch is a uint232
type, which acts as a unique identifier for each limit order.
type Epoch is uint232;
From the snippet below, we can see that limit order information is hashed and mapped to the epoch value. The epoch is an auto-incrementing data type starting from 1. Since uint232
can represent an extremely large range of values, there is no need to worry about conflicts between limit orders.
mapping(bytes32 => Epoch) public epochs;
function getEpoch(PoolKey memory key, int24 tickLower, bool zeroForOne) public view returns (Epoch) {
return epochs[keccak256(abi.encode(key, tickLower, zeroForOne))];
}
function setEpoch(PoolKey memory key, int24 tickLower, bool zeroForOne, Epoch epoch) private {
epochs[keccak256(abi.encode(key, tickLower, zeroForOne))] = epoch;
}
Each order’s information is stored in EpochInfo
. The filled
field indicates whether the limit order has been executed, which helps prevent double-filling of orders. The currency
field, representing a currency token address, is used to identify the currency involved in the transaction. tokenTotal
tracks the total amount of tokens accumulated from filled orders, which can be withdrawn by the user after the order is executed.
The liquidityTotal
field represents the total liquidity provided by all users for a specific epoch of the limit order. Additionally, the liquidity
mapping keeps track of each user’s contribution to the liquidity. Why is this design necessary?
This approach allows a single limit order to support contributions from multiple users. For example:
Let's say there's a ETH/USDC limit order where:
- User A provides 60 units of liquidity
- User B provides 40 units of liquidity
- liquidityTotal would be 100
- After the order fills and collects 1000 USDC:
- token1Total would be 1000 USDC
- User A can withdraw (60/100 * 1000) = 600 USDC
- User B can withdraw (40/100 * 1000) = 400 USDC
mapping(Epoch => EpochInfo) public epochInfos;
struct EpochInfo {
bool filled; // Whether the order is filled, default is false
Currency currency0; // First token in the pair
Currency currency1; // Second token in the pair
uint256 token0Total; // Total token0 amount
uint256 token1Total; // Total token1 amount
uint128 liquidityTotal; // Total liquidity
mapping(address => uint128) liquidity; // Liquidity per user
}
This structure ensures a fair distribution of the proceeds from filled orders, proportional to each user’s liquidity contribution.
Place Order
The place
function enables users to create limit orders at a specific price (tick). Using the zeroForOne
flag, users can specify whether they want to swap token 0 for token 1 or vice versa. As previously mentioned, each order is assigned a unique Epoch identifier. In the Uniswap system, the amount of tokens provided is represented as liquidity. The key parameter specifies the pool associated with the limit order.
function place(PoolKey calldata key, int24 tickLower, bool zeroForOne, uint128 liquidity)
external
onlyValidPools(key.hooks)
{
if (liquidity == 0) revert ZeroLiquidity();
manager.unlock(
abi.encodeCall(
this.unlockCallbackPlace, (key, tickLower, zeroForOne, int256(uint256(liquidity)), msg.sender)
)
);
EpochInfo storage epochInfo;
Epoch epoch = getEpoch(key, tickLower, zeroForOne);
if (epoch.equals(EPOCH_DEFAULT)) {
unchecked {
setEpoch(key, tickLower, zeroForOne, epoch = epochNext);
epochNext = epoch.unsafeIncrement();
}
epochInfo = epochInfos[epoch];
epochInfo.currency0 = key.currency0;
epochInfo.currency1 = key.currency1;
} else {
epochInfo = epochInfos[epoch];
}
unchecked {
epochInfo.liquidityTotal += liquidity;
epochInfo.liquidity[msg.sender] += liquidity;
}
emit Place(msg.sender, epoch, key, tickLower, zeroForOne, liquidity);
}
This function first calls manager.unlock
to handle the liquidity modification process, which adjusts the liquidity of the pool and transfers the corresponding tokens from the user. Subsequently, it updates the epoch information to record how much liquidity each user has provided.
If the getEpoch
function does not return an existing epoch, it indicates that the epoch has not been created yet. In this case, the epoch defaults to 0. The function then initializes the epoch with currency information and increments epochNext
to prepare for the next epoch.
Kill Order
The kill
function allows a user to cancel an order before it is filled. When the order is killed, the tokens locked in the pool are withdrawn by the user. Additionally, the kill
function adjusts certain epochInfo
values accordingly.
function kill(PoolKey calldata key, int24 tickLower, bool zeroForOne, address to) external {
Epoch epoch = getEpoch(key, tickLower, zeroForOne);
EpochInfo storage epochInfo = epochInfos[epoch];
if (epochInfo.filled) revert Filled();
uint128 liquidity = epochInfo.liquidity[msg.sender];
if (liquidity == 0) revert ZeroLiquidity();
delete epochInfo.liquidity[msg.sender];
uint256 amount0Fee;
uint256 amount1Fee;
(amount0Fee, amount1Fee) = abi.decode(
manager.unlock(
abi.encodeCall(
this.unlockCallbackKill,
(key, tickLower, -int256(uint256(liquidity)), to, liquidity == epochInfo.liquidityTotal)
)
),
(uint256, uint256)
);
epochInfo.liquidityTotal -= liquidity;
unchecked {
epochInfo.token0Total += amount0Fee;
epochInfo.token1Total += amount1Fee;
}
emit Kill(msg.sender, epoch, key, tickLower, zeroForOne, liquidity);
}
The kill order can only occur before the order is filled. Therefore, if epochInfo.filled == true
, an error will be triggered. The details of the kill order and fund withdrawal logic are handled in the unlockCallbackKill
function. From the code logic, if the user is not the last one to withdraw funds for that epoch, they can only withdraw their principal. The LP fees they earned will be transferred to the Limit Order contract. Only the last user in the epoch can withdraw both their principal and the LP fees.
If removingAllLiquidity
is false, there are two stages in modifying liquidity. The first stage is withdrawing LP fees, which are then sent to the Limit Order contract. The second stage involves withdrawing the principal to the user.
function unlockCallbackKill(
PoolKey calldata key,
int24 tickLower,
int256 liquidityDelta,
address to,
bool removingAllLiquidity
) external selfOnly returns (uint128 amount0Fee, uint128 amount1Fee) {
int24 tickUpper = tickLower + key.tickSpacing;
if (!removingAllLiquidity) {
(, BalanceDelta deltaFee) = manager.modifyLiquidity(
key,
IPoolManager.ModifyLiquidityParams({
tickLower: tickLower,
tickUpper: tickUpper,
liquidityDelta: 0,
salt: 0
}),
ZERO_BYTES
);
if (deltaFee.amount0() > 0) {
manager.mint(address(this), key.currency0.toId(), amount0Fee = uint128(deltaFee.amount0()));
}
if (deltaFee.amount1() > 0) {
manager.mint(address(this), key.currency1.toId(), amount1Fee = uint128(deltaFee.amount1()));
}
}
(BalanceDelta delta,) = manager.modifyLiquidity(
key,
IPoolManager.ModifyLiquidityParams({
tickLower: tickLower,
tickUpper: tickUpper,
liquidityDelta: liquidityDelta,
salt: 0
}),
ZERO_BYTES
);
if (delta.amount0() > 0) {
key.currency0.take(manager, to, uint256(uint128(delta.amount0())), false);
}
if (delta.amount1() > 0) {
key.currency1.take(manager, to, uint256(uint128(delta.amount1())), false);
}
}
Multiple Accounts
The limit order contract supports multiple users placing orders at the same price (tick). This allows different liquidity providers to participate in the same limit order, increasing liquidity depth at that specific price level. You can refer to the following test contract for further details on how multiple users interact with a single limit order.
// test/LimitOrder.t.sol
function testMultipleLPs() public {
...
}
Swap and Withdraw
For swap ability, we just need to write an afterSwap hook. Each swap will automatically run the hook function. You will receive the tokens after the limit order is triggered and the swap occurs.
The question is, who pays the swap and receives gas fees? The answer is the swap executor, not the limit order provider. You might find this a bit strange, but that’s just how this project's system is designed.
From the test code, we can see that the function only needs to swap the token to let the tick cross the limit order price tick; then, the limit order will be triggered automatically.
// test/LimitOrder.t.sol
function testSwapAcrossRange() public {}
Thanks to the design of the hooks, especially the afterSwap
hook, when the price crosses the limit order, the limit order is executed automatically. Now, let's examine the function of the afterSwap
hook.
function afterSwap(
address,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
BalanceDelta,
bytes calldata
) external override onlyByManager returns (bytes4, int128) {
(int24 tickLower, int24 lower, int24 upper) = _getCrossedTicks(key.toId(), key.tickSpacing);
if (lower > upper) return (LimitOrder.afterSwap.selector, 0);
// 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, 0);
}
Initially, the afterSwap
hook needs to utilize the current tick price to determine the lower and upper tick ranges. We will complete the limit order within these ranges. The _getCrossedTicks
function is to handle this requirement.
First, the function uses getTickLower
to acquire the current tick price, tickLower. Then, it uses getTickLowerLast
to examine the last checkpoint tick lower price. If tickLower < tickLowerLast, it indicates a price decline. Therefore, the limit order check range is from the current tick lower + tickSpacing to tickLowerLast (within this range, all ticks have been crossed, so the limit order must be executed). If this condition is not met, then the reverse applies.
mapping(PoolId => int24) public tickLowerLasts;
function _getCrossedTicks(PoolId poolId, int24 tickSpacing)
internal
view
returns (int24 tickLower, int24 lower, int24 upper)
{
tickLower = getTickLower(getTick(poolId), tickSpacing);
int24 tickLowerLast = getTickLowerLast(poolId);
if (tickLower < tickLowerLast) {
lower = tickLower + tickSpacing;
upper = tickLowerLast;
} else {
lower = tickLowerLast;
upper = tickLower - tickSpacing;
}
}
function getTickLowerLast(PoolId poolId) public view returns (int24) {
return tickLowerLasts[poolId];
}
When params.zeroForOne is true, it indicates that the token is being transformed from 0 to 1. As a liquidity provider, all our tokens will be transformed to token 0. Thus, the limit order token transfer direction is opposite to that of the pool. Therefore, we use bool zeroForOne = !params.zeroForOne
to represent this difference.
Later, the afterSwap
function uses a for loop to examine each tick that has been crossed. It checks whether a limit order has been executed and returns the funds.
After the limit order has filled, it is necessary to update the last tick lower information by using setTickLowerLast
.
Let's dive into the _fillEpoch
function.
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) =
_unlockCallbackFill(key, lower, -int256(uint256(epochInfo.liquidityTotal)));
unchecked {
epochInfo.token0Total += amount0;
epochInfo.token1Total += amount1;
}
setEpoch(key, lower, zeroForOne, EPOCH_DEFAULT);
emit Fill(epoch, key, lower, zeroForOne);
}
}
This function first updates the epochInfo
message and then removes liquidity using the _unlockCallbackFill
function. After removing liquidity, the swapped limit order token will be withdrawn.