@zoralabs/limit-orders 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.turbo/turbo-build$colon$js.log +47 -45
  2. package/CHANGELOG.md +61 -0
  3. package/abis/IWETH.json +118 -0
  4. package/abis/IZoraLimitOrderBook.json +5 -0
  5. package/abis/LimitOrderLiquidity.json +7 -0
  6. package/abis/LimitOrderViews.json +62 -0
  7. package/abis/SwapWithLimitOrders.json +18 -11
  8. package/abis/ZoraLimitOrderBook.json +28 -0
  9. package/cache/solidity-files-cache.json +1 -1
  10. package/dist/index.cjs +29 -8
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.js +29 -8
  13. package/dist/index.js.map +1 -1
  14. package/dist/wagmiGenerated.d.ts +37 -9
  15. package/dist/wagmiGenerated.d.ts.map +1 -1
  16. package/out/BytesLib.sol/BytesLib.json +1 -1
  17. package/out/CoinCommon.sol/CoinCommon.json +1 -1
  18. package/out/CoinConfigurationVersions.sol/CoinConfigurationVersions.json +1 -1
  19. package/out/CoinConstants.sol/CoinConstants.json +1 -1
  20. package/out/DopplerMath.sol/DopplerMath.json +1 -1
  21. package/out/FixedPoint96.sol/FixedPoint96.json +1 -1
  22. package/out/ISwapRouter.sol/ISwapRouter.json +1 -1
  23. package/out/IUniswapV3SwapCallback.sol/IUniswapV3SwapCallback.json +1 -1
  24. package/out/IWETH.sol/IWETH.json +1 -0
  25. package/out/IZoraHookRegistry.sol/IZoraHookRegistry.json +1 -1
  26. package/out/IZoraLimitOrderBook.sol/IZoraLimitOrderBook.json +1 -1
  27. package/out/IZoraLimitOrderBookCoinsInterface.sol/IZoraLimitOrderBookCoinsInterface.json +1 -1
  28. package/out/IZoraV4CoinHook.sol/IZoraV4CoinHook.json +1 -1
  29. package/out/LimitOrderBitmap.sol/LimitOrderBitmap.json +1 -1
  30. package/out/LimitOrderCommon.sol/LimitOrderCommon.json +1 -1
  31. package/out/LimitOrderCreate.sol/LimitOrderCreate.json +1 -1
  32. package/out/LimitOrderFill.sol/LimitOrderFill.json +1 -1
  33. package/out/LimitOrderLiquidity.sol/LimitOrderLiquidity.json +1 -1
  34. package/out/LimitOrderQueues.sol/LimitOrderQueues.json +1 -1
  35. package/out/LimitOrderStorage.sol/LimitOrderStorage.json +1 -1
  36. package/out/LimitOrderTypes.sol/LimitOrderTypes.json +1 -1
  37. package/out/LimitOrderViews.sol/LimitOrderViews.json +1 -0
  38. package/out/LimitOrderWithdraw.sol/LimitOrderWithdraw.json +1 -1
  39. package/out/LiquidityAmounts.sol/LiquidityAmounts.json +1 -1
  40. package/out/Path.sol/Path.json +1 -1
  41. package/out/Permit2Payments.sol/Permit2Payments.json +1 -1
  42. package/out/SimpleAccessManaged.sol/SimpleAccessManaged.json +1 -1
  43. package/out/SimpleAccessManager.sol/SimpleAccessManager.json +1 -1
  44. package/out/SqrtPriceMath.sol/SqrtPriceMath.json +1 -1
  45. package/out/SwapLimitOrders.sol/SwapLimitOrders.json +1 -1
  46. package/out/SwapWithLimitOrders.sol/SwapWithLimitOrders.json +1 -1
  47. package/out/UniV4SwapToCurrency.sol/UniV4SwapToCurrency.json +1 -1
  48. package/out/UnsafeMath.sol/UnsafeMath.json +1 -1
  49. package/out/V3ToV4SwapLib.sol/V3ToV4SwapLib.json +1 -1
  50. package/out/ZoraLimitOrderBook.sol/ZoraLimitOrderBook.json +1 -1
  51. package/out/build-info/{69718f10d1dc37f0.json → 876cc09bc44cc8a7.json} +1 -1
  52. package/out/uniswap/BitMath.sol/BitMath.json +1 -1
  53. package/out/uniswap/CustomRevert.sol/CustomRevert.json +1 -1
  54. package/out/uniswap/FullMath.sol/FullMath.json +1 -1
  55. package/out/uniswap/SafeCast.sol/SafeCast.json +1 -1
  56. package/out/uniswap/TickMath.sol/TickMath.json +1 -1
  57. package/package/wagmiGenerated.ts +28 -7
  58. package/package.json +1 -1
  59. package/src/IZoraLimitOrderBook.sol +2 -0
  60. package/src/ZoraLimitOrderBook.sol +22 -8
  61. package/src/libs/LimitOrderBitmap.sol +0 -51
  62. package/src/libs/LimitOrderCommon.sol +48 -30
  63. package/src/libs/LimitOrderCreate.sol +5 -18
  64. package/src/libs/LimitOrderFill.sol +32 -161
  65. package/src/libs/LimitOrderLiquidity.sol +92 -71
  66. package/src/libs/LimitOrderViews.sol +168 -0
  67. package/src/libs/LimitOrderWithdraw.sol +13 -4
  68. package/src/libs/SwapLimitOrders.sol +14 -7
  69. package/src/router/SwapWithLimitOrders.sol +40 -26
  70. package/test/LimitOrderBitmap.t.sol +13 -7
  71. package/test/LimitOrderFill.t.sol +43 -0
  72. package/test/LimitOrderLibraries.t.sol +18 -10
  73. package/test/LimitOrderLiquidityPayouts.t.sol +280 -3
  74. package/test/LimitOrderWithdraw.t.sol +28 -1
  75. package/test/SwapWithLimitOrders.t.sol +3 -3
  76. package/test/SwapWithLimitOrdersRouter.t.sol +108 -11
  77. package/test/unit/LimitOrderBitmapUnit.t.sol +0 -134
  78. package/test/unit/LimitOrderCreateUnit.t.sol +32 -0
  79. package/test/unit/SwapLimitOrdersUnit.t.sol +231 -33
  80. package/test/unit/SwapLimitOrdersValidation.t.sol +20 -34
  81. package/test/unit/SwapWithLimitOrdersUnit.t.sol +21 -88
  82. package/test/utils/BaseTest.sol +29 -8
  83. package/test/utils/MockWETH.sol +39 -0
  84. package/test/utils/TestableZoraLimitOrderBook.sol +5 -7
@@ -0,0 +1,168 @@
1
+ // SPDX-License-Identifier: ZORA-DELAYED-OSL-v1
2
+ // This software is licensed under the Zora Delayed Open Source License.
3
+ // Under this license, you may use, copy, modify, and distribute this software for
4
+ // non-commercial purposes only. Commercial use and competitive products are prohibited
5
+ // until the "Open Date" (3 years from first public distribution or earlier at Zora's discretion),
6
+ // at which point this software automatically becomes available under the MIT License.
7
+ // Full license terms available at: https://docs.zora.co/coins/license
8
+ pragma solidity ^0.8.28;
9
+
10
+ import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
11
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
12
+ import {PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol";
13
+ import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol";
14
+ import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol";
15
+ import {LiquidityAmounts} from "@zoralabs/coins/src/utils/uniswap/LiquidityAmounts.sol";
16
+
17
+ import {LimitOrderStorage} from "./LimitOrderStorage.sol";
18
+ import {IZoraLimitOrderBook} from "../IZoraLimitOrderBook.sol";
19
+ import {CoinCommon} from "@zoralabs/coins/src/libs/CoinCommon.sol";
20
+
21
+ /// @title LimitOrderViews
22
+ /// @notice External library for view/pure functions to reduce contract size via library linking
23
+ library LimitOrderViews {
24
+ using PoolIdLibrary for PoolKey;
25
+
26
+ int24 internal constant TICK_SENTINEL = type(int24).max;
27
+
28
+ /// @notice Snaps the current pool tick to the nearest aligned tick and returns its neighbors.
29
+ /// @dev Uniswap v4 ticks must lie on multiples of `tickSpacing`. We round the live
30
+ /// tick down to the nearest aligned value (handling negatives), clamp it inside
31
+ /// the pool's usable range, then compute the next/previous aligned ticks for
32
+ /// callers that need to build deterministic tick windows.
33
+ function _alignedTicks(
34
+ IPoolManager poolManager,
35
+ PoolKey memory key,
36
+ int24 spacing
37
+ ) private view returns (int24 anchorTick, int24 nextAligned, int24 prevAligned) {
38
+ (, int24 currentTick, , ) = StateLibrary.getSlot0(poolManager, key.toId());
39
+ int256 remainder;
40
+ assembly ("memory-safe") {
41
+ remainder := smod(currentTick, spacing)
42
+ }
43
+
44
+ if (remainder == 0) {
45
+ anchorTick = currentTick;
46
+ } else if (currentTick >= 0) {
47
+ anchorTick = int24(int256(currentTick) - remainder);
48
+ } else {
49
+ anchorTick = int24(int256(currentTick) - remainder - spacing);
50
+ }
51
+
52
+ int24 minUsable = TickMath.minUsableTick(spacing);
53
+ int24 maxUsable = TickMath.maxUsableTick(spacing);
54
+
55
+ if (anchorTick < minUsable) {
56
+ anchorTick = minUsable;
57
+ } else if (anchorTick > maxUsable) {
58
+ anchorTick = maxUsable;
59
+ }
60
+
61
+ nextAligned = anchorTick + spacing;
62
+ if (nextAligned > maxUsable) {
63
+ nextAligned = maxUsable;
64
+ }
65
+
66
+ prevAligned = anchorTick - spacing;
67
+ if (prevAligned < minUsable) {
68
+ prevAligned = minUsable;
69
+ }
70
+ }
71
+
72
+ /// @notice Calculate liquidity for a limit order given size and tick range.
73
+ /// @param isCurrency0 Whether the order is for currency0.
74
+ /// @param size The size of the order.
75
+ /// @param tickLower Lower tick of the position.
76
+ /// @param tickUpper Upper tick of the position.
77
+ /// @return The liquidity amount for the order.
78
+ function liquidityForOrder(bool isCurrency0, uint256 size, int24 tickLower, int24 tickUpper) external pure returns (uint128) {
79
+ uint160 sqrtPriceLower = TickMath.getSqrtPriceAtTick(tickLower);
80
+ uint160 sqrtPriceUpper = TickMath.getSqrtPriceAtTick(tickUpper);
81
+ return
82
+ isCurrency0
83
+ ? LiquidityAmounts.getLiquidityForAmount0(sqrtPriceLower, sqrtPriceUpper, size)
84
+ : LiquidityAmounts.getLiquidityForAmount1(sqrtPriceLower, sqrtPriceUpper, size);
85
+ }
86
+
87
+ /// @notice Validates and resolves tick range for fill operations.
88
+ /// @param state The limit order storage layout.
89
+ /// @param poolManager The Uniswap v4 pool manager.
90
+ /// @param providedKey The pool key provided by the caller.
91
+ /// @param isCurrency0 Whether targeting currency0 orders.
92
+ /// @param startTick User-provided start tick or sentinel.
93
+ /// @param endTick User-provided end tick or sentinel.
94
+ /// @return canonicalKey The canonical pool key from storage or provided key.
95
+ /// @return resolvedStart Concrete start tick after resolving sentinels.
96
+ /// @return resolvedEnd Concrete end tick after resolving sentinels.
97
+ function validateTickRange(
98
+ LimitOrderStorage.Layout storage state,
99
+ IPoolManager poolManager,
100
+ PoolKey calldata providedKey,
101
+ bool isCurrency0,
102
+ int24 startTick,
103
+ int24 endTick
104
+ ) external view returns (PoolKey memory canonicalKey, int24 resolvedStart, int24 resolvedEnd) {
105
+ bytes32 poolKeyHash = CoinCommon.hashPoolKey(providedKey);
106
+ canonicalKey = state.poolKeys[poolKeyHash];
107
+ if (canonicalKey.tickSpacing == 0) {
108
+ canonicalKey = providedKey;
109
+ if (canonicalKey.tickSpacing == 0) revert IZoraLimitOrderBook.InvalidPoolKey();
110
+ }
111
+
112
+ (resolvedStart, resolvedEnd) = _resolveTickRange(poolManager, canonicalKey, isCurrency0, startTick, endTick);
113
+ _validateTickRange(isCurrency0, resolvedStart, resolvedEnd);
114
+ }
115
+
116
+ /// @notice Derives concrete tick bounds from user input and current pool state.
117
+ /// @dev Callers may pass sentinel values (`-TICK_SENTINEL` / `TICK_SENTINEL`) to mean
118
+ /// "start at the current tick" or "extend one spacing away". This helper translates
119
+ /// those sentinels into real ticks by snapping to the pool's aligned tick grid and
120
+ /// offsetting one spacing in the appropriate direction so fills never include
121
+ /// orders created in the same transaction.
122
+ function _resolveTickRange(
123
+ IPoolManager poolManager,
124
+ PoolKey memory key,
125
+ bool isCurrency0,
126
+ int24 startTick,
127
+ int24 endTick
128
+ ) private view returns (int24 resolvedStart, int24 resolvedEnd) {
129
+ int24 spacing = key.tickSpacing;
130
+
131
+ bool startSentinel = startTick == -TICK_SENTINEL;
132
+ bool endSentinel = endTick == TICK_SENTINEL;
133
+
134
+ (int24 anchorTick, int24 nextAligned, int24 prevAligned) = _alignedTicks(poolManager, key, spacing);
135
+
136
+ if (startSentinel) {
137
+ // Treat sentinel start as anchoring the window at the aligned tick.
138
+ resolvedStart = anchorTick;
139
+ resolvedEnd = endSentinel ? (isCurrency0 ? nextAligned : prevAligned) : endTick;
140
+ return (resolvedStart, resolvedEnd);
141
+ }
142
+
143
+ if (endSentinel) {
144
+ resolvedStart = startTick;
145
+ resolvedEnd = isCurrency0 ? nextAligned : anchorTick;
146
+ return (resolvedStart, resolvedEnd);
147
+ }
148
+
149
+ resolvedStart = startTick;
150
+ resolvedEnd = endTick;
151
+ return (resolvedStart, resolvedEnd);
152
+ }
153
+
154
+ function _validateTickRange(bool isCurrency0, int24 startTick, int24 endTick) private pure {
155
+ if (startTick < TickMath.MIN_TICK || startTick > TickMath.MAX_TICK) {
156
+ revert IZoraLimitOrderBook.InvalidFillWindow(startTick, endTick, isCurrency0);
157
+ }
158
+ if (endTick < TickMath.MIN_TICK || endTick > TickMath.MAX_TICK) {
159
+ revert IZoraLimitOrderBook.InvalidFillWindow(startTick, endTick, isCurrency0);
160
+ }
161
+
162
+ if (isCurrency0) {
163
+ if (startTick > endTick) revert IZoraLimitOrderBook.InvalidFillWindow(startTick, endTick, isCurrency0);
164
+ } else {
165
+ if (startTick < endTick) revert IZoraLimitOrderBook.InvalidFillWindow(startTick, endTick, isCurrency0);
166
+ }
167
+ }
168
+ }
@@ -9,6 +9,8 @@ pragma solidity ^0.8.28;
9
9
 
10
10
  import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
11
11
  import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
12
+ import {PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol";
13
+ import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol";
12
14
 
13
15
  import {LimitOrderStorage} from "./LimitOrderStorage.sol";
14
16
  import {IZoraLimitOrderBook} from "../IZoraLimitOrderBook.sol";
@@ -17,15 +19,16 @@ import {LimitOrderCommon} from "./LimitOrderCommon.sol";
17
19
  import {LimitOrderLiquidity} from "./LimitOrderLiquidity.sol";
18
20
 
19
21
  library LimitOrderWithdraw {
20
- function handleWithdrawOrdersCallback(LimitOrderStorage.Layout storage state, IPoolManager poolManager, bytes memory payload) internal {
22
+ function handleWithdrawOrdersCallback(LimitOrderStorage.Layout storage state, IPoolManager poolManager, address weth, bytes memory payload) internal {
21
23
  IZoraLimitOrderBook.WithdrawOrdersCallbackData memory data = abi.decode(payload, (IZoraLimitOrderBook.WithdrawOrdersCallbackData));
22
24
 
23
- withdrawOrders(state, poolManager, data.maker, data.orderIds, data.coin, data.minAmountOut, data.recipient);
25
+ withdrawOrders(state, poolManager, weth, data.maker, data.orderIds, data.coin, data.minAmountOut, data.recipient);
24
26
  }
25
27
 
26
28
  function withdrawOrders(
27
29
  LimitOrderStorage.Layout storage state,
28
30
  IPoolManager poolManager,
31
+ address weth,
29
32
  address maker,
30
33
  bytes32[] memory orderIds,
31
34
  address coin,
@@ -47,7 +50,7 @@ library LimitOrderWithdraw {
47
50
  require(order.status == LimitOrderTypes.OrderStatus.OPEN, IZoraLimitOrderBook.OrderClosed());
48
51
 
49
52
  uint128 orderSize = order.orderSize; // Cache before cancellation
50
- address currentCoin = _cancelOrder(state, poolManager, maker, orderId, order, recipient);
53
+ address currentCoin = _cancelOrder(state, poolManager, weth, maker, orderId, order, recipient);
51
54
 
52
55
  // Validate order coin matches expected coin
53
56
  if (currentCoin != coin) {
@@ -69,6 +72,7 @@ library LimitOrderWithdraw {
69
72
  function _cancelOrder(
70
73
  LimitOrderStorage.Layout storage state,
71
74
  IPoolManager poolManager,
75
+ address weth,
72
76
  address maker,
73
77
  bytes32 orderId,
74
78
  LimitOrderTypes.LimitOrder storage order,
@@ -77,6 +81,11 @@ library LimitOrderWithdraw {
77
81
  PoolKey memory key = state.poolKeys[order.poolKeyHash];
78
82
  require(key.tickSpacing != 0, IZoraLimitOrderBook.InvalidOrder());
79
83
 
84
+ // Prevent withdrawal of fillable orders - they must be filled instead
85
+ (, int24 currentTick, , ) = StateLibrary.getSlot0(poolManager, PoolIdLibrary.toId(key));
86
+ bool fillable = order.isCurrency0 ? currentTick >= order.tickUpper : currentTick <= order.tickLower;
87
+ require(!fillable, IZoraLimitOrderBook.OrderFillable());
88
+
80
89
  int24 orderTick = LimitOrderCommon.getOrderTick(order);
81
90
  coin = LimitOrderCommon.getOrderCoin(key, order.isCurrency0);
82
91
 
@@ -93,7 +102,7 @@ library LimitOrderWithdraw {
93
102
  LimitOrderCommon.removeOrder(state, key, coin, tickQueue, order);
94
103
 
95
104
  // External call after state is updated
96
- LimitOrderLiquidity.burnAndRefund(poolManager, key, tickLower, tickUpper, liquidity, orderId, recipient, isCurrency0);
105
+ LimitOrderLiquidity.burnAndRefund(poolManager, key, tickLower, tickUpper, liquidity, orderId, recipient, isCurrency0, weth);
97
106
 
98
107
  emit IZoraLimitOrderBook.LimitOrderUpdated(maker, coin, order.poolKeyHash, isCurrency0, orderTick, 0, orderId, true);
99
108
  }
@@ -44,9 +44,6 @@ library SwapLimitOrders {
44
44
  /// @dev sqrt(1e18) - scales sqrt calculations without precision loss
45
45
  uint256 internal constant SQRT_MULTIPLE_SCALE = 1e9;
46
46
 
47
- /// @dev Minimum coins to create orders - prevents dust
48
- uint256 internal constant MIN_LIMIT_ORDER_SIZE = 1e18;
49
-
50
47
  /// @notice Multiples and percentages arrays have different lengths
51
48
  error LengthMismatch();
52
49
 
@@ -95,7 +92,6 @@ library SwapLimitOrders {
95
92
  /// @return unallocated Amount of totalSize not allocated (dust or partial fill)
96
93
  /// @dev Orders are sized sequentially: each order takes its percentage of remaining balance.
97
94
  /// Orders with zero size after rounding are skipped - arrays shrink to match.
98
- /// Returns empty arrays if totalSize < MIN_LIMIT_ORDER_SIZE.
99
95
  function computeOrders(
100
96
  PoolKey memory key,
101
97
  bool isCurrency0,
@@ -104,8 +100,18 @@ library SwapLimitOrders {
104
100
  uint160 sqrtPriceX96,
105
101
  LimitOrderConfig memory config
106
102
  ) internal pure returns (Orders memory o, uint128 allocated, uint128 unallocated) {
107
- if (totalSize < MIN_LIMIT_ORDER_SIZE) {
108
- unallocated = uint128(totalSize);
103
+ if (totalSize == 0) {
104
+ return (o, allocated, unallocated);
105
+ }
106
+
107
+ // Skip order creation when at tick boundaries
108
+ // For currency0 (buy orders): cannot place if baseTick is at maxTick
109
+ // For currency1 (sell orders): cannot place if baseTick is at minTick
110
+ int24 maxTick = TickMath.maxUsableTick(key.tickSpacing);
111
+ int24 alignedBaseTick = DopplerMath.alignTickToTickSpacing(isCurrency0, baseTick, key.tickSpacing);
112
+
113
+ if (isCurrency0 ? alignedBaseTick >= maxTick : alignedBaseTick <= -maxTick) {
114
+ unallocated = totalSize;
109
115
  return (o, allocated, unallocated);
110
116
  }
111
117
 
@@ -185,7 +191,8 @@ library SwapLimitOrders {
185
191
  aligned = minTick;
186
192
  }
187
193
 
188
- int24 minAway = baseTick + (isCurrency0 ? key.tickSpacing : -key.tickSpacing);
194
+ int24 alignedBaseTick = DopplerMath.alignTickToTickSpacing(isCurrency0, baseTick, key.tickSpacing);
195
+ int24 minAway = alignedBaseTick + (isCurrency0 ? key.tickSpacing : -key.tickSpacing);
189
196
  if (isCurrency0) {
190
197
  if (aligned < minAway) aligned = minAway;
191
198
  } else {
@@ -26,7 +26,7 @@ import {TransientSlot} from "@openzeppelin/contracts/utils/TransientSlot.sol";
26
26
  import {Path} from "@zoralabs/shared-contracts/libs/UniswapV3/Path.sol";
27
27
  import {V3ToV4SwapLib} from "@zoralabs/coins/src/libs/V3ToV4SwapLib.sol";
28
28
  import {SimpleAccessManaged} from "../access/SimpleAccessManaged.sol";
29
- import {Permit2Payments} from "../libs/Permit2Payments.sol";
29
+ import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol";
30
30
 
31
31
  /// @title SwapWithLimitOrders
32
32
  /// @notice Standalone router contract that executes swaps with automatic limit order placement and filling.
@@ -35,7 +35,7 @@ import {Permit2Payments} from "../libs/Permit2Payments.sol";
35
35
  /// Users call swapWithLimitOrders() directly, which triggers the unlock callback flow.
36
36
  /// Uses Permit2 for token approvals, matching the universal-router pattern.
37
37
  /// @author oveddan
38
- contract SwapWithLimitOrders is IMsgSender, Permit2Payments {
38
+ contract SwapWithLimitOrders is IMsgSender {
39
39
  using SafeERC20 for IERC20;
40
40
  using BalanceDeltaLibrary for BalanceDelta;
41
41
  using CurrencyLibrary for Currency;
@@ -51,6 +51,9 @@ contract SwapWithLimitOrders is IMsgSender, Permit2Payments {
51
51
  /// @notice The Uniswap V3 swap router
52
52
  ISwapRouter public immutable swapRouter;
53
53
 
54
+ /// @notice The Permit2 contract (immutable, same address on all chains)
55
+ IAllowanceTransfer internal immutable PERMIT2;
56
+
54
57
  /// @notice Canonical limit order configuration
55
58
  LimitOrderConfig private _limitOrderConfig;
56
59
 
@@ -97,13 +100,18 @@ contract SwapWithLimitOrders is IMsgSender, Permit2Payments {
97
100
  /// @notice Emitted when a swap with limit order placement is executed
98
101
  /// @param orders Array of created orders with their configuration. Only includes orders
99
102
  /// that were actually created (skipped rungs due to rounding are omitted).
103
+ /// @param amount0 The amount of currency0 swapped (negative = paid, positive = received)
104
+ /// @param amount1 The amount of currency1 swapped (negative = paid, positive = received)
105
+ /// @param sqrtPriceX96 The sqrt price after the swap, used for USD price computation
100
106
  event SwapWithLimitOrdersExecuted(
101
107
  address indexed sender,
102
108
  address indexed recipient,
103
109
  PoolKey poolKey,
104
- BalanceDelta delta,
105
110
  int24 tickBeforeSwap,
106
111
  int24 tickAfterSwap,
112
+ int128 amount0,
113
+ int128 amount1,
114
+ uint160 sqrtPriceX96,
107
115
  CreatedOrder[] orders
108
116
  );
109
117
 
@@ -133,7 +141,7 @@ contract SwapWithLimitOrders is IMsgSender, Permit2Payments {
133
141
  /// @param zoraLimitOrderBook_ The limit order book contract
134
142
  /// @param swapRouter_ The Uniswap V3 swap router
135
143
  /// @param permit2_ The Permit2 contract address (0x000000000022D473030F116dDEE9F6B43aC78BA3)
136
- constructor(IPoolManager poolManager_, IZoraLimitOrderBook zoraLimitOrderBook_, ISwapRouter swapRouter_, address permit2_) Permit2Payments(permit2_) {
144
+ constructor(IPoolManager poolManager_, IZoraLimitOrderBook zoraLimitOrderBook_, ISwapRouter swapRouter_, address permit2_) {
137
145
  require(address(poolManager_) != address(0), "PoolManager cannot be zero");
138
146
  require(address(zoraLimitOrderBook_) != address(0), "ZoraLimitOrderBook cannot be zero");
139
147
  require(address(swapRouter_) != address(0), "SwapRouter cannot be zero");
@@ -141,6 +149,7 @@ contract SwapWithLimitOrders is IMsgSender, Permit2Payments {
141
149
  poolManager = poolManager_;
142
150
  zoraLimitOrderBook = zoraLimitOrderBook_;
143
151
  swapRouter = swapRouter_;
152
+ PERMIT2 = IAllowanceTransfer(permit2_);
144
153
  }
145
154
 
146
155
  /// @inheritdoc IMsgSender
@@ -218,17 +227,30 @@ contract SwapWithLimitOrders is IMsgSender, Permit2Payments {
218
227
  // Execute V4 swaps + create orders via unlock callback
219
228
  bytes memory result = poolManager.unlock(abi.encode(callbackData));
220
229
 
221
- (CreatedOrder[] memory orders, bool isCoinCurrency0, int24 tickAfterSwap) = abi.decode(result, (CreatedOrder[], bool, int24));
230
+ (CreatedOrder[] memory orders, bool isCoinCurrency0, int24 tickAfterSwap, uint160 sqrtPriceX96, BalanceDelta targetPoolDelta) = abi.decode(
231
+ result,
232
+ (CreatedOrder[], bool, int24, uint160, BalanceDelta)
233
+ );
222
234
 
223
235
  // Check if hook supports limit order filling using ERC165
224
236
  bool hookSupportsFill = IERC165(address(targetPool.hooks)).supportsInterface(type(ISupportsLimitOrderFill).interfaceId);
225
237
 
226
238
  // Router-based filling for legacy hooks
227
- if (!hookSupportsFill && orders.length > 0 && tickBeforeSwap != tickAfterSwap) {
228
- _fillOrders(targetPool, !isCoinCurrency0, tickBeforeSwap, tickAfterSwap);
239
+ if (!hookSupportsFill && tickBeforeSwap != tickAfterSwap) {
240
+ _fillOrders(targetPool, isCoinCurrency0, tickBeforeSwap, tickAfterSwap);
229
241
  }
230
242
 
231
- emit SwapWithLimitOrdersExecuted(msg.sender, params.recipient, targetPool, BalanceDelta.wrap(0), tickBeforeSwap, tickAfterSwap, orders);
243
+ emit SwapWithLimitOrdersExecuted(
244
+ msg.sender,
245
+ params.recipient,
246
+ targetPool,
247
+ tickBeforeSwap,
248
+ tickAfterSwap,
249
+ targetPoolDelta.amount0(),
250
+ targetPoolDelta.amount1(),
251
+ sqrtPriceX96,
252
+ orders
253
+ );
232
254
 
233
255
  // Clear maker from transient storage
234
256
  TransientSlot.tstore(slot, address(0));
@@ -271,7 +293,7 @@ contract SwapWithLimitOrders is IMsgSender, Permit2Payments {
271
293
  // Settle currencies with pool manager
272
294
  _settleCurrencies(callbackData.currencyReceived, callbackData.currencyAmount, coinAddress, unallocated, callbackData.recipient);
273
295
 
274
- return abi.encode(createdOrders, isCoinCurrency0, currentTick);
296
+ return abi.encode(createdOrders, isCoinCurrency0, currentTick, sqrtPriceX96, swapResult.targetPoolDelta);
275
297
  }
276
298
 
277
299
  /// @notice Executes V4 multi-hop swaps and validates output
@@ -398,33 +420,25 @@ contract SwapWithLimitOrders is IMsgSender, Permit2Payments {
398
420
 
399
421
  poolManager.settle();
400
422
  } else {
423
+ // sync before settle for native ETH
424
+ poolManager.sync(currency);
401
425
  poolManager.settle{value: amount}();
402
426
  }
403
427
  }
404
428
 
405
429
  /// @notice Fills limit orders within the tick range crossed by the swap
406
430
  /// @param poolKey The pool key
407
- /// @param isCurrency0 Whether to fill currency0 orders
408
431
  /// @param tickBeforeSwap The tick before the swap
409
432
  /// @param tickAfterSwap The tick after the swap
410
- function _fillOrders(PoolKey memory poolKey, bool isCurrency0, int24 tickBeforeSwap, int24 tickAfterSwap) internal {
411
- // Ensure ticks are in the correct order for fill validation
412
- // For currency0 orders: startTick <= endTick (ascending)
413
- // For currency1 orders: startTick >= endTick (descending)
414
- int24 startTick;
415
- int24 endTick;
416
- if (isCurrency0) {
417
- // Currency0 orders need ascending tick range
418
- startTick = tickBeforeSwap < tickAfterSwap ? tickBeforeSwap : tickAfterSwap;
419
- endTick = tickBeforeSwap < tickAfterSwap ? tickAfterSwap : tickBeforeSwap;
420
- } else {
421
- // Currency1 orders need descending tick range
422
- startTick = tickBeforeSwap > tickAfterSwap ? tickBeforeSwap : tickAfterSwap;
423
- endTick = tickBeforeSwap > tickAfterSwap ? tickAfterSwap : tickBeforeSwap;
433
+ function _fillOrders(PoolKey memory poolKey, bool, int24 tickBeforeSwap, int24 tickAfterSwap) internal {
434
+ if (tickAfterSwap == tickBeforeSwap) {
435
+ return;
424
436
  }
425
437
 
426
- // Call fill in locked mode - will trigger unlock/callback flow in ZoraLimitOrderBook
427
- zoraLimitOrderBook.fill(poolKey, isCurrency0, startTick, endTick, 0, address(0));
438
+ // Derive fill direction from tick movement
439
+ bool isCurrency0 = tickAfterSwap > tickBeforeSwap;
440
+
441
+ zoraLimitOrderBook.fill(poolKey, isCurrency0, tickBeforeSwap, tickAfterSwap, 0, address(0));
428
442
  }
429
443
 
430
444
  /// @notice Validates that the provided config matches the canonical config
@@ -30,7 +30,8 @@ contract LimitOrderBitmapTest is BaseTest {
30
30
 
31
31
  // Verify all ticks are initialized in bitmap
32
32
  for (uint256 i; i < orderTicks.length; ++i) {
33
- assertTrue(_isTickInitialized(poolKeyHash, orderCoin, orderTicks[i], key.tickSpacing), "tick should be initialized");
33
+ int24 fillableTick = _fillableTick(isCurrency0, orderTicks[i], key.tickSpacing);
34
+ assertTrue(_isTickInitialized(poolKeyHash, orderCoin, fillableTick, key.tickSpacing), "tick should be initialized");
34
35
  }
35
36
  }
36
37
 
@@ -52,7 +53,8 @@ contract LimitOrderBitmapTest is BaseTest {
52
53
  bytes32 poolKeyHash = created[0].poolKeyHash;
53
54
 
54
55
  // Verify tick is initialized
55
- assertTrue(_isTickInitialized(poolKeyHash, orderCoin, orderTicks[0], key.tickSpacing), "tick should be initialized after create");
56
+ int24 fillableTick = _fillableTick(isCurrency0, orderTicks[0], key.tickSpacing);
57
+ assertTrue(_isTickInitialized(poolKeyHash, orderCoin, fillableTick, key.tickSpacing), "tick should be initialized after create");
56
58
 
57
59
  // Withdraw order
58
60
  bytes32[] memory orderIds = new bytes32[](1);
@@ -61,7 +63,7 @@ contract LimitOrderBitmapTest is BaseTest {
61
63
  limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
62
64
 
63
65
  // Verify tick is cleared in bitmap
64
- assertFalse(_isTickInitialized(poolKeyHash, orderCoin, orderTicks[0], key.tickSpacing), "tick should be cleared after withdraw");
66
+ assertFalse(_isTickInitialized(poolKeyHash, orderCoin, fillableTick, key.tickSpacing), "tick should be cleared after withdraw");
65
67
  }
66
68
 
67
69
  function test_bitmap_RemainsSetWithPartialOrders() public {
@@ -95,7 +97,8 @@ contract LimitOrderBitmapTest is BaseTest {
95
97
  limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
96
98
 
97
99
  // Bitmap should still be set because second order remains
98
- assertTrue(_isTickInitialized(poolKeyHash, orderCoin, tick, key.tickSpacing), "tick should remain initialized with remaining order");
100
+ int24 fillableTick = _fillableTick(isCurrency0, tick, key.tickSpacing);
101
+ assertTrue(_isTickInitialized(poolKeyHash, orderCoin, fillableTick, key.tickSpacing), "tick should remain initialized with remaining order");
99
102
  }
100
103
 
101
104
  function test_bitmap_wordBoundaries() public {
@@ -129,7 +132,8 @@ contract LimitOrderBitmapTest is BaseTest {
129
132
 
130
133
  // Verify all boundary ticks are initialized
131
134
  for (uint256 i = 0; i < boundaryTicks.length; i++) {
132
- assertTrue(_isTickInitialized(poolKeyHash, orderCoin, boundaryTicks[i], spacing), "boundary tick should be initialized");
135
+ int24 fillableTick = _fillableTick(isCurrency0, boundaryTicks[i], spacing);
136
+ assertTrue(_isTickInitialized(poolKeyHash, orderCoin, fillableTick, spacing), "boundary tick should be initialized");
133
137
  }
134
138
  }
135
139
 
@@ -171,8 +175,10 @@ contract LimitOrderBitmapTest is BaseTest {
171
175
  bytes32 poolKeyHash = created[0].poolKeyHash;
172
176
 
173
177
  // Verify extreme ticks are initialized
174
- assertTrue(_isTickInitialized(poolKeyHash, orderCoin, extremeTicks[0], spacing), "far tick 1 should be initialized");
175
- assertTrue(_isTickInitialized(poolKeyHash, orderCoin, extremeTicks[1], spacing), "far tick 2 should be initialized");
178
+ int24 fillableTick0 = _fillableTick(isCurrency0, extremeTicks[0], spacing);
179
+ int24 fillableTick1 = _fillableTick(isCurrency0, extremeTicks[1], spacing);
180
+ assertTrue(_isTickInitialized(poolKeyHash, orderCoin, fillableTick0, spacing), "far tick 1 should be initialized");
181
+ assertTrue(_isTickInitialized(poolKeyHash, orderCoin, fillableTick1, spacing), "far tick 2 should be initialized");
176
182
  }
177
183
 
178
184
  function _fundAndApprove(address user, address token, uint256 amount) internal {
@@ -814,6 +814,49 @@ contract LimitOrderFillTest is BaseTest {
814
814
  assertEq(fills.length, 2, "should fill default max count of 2 orders");
815
815
  }
816
816
 
817
+ /// @notice Tests fill() caps maxFillCount to configured default
818
+ function test_fill_maxFillCountExceedsDefault_isCapped() public {
819
+ PoolKey memory key = creatorCoin.getPoolKey();
820
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
821
+ address orderCoin = _orderCoin(key, isCurrency0);
822
+
823
+ // Set max fill count to 2
824
+ vm.prank(users.factoryOwner);
825
+ limitOrderBook.setMaxFillCount(2);
826
+
827
+ // Create 5 fillable orders
828
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 5, 20e18);
829
+ uint256 totalSize;
830
+ for (uint256 i; i < orderSizes.length; ++i) {
831
+ totalSize += orderSizes[i];
832
+ }
833
+
834
+ if (orderCoin == address(0)) {
835
+ vm.deal(users.seller, totalSize);
836
+ } else {
837
+ deal(orderCoin, users.seller, totalSize);
838
+ vm.startPrank(users.seller);
839
+ IERC20(orderCoin).approve(address(limitOrderBook), totalSize);
840
+ vm.stopPrank();
841
+ }
842
+
843
+ vm.recordLogs();
844
+ vm.prank(users.seller);
845
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
846
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
847
+
848
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
849
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
850
+
851
+ // Call fill with maxFillCount = 100 (way above default of 2)
852
+ vm.recordLogs();
853
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, 100, address(0));
854
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
855
+
856
+ // Should cap to 2 orders (the configured max)
857
+ assertEq(fills.length, 2, "should cap to configured max of 2 orders");
858
+ }
859
+
817
860
  /// @notice Tests batch fill with empty orderIds array (line 134)
818
861
  /// @dev This tests the branch: if (batch.orderIds.length != 0)
819
862
  function test_batchFill_emptyOrderIds_skips() public {
@@ -67,7 +67,8 @@ contract LimitOrderLibrariesTest is BaseTest {
67
67
  assertEq(created.length, 3, "should create three orders");
68
68
 
69
69
  // Verify tick queue linked list structure
70
- QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, tick);
70
+ int24 fillableTick = _fillableTick(isCurrency0, tick, key.tickSpacing);
71
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, fillableTick);
71
72
  assertEq(tickQueue.length, 3, "queue length should be 3");
72
73
  assertEq(tickQueue.head, created[0].orderId, "head should be first order");
73
74
  assertEq(tickQueue.tail, created[2].orderId, "tail should be last order");
@@ -108,7 +109,8 @@ contract LimitOrderLibrariesTest is BaseTest {
108
109
  vm.prank(users.seller);
109
110
  limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
110
111
 
111
- QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, tick);
112
+ int24 fillableTick = _fillableTick(isCurrency0, tick, key.tickSpacing);
113
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, fillableTick);
112
114
  assertEq(tickQueue.length, 2, "queue length should be 2");
113
115
  assertEq(tickQueue.head, created[1].orderId, "head should be second order");
114
116
  assertEq(tickQueue.tail, created[2].orderId, "tail unchanged");
@@ -149,7 +151,8 @@ contract LimitOrderLibrariesTest is BaseTest {
149
151
  vm.prank(users.seller);
150
152
  limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
151
153
 
152
- QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, tick);
154
+ int24 fillableTick = _fillableTick(isCurrency0, tick, key.tickSpacing);
155
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, fillableTick);
153
156
  assertEq(tickQueue.length, 2, "queue length should be 2");
154
157
  assertEq(tickQueue.head, created[0].orderId, "head unchanged");
155
158
  assertEq(tickQueue.tail, created[1].orderId, "tail should be second order");
@@ -190,7 +193,8 @@ contract LimitOrderLibrariesTest is BaseTest {
190
193
  vm.prank(users.seller);
191
194
  limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
192
195
 
193
- QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, tick);
196
+ int24 fillableTick = _fillableTick(isCurrency0, tick, key.tickSpacing);
197
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, fillableTick);
194
198
  assertEq(tickQueue.length, 2, "queue length should be 2");
195
199
  assertEq(tickQueue.head, created[0].orderId, "head unchanged");
196
200
  assertEq(tickQueue.tail, created[2].orderId, "tail unchanged");
@@ -237,7 +241,8 @@ contract LimitOrderLibrariesTest is BaseTest {
237
241
 
238
242
  // Verify bitmap is set
239
243
  bytes32 poolKeyHash = keccak256(abi.encode(key));
240
- assertTrue(_isTickInitialized(poolKeyHash, orderCoin, orderTicks[0], key.tickSpacing), "tick should be initialized");
244
+ int24 fillableTick = _fillableTick(isCurrency0, orderTicks[0], key.tickSpacing);
245
+ assertTrue(_isTickInitialized(poolKeyHash, orderCoin, fillableTick, key.tickSpacing), "tick should be initialized");
241
246
  }
242
247
 
243
248
  function test_bitmapSetIfFirstWhenNonEmpty() public {
@@ -262,9 +267,10 @@ contract LimitOrderLibrariesTest is BaseTest {
262
267
 
263
268
  // Verify bitmap is still set (second enqueue didn't break it)
264
269
  bytes32 poolKeyHash = keccak256(abi.encode(key));
265
- assertTrue(_isTickInitialized(poolKeyHash, orderCoin, tick, key.tickSpacing), "tick should be initialized");
270
+ int24 fillableTick = _fillableTick(isCurrency0, tick, key.tickSpacing);
271
+ assertTrue(_isTickInitialized(poolKeyHash, orderCoin, fillableTick, key.tickSpacing), "tick should be initialized");
266
272
 
267
- QueueSnapshot memory tickQueue = _tickQueueSnapshot(poolKeyHash, orderCoin, tick);
273
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(poolKeyHash, orderCoin, fillableTick);
268
274
  assertEq(tickQueue.length, 2, "should have 2 orders at same tick");
269
275
  }
270
276
 
@@ -290,7 +296,8 @@ contract LimitOrderLibrariesTest is BaseTest {
290
296
  vm.prank(users.seller);
291
297
  limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
292
298
 
293
- assertFalse(_isTickInitialized(poolKeyHash, orderCoin, orderTicks[0], key.tickSpacing), "tick should be cleared");
299
+ int24 fillableTick = _fillableTick(isCurrency0, orderTicks[0], key.tickSpacing);
300
+ assertFalse(_isTickInitialized(poolKeyHash, orderCoin, fillableTick, key.tickSpacing), "tick should be cleared");
294
301
  }
295
302
 
296
303
  function test_bitmapClearIfEmptyWhenStillHasOrders() public {
@@ -323,9 +330,10 @@ contract LimitOrderLibrariesTest is BaseTest {
323
330
  vm.prank(users.seller);
324
331
  limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
325
332
 
326
- assertTrue(_isTickInitialized(poolKeyHash, orderCoin, tick, key.tickSpacing), "tick should still be initialized");
333
+ int24 fillableTick = _fillableTick(isCurrency0, tick, key.tickSpacing);
334
+ assertTrue(_isTickInitialized(poolKeyHash, orderCoin, fillableTick, key.tickSpacing), "tick should still be initialized");
327
335
 
328
- QueueSnapshot memory tickQueue = _tickQueueSnapshot(poolKeyHash, orderCoin, tick);
336
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(poolKeyHash, orderCoin, fillableTick);
329
337
  assertEq(tickQueue.length, 1, "should have 1 order remaining");
330
338
  }
331
339