@zoralabs/limit-orders 0.2.0

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 (208) hide show
  1. package/.turbo/turbo-build$colon$js.log +85 -0
  2. package/AUDIT_NOTES.md +33 -0
  3. package/AUDIT_RFP.md +408 -0
  4. package/CHANGELOG.md +25 -0
  5. package/GAS_COMPARISON_RESULTS.md +194 -0
  6. package/LICENSE +21 -0
  7. package/README.md +650 -0
  8. package/SPEC.md +291 -0
  9. package/abis/BalanceDeltaLibrary.json +15 -0
  10. package/abis/BeforeSwapDeltaLibrary.json +15 -0
  11. package/abis/CurrencyLibrary.json +25 -0
  12. package/abis/CustomRevert.json +28 -0
  13. package/abis/IAllowanceTransfer.json +486 -0
  14. package/abis/IAuthority.json +31 -0
  15. package/abis/ICoin.json +1074 -0
  16. package/abis/IDeployedCoinVersionLookup.json +21 -0
  17. package/abis/IDopplerErrors.json +44 -0
  18. package/abis/IEIP712.json +15 -0
  19. package/abis/IERC1363.json +373 -0
  20. package/abis/IERC165.json +21 -0
  21. package/abis/IERC20.json +185 -0
  22. package/abis/IERC20Minimal.json +172 -0
  23. package/abis/IERC6909Claims.json +288 -0
  24. package/abis/IERC7572.json +21 -0
  25. package/abis/IExtsload.json +64 -0
  26. package/abis/IExttload.json +40 -0
  27. package/abis/IHasCoinType.json +15 -0
  28. package/abis/IHasPoolKey.json +42 -0
  29. package/abis/IHasRewardsRecipients.json +54 -0
  30. package/abis/IHasSwapPath.json +60 -0
  31. package/abis/IHasTotalSupplyForPositions.json +15 -0
  32. package/abis/IHooks.json +789 -0
  33. package/abis/IMsgSender.json +15 -0
  34. package/abis/IPoolManager.json +1286 -0
  35. package/abis/IProtocolFees.json +174 -0
  36. package/abis/ISupportsLimitOrderFill.json +15 -0
  37. package/abis/ISwapPathRouter.json +92 -0
  38. package/abis/ISwapRouter.json +219 -0
  39. package/abis/IUniswapV3SwapCallback.json +25 -0
  40. package/abis/IUpgradeableDestinationV4Hook.json +84 -0
  41. package/abis/IUpgradeableDestinationV4HookWithUpdateableFee.json +95 -0
  42. package/abis/IUpgradeableV4Hook.json +112 -0
  43. package/abis/IZoraHookRegistry.json +188 -0
  44. package/abis/IZoraLimitOrderBook.json +623 -0
  45. package/abis/IZoraLimitOrderBookCoinsInterface.json +67 -0
  46. package/abis/IZoraV4CoinHook.json +610 -0
  47. package/abis/Permit2Payments.json +7 -0
  48. package/abis/Position.json +7 -0
  49. package/abis/SafeCast.json +7 -0
  50. package/abis/SafeCast160.json +7 -0
  51. package/abis/SafeERC20.json +34 -0
  52. package/abis/SimpleAccessManaged.json +57 -0
  53. package/abis/SimpleAccessManager.json +351 -0
  54. package/abis/SqrtPriceMath.json +22 -0
  55. package/abis/StateLibrary.json +80 -0
  56. package/abis/SwapLimitOrders.json +22 -0
  57. package/abis/SwapWithLimitOrders.json +457 -0
  58. package/abis/TickBitmap.json +18 -0
  59. package/abis/TickMath.json +24 -0
  60. package/abis/V3ToV4SwapLib.json +28 -0
  61. package/abis/ZoraLimitOrderBook.json +771 -0
  62. package/cache/solidity-files-cache.json +1 -0
  63. package/dist/index.cjs +760 -0
  64. package/dist/index.cjs.map +1 -0
  65. package/dist/index.d.ts +2 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +731 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/wagmiGenerated.d.ts +1012 -0
  70. package/dist/wagmiGenerated.d.ts.map +1 -0
  71. package/foundry.toml +29 -0
  72. package/gas_comparison.py +49 -0
  73. package/out/BalanceDelta.sol/BalanceDeltaLibrary.json +1 -0
  74. package/out/BeforeSwapDelta.sol/BeforeSwapDeltaLibrary.json +1 -0
  75. package/out/BitMath.sol/BitMath.json +1 -0
  76. package/out/BytesLib.sol/BytesLib.json +1 -0
  77. package/out/CoinCommon.sol/CoinCommon.json +1 -0
  78. package/out/CoinConfigurationVersions.sol/CoinConfigurationVersions.json +1 -0
  79. package/out/CoinConstants.sol/CoinConstants.json +1 -0
  80. package/out/Context.sol/Context.json +1 -0
  81. package/out/Currency.sol/CurrencyLibrary.json +1 -0
  82. package/out/CurrencyReserves.sol/CurrencyReserves.json +1 -0
  83. package/out/CustomRevert.sol/CustomRevert.json +1 -0
  84. package/out/DopplerMath.sol/DopplerMath.json +1 -0
  85. package/out/FixedPoint128.sol/FixedPoint128.json +1 -0
  86. package/out/FixedPoint96.sol/FixedPoint96.json +1 -0
  87. package/out/FullMath.sol/FullMath.json +1 -0
  88. package/out/IAllowanceTransfer.sol/IAllowanceTransfer.json +1 -0
  89. package/out/IAuthority.sol/IAuthority.json +1 -0
  90. package/out/ICoin.sol/ICoin.json +1 -0
  91. package/out/ICoin.sol/IHasCoinType.json +1 -0
  92. package/out/ICoin.sol/IHasPoolKey.json +1 -0
  93. package/out/ICoin.sol/IHasSwapPath.json +1 -0
  94. package/out/ICoin.sol/IHasTotalSupplyForPositions.json +1 -0
  95. package/out/IDeployedCoinVersionLookup.sol/IDeployedCoinVersionLookup.json +1 -0
  96. package/out/IDopplerErrors.sol/IDopplerErrors.json +1 -0
  97. package/out/IEIP712.sol/IEIP712.json +1 -0
  98. package/out/IERC1363.sol/IERC1363.json +1 -0
  99. package/out/IERC165.sol/IERC165.json +1 -0
  100. package/out/IERC20.sol/IERC20.json +1 -0
  101. package/out/IERC20Minimal.sol/IERC20Minimal.json +1 -0
  102. package/out/IERC6909Claims.sol/IERC6909Claims.json +1 -0
  103. package/out/IERC7572.sol/IERC7572.json +1 -0
  104. package/out/IExtsload.sol/IExtsload.json +1 -0
  105. package/out/IExttload.sol/IExttload.json +1 -0
  106. package/out/IHasRewardsRecipients.sol/IHasRewardsRecipients.json +1 -0
  107. package/out/IHooks.sol/IHooks.json +1 -0
  108. package/out/IMsgSender.sol/IMsgSender.json +1 -0
  109. package/out/IPoolManager.sol/IPoolManager.json +1 -0
  110. package/out/IProtocolFees.sol/IProtocolFees.json +1 -0
  111. package/out/ISupportsLimitOrderFill.sol/ISupportsLimitOrderFill.json +1 -0
  112. package/out/ISwapPathRouter.sol/ISwapPathRouter.json +1 -0
  113. package/out/ISwapRouter.sol/ISwapRouter.json +1 -0
  114. package/out/IUniswapV3SwapCallback.sol/IUniswapV3SwapCallback.json +1 -0
  115. package/out/IUpgradeableV4Hook.sol/IUpgradeableDestinationV4Hook.json +1 -0
  116. package/out/IUpgradeableV4Hook.sol/IUpgradeableDestinationV4HookWithUpdateableFee.json +1 -0
  117. package/out/IUpgradeableV4Hook.sol/IUpgradeableV4Hook.json +1 -0
  118. package/out/IZoraHookRegistry.sol/IZoraHookRegistry.json +1 -0
  119. package/out/IZoraLimitOrderBook.sol/IZoraLimitOrderBook.json +1 -0
  120. package/out/IZoraLimitOrderBookCoinsInterface.sol/IZoraLimitOrderBookCoinsInterface.json +1 -0
  121. package/out/IZoraV4CoinHook.sol/IZoraV4CoinHook.json +1 -0
  122. package/out/LimitOrderBitmap.sol/LimitOrderBitmap.json +1 -0
  123. package/out/LimitOrderCommon.sol/LimitOrderCommon.json +1 -0
  124. package/out/LimitOrderCreate.sol/LimitOrderCreate.json +1 -0
  125. package/out/LimitOrderFill.sol/LimitOrderFill.json +1 -0
  126. package/out/LimitOrderLiquidity.sol/LimitOrderLiquidity.json +1 -0
  127. package/out/LimitOrderQueues.sol/LimitOrderQueues.json +1 -0
  128. package/out/LimitOrderStorage.sol/LimitOrderStorage.json +1 -0
  129. package/out/LimitOrderTypes.sol/LimitOrderTypes.json +1 -0
  130. package/out/LimitOrderWithdraw.sol/LimitOrderWithdraw.json +1 -0
  131. package/out/LiquidityAmounts.sol/LiquidityAmounts.json +1 -0
  132. package/out/LiquidityMath.sol/LiquidityMath.json +1 -0
  133. package/out/Lock.sol/Lock.json +1 -0
  134. package/out/NonzeroDeltaCount.sol/NonzeroDeltaCount.json +1 -0
  135. package/out/Path.sol/Path.json +1 -0
  136. package/out/PathKey.sol/PathKeyLibrary.json +1 -0
  137. package/out/Permit2Payments.sol/Permit2Payments.json +1 -0
  138. package/out/PoolId.sol/PoolIdLibrary.json +1 -0
  139. package/out/Position.sol/Position.json +1 -0
  140. package/out/SafeCast.sol/SafeCast.json +1 -0
  141. package/out/SafeCast160.sol/SafeCast160.json +1 -0
  142. package/out/SafeERC20.sol/SafeERC20.json +1 -0
  143. package/out/SimpleAccessManaged.sol/SimpleAccessManaged.json +1 -0
  144. package/out/SimpleAccessManager.sol/SimpleAccessManager.json +1 -0
  145. package/out/SqrtPriceMath.sol/SqrtPriceMath.json +1 -0
  146. package/out/StateLibrary.sol/StateLibrary.json +1 -0
  147. package/out/SwapLimitOrders.sol/SwapLimitOrders.json +1 -0
  148. package/out/SwapWithLimitOrders.sol/SwapWithLimitOrders.json +1 -0
  149. package/out/TickBitmap.sol/TickBitmap.json +1 -0
  150. package/out/TickMath.sol/TickMath.json +1 -0
  151. package/out/TransientSlot.sol/TransientSlot.json +1 -0
  152. package/out/TransientStateLibrary.sol/TransientStateLibrary.json +1 -0
  153. package/out/UniV4SwapToCurrency.sol/UniV4SwapToCurrency.json +1 -0
  154. package/out/UnsafeMath.sol/UnsafeMath.json +1 -0
  155. package/out/V3ToV4SwapLib.sol/V3ToV4SwapLib.json +1 -0
  156. package/out/ZoraLimitOrderBook.sol/ZoraLimitOrderBook.json +1 -0
  157. package/out/build-info/69718f10d1dc37f0.json +1 -0
  158. package/out/uniswap/BitMath.sol/BitMath.json +1 -0
  159. package/out/uniswap/CustomRevert.sol/CustomRevert.json +1 -0
  160. package/out/uniswap/FullMath.sol/FullMath.json +1 -0
  161. package/out/uniswap/SafeCast.sol/SafeCast.json +1 -0
  162. package/out/uniswap/TickMath.sol/TickMath.json +1 -0
  163. package/package/index.ts +1 -0
  164. package/package/wagmiGenerated.ts +738 -0
  165. package/package.json +57 -0
  166. package/remappings.txt +11 -0
  167. package/src/IZoraLimitOrderBook.sol +195 -0
  168. package/src/ZoraLimitOrderBook.sol +220 -0
  169. package/src/access/SimpleAccessManaged.sol +76 -0
  170. package/src/access/SimpleAccessManager.sol +268 -0
  171. package/src/libs/LimitOrderBitmap.sol +84 -0
  172. package/src/libs/LimitOrderCommon.sol +91 -0
  173. package/src/libs/LimitOrderCreate.sol +277 -0
  174. package/src/libs/LimitOrderFill.sol +362 -0
  175. package/src/libs/LimitOrderLiquidity.sol +222 -0
  176. package/src/libs/LimitOrderQueues.sol +101 -0
  177. package/src/libs/LimitOrderStorage.sol +34 -0
  178. package/src/libs/LimitOrderTypes.sol +41 -0
  179. package/src/libs/LimitOrderWithdraw.sol +100 -0
  180. package/src/libs/Permit2Payments.sol +41 -0
  181. package/src/libs/SwapLimitOrders.sol +209 -0
  182. package/src/router/SwapWithLimitOrders.sol +454 -0
  183. package/test/LimitOrderAccessControl.t.sol +461 -0
  184. package/test/LimitOrderBitmap.t.sol +194 -0
  185. package/test/LimitOrderCreate.t.sol +348 -0
  186. package/test/LimitOrderFill.t.sol +1005 -0
  187. package/test/LimitOrderLibraries.t.sol +354 -0
  188. package/test/LimitOrderLiquidityPayouts.t.sol +333 -0
  189. package/test/LimitOrderV4Pools.t.sol +157 -0
  190. package/test/LimitOrderWithdraw.t.sol +653 -0
  191. package/test/SimpleAccessManager.t.sol +420 -0
  192. package/test/SwapWithLimitOrders.t.sol +107 -0
  193. package/test/SwapWithLimitOrdersRouter.t.sol +1073 -0
  194. package/test/gas/LimitOrderFillGas.t.sol +1008 -0
  195. package/test/gas/LimitOrderSwapGas.t.sol +403 -0
  196. package/test/gas/logs/gas_benchmarks_fill_20251201.log +30 -0
  197. package/test/gas/logs/gas_benchmarks_swap_20251201.log +27 -0
  198. package/test/unit/LimitOrderBitmapUnit.t.sol +276 -0
  199. package/test/unit/LimitOrderCreateUnit.t.sol +358 -0
  200. package/test/unit/SwapLimitOrdersUnit.t.sol +672 -0
  201. package/test/unit/SwapLimitOrdersValidation.t.sol +423 -0
  202. package/test/unit/SwapWithLimitOrdersUnit.t.sol +321 -0
  203. package/test/utils/BaseTest.sol +793 -0
  204. package/test/utils/TestableZoraLimitOrderBook.sol +54 -0
  205. package/tsconfig.build.json +10 -0
  206. package/tsconfig.json +9 -0
  207. package/tsup.config.ts +11 -0
  208. package/wagmi.config.ts +18 -0
@@ -0,0 +1,1005 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.13;
3
+
4
+ import {BaseTest} from "./utils/BaseTest.sol";
5
+ import {IZoraLimitOrderBook} from "../src/IZoraLimitOrderBook.sol";
6
+ import {LimitOrderCommon} from "../src/libs/LimitOrderCommon.sol";
7
+ import {CoinCommon} from "@zoralabs/coins/src/libs/CoinCommon.sol";
8
+ import {ICoin} from "@zoralabs/coins/src/interfaces/ICoin.sol";
9
+ import {LimitOrderTypes} from "../src/libs/LimitOrderTypes.sol";
10
+
11
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
12
+ import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
13
+ import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol";
14
+
15
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
16
+
17
+ contract LimitOrderFillTest is BaseTest {
18
+ function test_debugCreateMakerBalance() public {
19
+ PoolKey memory key = creatorCoin.getPoolKey();
20
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
21
+ address orderCoin = LimitOrderCommon.getOrderCoin(key, isCurrency0);
22
+
23
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 1, 25e18);
24
+ uint256 totalSize = orderSizes[0];
25
+
26
+ if (orderCoin == address(0)) {
27
+ vm.deal(users.seller, totalSize);
28
+ } else {
29
+ deal(orderCoin, users.seller, totalSize);
30
+ vm.prank(users.seller);
31
+ IERC20(orderCoin).approve(address(limitOrderBook), totalSize);
32
+ }
33
+
34
+ vm.prank(users.seller);
35
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
36
+
37
+ // Contract view
38
+ uint256 onchainBalance = limitOrderBook.balanceOf(users.seller, orderCoin);
39
+ assertEq(onchainBalance, totalSize, "maker balance from contract");
40
+ }
41
+
42
+ function test_fillWithNoOrdersIsNoop() public {
43
+ PoolKey memory key = creatorCoin.getPoolKey();
44
+
45
+ vm.recordLogs();
46
+ limitOrderBook.fill(key, true, -type(int24).max, type(int24).max, 5, address(0));
47
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
48
+ assertEq(fills.length, 0, "unexpected fills");
49
+ }
50
+
51
+ function test_fillRangeConsumesOrders() public {
52
+ PoolKey memory key = creatorCoin.getPoolKey();
53
+
54
+ vm.recordLogs();
55
+ _executeSingleHopSwapWithLimitOrders(users.buyer, key, DEFAULT_LIMIT_ORDER_AMOUNT, _defaultMultiples(), _defaultPercentages());
56
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
57
+ assertGt(created.length, 0, "expected orders to be created");
58
+ _assertOpenOrderState(users.buyer, created[0].coin, created[0].poolKeyHash, created, key.tickSpacing);
59
+
60
+ // Move price past orders so they are fully crossed
61
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
62
+
63
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
64
+ bool isCurrency0 = created[0].isCurrency0;
65
+ address orderCoin = created[0].coin;
66
+ bytes32 poolKeyHash = created[0].poolKeyHash;
67
+ uint256 epochBefore = _poolEpoch(poolKeyHash);
68
+
69
+ vm.recordLogs();
70
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, created.length, address(0));
71
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
72
+
73
+ assertEq(fills.length, created.length, "fill count mismatch");
74
+ for (uint256 i; i < fills.length; ++i) {
75
+ assertEq(fills[i].maker, users.buyer, "maker mismatch");
76
+ assertEq(fills[i].coinIn, orderCoin, "coin mismatch");
77
+ assertEq(fills[i].fillReferral, address(0), "unexpected referral");
78
+ assertEq(fills[i].fillReferralAmount, 0, "unexpected referral amount");
79
+ }
80
+
81
+ assertEq(_makerBalance(users.buyer, orderCoin), 0, "maker balance should be zero");
82
+ _assertEpochIncrement(poolKeyHash, epochBefore);
83
+
84
+ for (uint256 i; i < created.length; ++i) {
85
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(poolKeyHash, orderCoin, created[i].tick);
86
+ assertEq(tickQueue.length, 0, "tick queue length");
87
+ assertEq(tickQueue.balance, 0, "tick queue balance");
88
+ assertEq(tickQueue.head, bytes32(0), "tick queue head not cleared");
89
+ assertEq(tickQueue.tail, bytes32(0), "tick queue tail not cleared");
90
+ assertFalse(_isTickInitialized(poolKeyHash, orderCoin, created[i].tick, key.tickSpacing), "tick bitmap still set");
91
+ }
92
+ }
93
+
94
+ function test_fillRangeConsumesOrdersWithAutoFillDisabledDuringSetup() public {
95
+ PoolKey memory key = creatorCoin.getPoolKey();
96
+
97
+ vm.recordLogs();
98
+ _executeSingleHopSwapWithLimitOrders(users.buyer, key, DEFAULT_LIMIT_ORDER_AMOUNT, _defaultMultiples(), _defaultPercentages());
99
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
100
+ assertGt(created.length, 0, "expected orders to be created");
101
+ _assertOpenOrderState(users.buyer, created[0].coin, created[0].poolKeyHash, created, key.tickSpacing);
102
+
103
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
104
+
105
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
106
+ bool isCurrency0 = created[0].isCurrency0;
107
+ address orderCoin = created[0].coin;
108
+
109
+ vm.recordLogs();
110
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, created.length, address(0));
111
+ for (uint256 i; i < created.length; ++i) {
112
+ LimitOrderTypes.LimitOrder memory orderState = limitOrderBook.exposedOrder(created[i].orderId);
113
+ assertEq(uint256(orderState.status), uint256(LimitOrderTypes.OrderStatus.FILLED), "order remained open");
114
+ }
115
+ assertEq(_makerBalance(users.buyer, orderCoin), 0, "maker balance should be zero");
116
+ }
117
+
118
+ function test_fillSentinelBoundsConsumesOrders() public {
119
+ PoolKey memory key = creatorCoin.getPoolKey();
120
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
121
+ address orderCoin = _orderCoin(key, isCurrency0);
122
+
123
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 2, 25e18);
124
+ uint256 totalSize;
125
+ for (uint256 i; i < orderSizes.length; ++i) {
126
+ totalSize += orderSizes[i];
127
+ }
128
+
129
+ if (orderCoin == address(0)) {
130
+ vm.deal(users.seller, totalSize);
131
+ } else {
132
+ deal(orderCoin, users.seller, totalSize);
133
+ vm.startPrank(users.seller);
134
+ IERC20(orderCoin).approve(address(limitOrderBook), totalSize);
135
+ vm.stopPrank();
136
+ }
137
+
138
+ vm.recordLogs();
139
+ vm.prank(users.seller);
140
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
141
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
142
+ assertEq(created.length, orderSizes.length, "unexpected created order count");
143
+ _assertOpenOrderState(users.seller, orderCoin, created[0].poolKeyHash, created, key.tickSpacing);
144
+
145
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
146
+
147
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
148
+ bytes32 poolKeyHash = created[0].poolKeyHash;
149
+ uint256 epochBefore = _poolEpoch(poolKeyHash);
150
+
151
+ vm.recordLogs();
152
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, 0, address(0));
153
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
154
+
155
+ assertEq(fills.length, created.length, "fill count mismatch");
156
+ assertEq(_makerBalance(users.seller, orderCoin), 0, "maker balance should be zero");
157
+ _assertEpochIncrement(poolKeyHash, epochBefore);
158
+
159
+ for (uint256 i; i < created.length; ++i) {
160
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(poolKeyHash, orderCoin, created[i].tick);
161
+ assertEq(tickQueue.length, 0, "tick queue length");
162
+ assertEq(tickQueue.balance, 0, "tick queue balance");
163
+ assertFalse(_isTickInitialized(poolKeyHash, orderCoin, created[i].tick, key.tickSpacing), "tick bitmap still set");
164
+ }
165
+ }
166
+
167
+ function test_fillSentinelBoundsAtMaxTickDoesNotRevert() public {
168
+ PoolKey memory key = creatorCoin.getPoolKey();
169
+ _setPoolTick(key, TickMath.MAX_TICK);
170
+
171
+ vm.recordLogs();
172
+ limitOrderBook.fill(key, true, -type(int24).max, type(int24).max, 1, address(0));
173
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
174
+ assertEq(fills.length, 0, "no fills expected at sentinel boundary");
175
+ }
176
+
177
+ function test_fillSentinelBoundsAtMinTickDoesNotRevert() public {
178
+ PoolKey memory key = creatorCoin.getPoolKey();
179
+ _setPoolTick(key, TickMath.MIN_TICK);
180
+
181
+ vm.recordLogs();
182
+ limitOrderBook.fill(key, false, -type(int24).max, type(int24).max, 1, address(0));
183
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
184
+ assertEq(fills.length, 0, "no fills expected at sentinel boundary");
185
+ }
186
+
187
+ function test_currency1StartSentinelAnchorsCurrentTick() public {
188
+ PoolKey memory key = creatorCoin.getPoolKey();
189
+ bool isCurrency0 = false;
190
+
191
+ int24 anchorTick = _alignedTick(_currentTick(key), key.tickSpacing);
192
+ _setPoolTick(key, anchorTick);
193
+
194
+ (int24 resolvedStart, int24 resolvedEnd) = limitOrderBook.exposedResolveTickRange(key, isCurrency0, -type(int24).max, anchorTick - key.tickSpacing);
195
+
196
+ assertEq(resolvedStart, anchorTick, "start sentinel should anchor current tick");
197
+ assertEq(resolvedEnd, anchorTick - key.tickSpacing, "explicit end should be preserved");
198
+ }
199
+
200
+ function test_fillRangeUsesDefaultWhenMaxZero() public {
201
+ PoolKey memory key = creatorCoin.getPoolKey();
202
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
203
+ address orderCoin = _orderCoin(key, isCurrency0);
204
+
205
+ vm.prank(users.factoryOwner);
206
+ limitOrderBook.setMaxFillCount(2);
207
+
208
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 3, 20e18);
209
+ uint256 totalSize;
210
+ for (uint256 i; i < orderSizes.length; ++i) {
211
+ totalSize += orderSizes[i];
212
+ }
213
+
214
+ if (orderCoin == address(0)) {
215
+ vm.deal(users.seller, totalSize);
216
+ } else {
217
+ deal(orderCoin, users.seller, totalSize);
218
+ vm.startPrank(users.seller);
219
+ IERC20(orderCoin).approve(address(limitOrderBook), totalSize);
220
+ vm.stopPrank();
221
+ }
222
+
223
+ vm.recordLogs();
224
+ vm.prank(users.seller);
225
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
226
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
227
+ assertEq(created.length, orderSizes.length, "unexpected created order count");
228
+ _assertOpenOrderState(users.seller, orderCoin, created[0].poolKeyHash, created, key.tickSpacing);
229
+
230
+ // Move price past orders to make them fillable, but disable auto-fill so manual fill can consume them
231
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
232
+
233
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
234
+ bytes32 poolKeyHash = created[0].poolKeyHash;
235
+ uint256 epochBefore = _poolEpoch(poolKeyHash);
236
+
237
+ vm.recordLogs();
238
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, 0, address(0));
239
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
240
+
241
+ assertEq(fills.length, 2, "should fill router default count");
242
+
243
+ uint256 expectedRemaining = totalSize;
244
+ for (uint256 i; i < fills.length; ++i) {
245
+ expectedRemaining -= uint256(fills[i].amountIn);
246
+ }
247
+
248
+ // Allow 1 wei rounding error due to liquidity/amount conversions in V4
249
+ assertApproxEqAbs(_makerBalance(users.seller, orderCoin), expectedRemaining, 1, "maker balance");
250
+
251
+ uint256 remainingTicks;
252
+ for (uint256 i; i < created.length; ++i) {
253
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(poolKeyHash, orderCoin, created[i].tick);
254
+ if (tickQueue.length == 0) {
255
+ assertEq(tickQueue.balance, 0, "cleared tick balance");
256
+ assertFalse(_isTickInitialized(poolKeyHash, orderCoin, created[i].tick, key.tickSpacing), "tick bitmap still set");
257
+ } else {
258
+ ++remainingTicks;
259
+ assertEq(tickQueue.length, 1, "remaining tick length");
260
+ // Allow 1 wei rounding error due to liquidity/amount conversions in V4
261
+ assertApproxEqAbs(uint256(tickQueue.balance), expectedRemaining, 1, "remaining tick balance");
262
+ }
263
+ }
264
+ assertEq(remainingTicks, 1, "expected single remaining tick");
265
+
266
+ _assertEpochIncrement(poolKeyHash, epochBefore);
267
+ }
268
+
269
+ function test_fillRangeRespectsExplicitMaxFillCount() public {
270
+ PoolKey memory key = creatorCoin.getPoolKey();
271
+
272
+ vm.recordLogs();
273
+ _executeSingleHopSwapWithLimitOrders(users.buyer, key, DEFAULT_LIMIT_ORDER_AMOUNT, _defaultMultiples(), _defaultPercentages());
274
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
275
+ assertGt(created.length, 1, "expected multiple orders");
276
+ _assertOpenOrderState(users.buyer, created[0].coin, created[0].poolKeyHash, created, key.tickSpacing);
277
+
278
+ // Move price past orders so they are fully crossed
279
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
280
+
281
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
282
+ bool isCurrency0 = created[0].isCurrency0;
283
+ bytes32 poolKeyHash = created[0].poolKeyHash;
284
+ address orderCoin = created[0].coin;
285
+ uint256 epochBefore = _poolEpoch(poolKeyHash);
286
+ uint256 maxFillCount = 1;
287
+ uint256 totalSize = _sumOrderSizes(created);
288
+
289
+ vm.recordLogs();
290
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, maxFillCount, address(0));
291
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
292
+
293
+ assertEq(fills.length, maxFillCount, "fill count mismatch");
294
+
295
+ uint256 expectedRemaining = totalSize;
296
+ for (uint256 i; i < fills.length; ++i) {
297
+ expectedRemaining -= uint256(fills[i].amountIn);
298
+ }
299
+
300
+ assertEq(_makerBalance(users.buyer, orderCoin), expectedRemaining, "maker balance");
301
+
302
+ _assertEpochIncrement(poolKeyHash, epochBefore);
303
+ }
304
+
305
+ function test_manualFillWithDisabledHookAutoFill() public {
306
+ PoolKey memory key = creatorCoin.getPoolKey();
307
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
308
+ address orderCoin = _orderCoin(key, isCurrency0);
309
+
310
+ // 1. Create a single manual order out-of-the-money
311
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 1, 50e18);
312
+ uint256 totalSize = orderSizes[0];
313
+
314
+ if (orderCoin == address(0)) {
315
+ vm.deal(users.seller, totalSize);
316
+ } else {
317
+ deal(orderCoin, users.seller, totalSize);
318
+ vm.prank(users.seller);
319
+ IERC20(orderCoin).approve(address(limitOrderBook), totalSize);
320
+ }
321
+
322
+ vm.recordLogs();
323
+ vm.prank(users.seller);
324
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
325
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
326
+ assertEq(created.length, 1, "expected single order");
327
+
328
+ bytes32 poolKeyHash = created[0].poolKeyHash;
329
+
330
+ // 2. Do a small swap that DOESN'T cross the order to increment epoch
331
+ // (Order is far out of the money, so small swap won't reach it)
332
+ address swapper = makeAddr("price-mover");
333
+ uint128 smallSwap = uint128(DEFAULT_LIMIT_ORDER_AMOUNT / 100);
334
+ deal(address(zoraToken), swapper, uint256(smallSwap));
335
+ _swapSomeCurrencyForCoin(ICoin(address(creatorCoin)), address(zoraToken), smallSwap, swapper);
336
+
337
+ // 3. Now DISABLE hook auto-fills
338
+ uint256 originalMaxFillCount = limitOrderBook.getMaxFillCount();
339
+ vm.prank(users.factoryOwner);
340
+ limitOrderBook.setMaxFillCount(0);
341
+
342
+ // 4. Move price past the order WITHOUT triggering hook fills
343
+ uint128 swapAmount = uint128(DEFAULT_LIMIT_ORDER_AMOUNT * 10);
344
+ deal(address(zoraToken), swapper, uint256(swapAmount));
345
+ _swapSomeCurrencyForCoin(ICoin(address(creatorCoin)), address(zoraToken), swapAmount, swapper);
346
+
347
+ // 5. Restore original maxFillCount
348
+ vm.prank(users.factoryOwner);
349
+ limitOrderBook.setMaxFillCount(originalMaxFillCount);
350
+
351
+ // 6. Verify order exists and check epoch
352
+ uint256 currentEpoch = _poolEpoch(poolKeyHash);
353
+ QueueSnapshot memory queueBefore = _tickQueueSnapshot(poolKeyHash, orderCoin, created[0].tick);
354
+ assertGt(queueBefore.length, 0, "order should exist in tick queue");
355
+
356
+ // Note: we created at epoch 0, small swap incremented to 1, big swap tried to fill but maxFillCount=0
357
+ // So current epoch should be > 0, allowing our fill
358
+
359
+ // 7. Now manually fill the order with explicit tick window
360
+ uint256 epochBefore = currentEpoch;
361
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
362
+
363
+ vm.recordLogs();
364
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, 1, address(0));
365
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
366
+
367
+ // 8. Verify fill succeeded
368
+ assertEq(fills.length, 1, "should fill single order");
369
+ assertEq(fills[0].maker, users.seller, "maker mismatch");
370
+ assertGt(fills[0].amountOut, 0, "should have output amount");
371
+
372
+ _assertEpochIncrement(poolKeyHash, epochBefore);
373
+
374
+ // Verify order consumed
375
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(poolKeyHash, orderCoin, created[0].tick);
376
+ assertEq(tickQueue.length, 0, "tick queue should be empty");
377
+ assertEq(tickQueue.balance, 0, "tick balance should be zero");
378
+ }
379
+
380
+ function test_fillRangePaysReferral() public {
381
+ PoolKey memory key = creatorCoin.getPoolKey();
382
+ address referral = makeAddr("referral");
383
+
384
+ // 1. Create orders via swap - orders placed as limit orders behind current price
385
+ vm.recordLogs();
386
+ _executeSingleHopSwapWithLimitOrders(users.buyer, key, DEFAULT_LIMIT_ORDER_AMOUNT, _defaultMultiples(), _defaultPercentages());
387
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
388
+ assertGt(created.length, 0, "expected orders to be created");
389
+ _assertOpenOrderState(users.buyer, created[0].coin, created[0].poolKeyHash, created, key.tickSpacing);
390
+
391
+ // Move price past orders so they are fully crossed
392
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
393
+
394
+ bool isCurrency0 = created[0].isCurrency0;
395
+ address orderCoin = created[0].coin;
396
+ bytes32 poolKeyHash = created[0].poolKeyHash;
397
+
398
+ // 2. Fill one order with referral address
399
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
400
+ address payoutCoin = isCurrency0 ? Currency.unwrap(key.currency1) : Currency.unwrap(key.currency0);
401
+ uint256 referralBalanceBefore = _balanceOf(payoutCoin, referral);
402
+ uint256 epochBefore = _poolEpoch(poolKeyHash);
403
+
404
+ vm.recordLogs();
405
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, 1, referral);
406
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
407
+
408
+ assertEq(fills.length, 1, "should fill single order");
409
+ assertEq(fills[0].maker, users.buyer, "maker mismatch");
410
+ assertEq(fills[0].coinIn, orderCoin, "coin mismatch");
411
+ assertEq(fills[0].fillReferral, referral, "referral address is correctly tracked");
412
+
413
+ // Verify referral balance change matches the event amount (validates accounting correctness)
414
+ uint256 referralBalanceAfter = _balanceOf(payoutCoin, referral);
415
+ uint256 referralDelta = referralBalanceAfter - referralBalanceBefore;
416
+ assertEq(referralDelta, fills[0].fillReferralAmount, "referral balance delta must match event");
417
+
418
+ _assertEpochIncrement(poolKeyHash, epochBefore);
419
+ }
420
+
421
+ function test_fillRevertsOnInvalidWindowCurrency0() public {
422
+ PoolKey memory key = creatorCoin.getPoolKey();
423
+ int24 baseTick = _alignedTick(_currentTick(key), key.tickSpacing);
424
+ int24 startTick = baseTick + key.tickSpacing;
425
+ int24 endTick = baseTick - key.tickSpacing;
426
+
427
+ vm.expectRevert(abi.encodeWithSelector(IZoraLimitOrderBook.InvalidFillWindow.selector, startTick, endTick, true));
428
+ limitOrderBook.fill(key, true, startTick, endTick, 1, address(0));
429
+ }
430
+
431
+ function test_fillRevertsOnInvalidWindowCurrency1() public {
432
+ PoolKey memory key = creatorCoin.getPoolKey();
433
+ int24 baseTick = _alignedTick(_currentTick(key), key.tickSpacing);
434
+ int24 startTick = baseTick - key.tickSpacing;
435
+ int24 endTick = baseTick + key.tickSpacing;
436
+
437
+ vm.expectRevert(abi.encodeWithSelector(IZoraLimitOrderBook.InvalidFillWindow.selector, startTick, endTick, false));
438
+ limitOrderBook.fill(key, false, startTick, endTick, 1, address(0));
439
+ }
440
+
441
+ function test_fillRangeViaHookConsumesOrders() public {
442
+ PoolKey memory key = creatorCoin.getPoolKey();
443
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
444
+ address orderCoin = _orderCoin(key, isCurrency0);
445
+
446
+ // Create orders manually at specific ticks OUT of the money
447
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 3, 30e18);
448
+ uint256 totalSize;
449
+ for (uint256 i = 0; i < orderSizes.length; ++i) {
450
+ totalSize += orderSizes[i];
451
+ }
452
+
453
+ _fundMaker(orderCoin, users.seller, totalSize);
454
+
455
+ vm.recordLogs();
456
+ vm.prank(users.seller);
457
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
458
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
459
+ assertEq(created.length, orderSizes.length, "expected all orders to be created");
460
+ _assertOpenOrderState(users.seller, orderCoin, created[0].poolKeyHash, created, key.tickSpacing);
461
+
462
+ bytes32 poolKeyHash = created[0].poolKeyHash;
463
+ uint256 epochBefore = _poolEpoch(poolKeyHash);
464
+
465
+ // _movePriceBeyondTicks triggers the hook's afterSwap which automatically fills the orders
466
+ vm.recordLogs();
467
+ _movePriceBeyondTicks(created);
468
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
469
+
470
+ assertEq(fills.length, created.length, "hook fill count mismatch");
471
+ assertEq(_makerBalance(users.seller, orderCoin), 0, "maker balance should clear");
472
+ _assertEpochIncrement(poolKeyHash, epochBefore);
473
+ }
474
+
475
+ function test_fillRangeSkipsStaleOrdersButRespectsMaxFillCount() public {
476
+ PoolKey memory key = creatorCoin.getPoolKey();
477
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
478
+ address orderCoin = _orderCoin(key, isCurrency0);
479
+
480
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 3, 25e18);
481
+ uint256 totalSize;
482
+ for (uint256 i; i < orderSizes.length; ++i) {
483
+ totalSize += orderSizes[i];
484
+ }
485
+
486
+ if (orderCoin == address(0)) {
487
+ vm.deal(users.seller, totalSize);
488
+ } else {
489
+ deal(orderCoin, users.seller, totalSize);
490
+ vm.startPrank(users.seller);
491
+ IERC20(orderCoin).approve(address(limitOrderBook), totalSize);
492
+ vm.stopPrank();
493
+ }
494
+
495
+ vm.recordLogs();
496
+ vm.prank(users.seller);
497
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
498
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
499
+ assertEq(created.length, orderSizes.length, "unexpected created order count");
500
+ _assertOpenOrderState(users.seller, orderCoin, created[0].poolKeyHash, created, key.tickSpacing);
501
+
502
+ bytes32 poolKeyHash = created[0].poolKeyHash;
503
+ uint256 epochBefore = _poolEpoch(poolKeyHash);
504
+
505
+ uint256 staleIndex;
506
+ int24 minTick = created[0].tick;
507
+ for (uint256 i = 1; i < created.length; ++i) {
508
+ if (created[i].tick < minTick) {
509
+ minTick = created[i].tick;
510
+ staleIndex = i;
511
+ }
512
+ }
513
+ _setOrderCreatedEpoch(created[staleIndex].orderId, uint32(epochBefore + 1));
514
+
515
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
516
+
517
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
518
+ uint256 maxFillCount = 2;
519
+
520
+ vm.recordLogs();
521
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, maxFillCount, address(0));
522
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
523
+
524
+ assertEq(fills.length, maxFillCount, "fill count mismatch");
525
+ for (uint256 i; i < fills.length; ++i) {
526
+ assertTrue(fills[i].orderId != created[staleIndex].orderId, "stale order should remain");
527
+ }
528
+
529
+ uint256 expectedRemaining = totalSize;
530
+ for (uint256 i; i < fills.length; ++i) {
531
+ expectedRemaining -= uint256(fills[i].amountIn);
532
+ }
533
+
534
+ assertEq(_makerBalance(users.seller, orderCoin), expectedRemaining, "maker balance");
535
+
536
+ for (uint256 i; i < created.length; ++i) {
537
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(poolKeyHash, orderCoin, created[i].tick);
538
+ if (i == staleIndex) {
539
+ assertEq(tickQueue.length, 1, "stale tick length");
540
+ assertEq(uint256(tickQueue.balance), expectedRemaining, "stale tick balance");
541
+ assertTrue(_isTickInitialized(poolKeyHash, orderCoin, created[i].tick, key.tickSpacing), "stale tick bitmap cleared");
542
+ } else {
543
+ assertEq(tickQueue.length, 0, "cleared tick length");
544
+ assertEq(tickQueue.balance, 0, "cleared tick balance");
545
+ assertFalse(_isTickInitialized(poolKeyHash, orderCoin, created[i].tick, key.tickSpacing), "tick bitmap still set");
546
+ }
547
+ }
548
+
549
+ _assertEpochIncrement(poolKeyHash, epochBefore);
550
+ }
551
+
552
+ function test_fillBatchConsumesOrders() public {
553
+ PoolKey memory key = creatorCoin.getPoolKey();
554
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
555
+ address orderCoin = LimitOrderCommon.getOrderCoin(key, isCurrency0);
556
+
557
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 2, 25e18);
558
+ uint256 totalSize;
559
+ for (uint256 i; i < orderSizes.length; ++i) {
560
+ totalSize += orderSizes[i];
561
+ }
562
+ if (orderCoin == address(0)) {
563
+ vm.deal(users.seller, totalSize);
564
+ } else {
565
+ deal(orderCoin, users.seller, totalSize);
566
+ vm.prank(users.seller);
567
+ IERC20(orderCoin).approve(address(limitOrderBook), totalSize);
568
+ }
569
+
570
+ vm.recordLogs();
571
+ vm.prank(users.seller);
572
+ bytes32[] memory orderIds = limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(
573
+ key,
574
+ isCurrency0,
575
+ orderSizes,
576
+ orderTicks,
577
+ users.seller
578
+ );
579
+ assertTrue(orderIds[0] != orderIds[1], "duplicate order ids");
580
+ LimitOrderTypes.LimitOrder memory order0Before = limitOrderBook.exposedOrder(orderIds[0]);
581
+ LimitOrderTypes.LimitOrder memory order1Before = limitOrderBook.exposedOrder(orderIds[1]);
582
+ assertEq(uint256(order0Before.status), uint256(LimitOrderTypes.OrderStatus.OPEN), "order0 pre status");
583
+ assertEq(uint256(order1Before.status), uint256(LimitOrderTypes.OrderStatus.OPEN), "order1 pre status");
584
+
585
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
586
+ assertEq(created.length, orderIds.length, "unexpected created order count");
587
+ bytes32 poolKeyHash = CoinCommon.hashPoolKey(key);
588
+ for (uint256 i; i < created.length; ++i) {
589
+ assertEq(created[i].poolKeyHash, poolKeyHash, "pool hash mismatch");
590
+ }
591
+ _assertOpenOrderState(users.seller, orderCoin, poolKeyHash, created, key.tickSpacing);
592
+
593
+ _movePriceBeyondTicks(created);
594
+
595
+ IZoraLimitOrderBook.OrderBatch[] memory batches = new IZoraLimitOrderBook.OrderBatch[](1);
596
+ batches[0] = IZoraLimitOrderBook.OrderBatch({key: key, isCurrency0: isCurrency0, orderIds: orderIds});
597
+
598
+ limitOrderBook.fill(batches, address(0));
599
+ LimitOrderTypes.LimitOrder memory order0After = limitOrderBook.exposedOrder(orderIds[0]);
600
+ LimitOrderTypes.LimitOrder memory order1After = limitOrderBook.exposedOrder(orderIds[1]);
601
+ assertEq(uint256(order0After.status), uint256(LimitOrderTypes.OrderStatus.FILLED), "order0 post status");
602
+ assertEq(uint256(order1After.status), uint256(LimitOrderTypes.OrderStatus.FILLED), "order1 post status");
603
+ assertEq(_makerBalance(users.seller, orderCoin), 0, "maker balance should be zero");
604
+
605
+ for (uint256 i; i < created.length; ++i) {
606
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[i].poolKeyHash, orderCoin, created[i].tick);
607
+ assertEq(tickQueue.length, 0, "tick queue length");
608
+ assertEq(tickQueue.balance, 0, "tick queue balance");
609
+ assertFalse(_isTickInitialized(created[i].poolKeyHash, orderCoin, created[i].tick, key.tickSpacing), "tick bitmap still set");
610
+ }
611
+ }
612
+
613
+ function test_fillBatchConsumesOrdersWithAutoFillDisabledDuringSetup() public {
614
+ PoolKey memory key = creatorCoin.getPoolKey();
615
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
616
+ address orderCoin = LimitOrderCommon.getOrderCoin(key, isCurrency0);
617
+
618
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 2, 25e18);
619
+ uint256 totalSize;
620
+ for (uint256 i; i < orderSizes.length; ++i) {
621
+ totalSize += orderSizes[i];
622
+ }
623
+ if (orderCoin == address(0)) {
624
+ vm.deal(users.seller, totalSize);
625
+ } else {
626
+ deal(orderCoin, users.seller, totalSize);
627
+ vm.prank(users.seller);
628
+ IERC20(orderCoin).approve(address(limitOrderBook), totalSize);
629
+ }
630
+
631
+ vm.recordLogs();
632
+ vm.prank(users.seller);
633
+ bytes32[] memory orderIds = limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(
634
+ key,
635
+ isCurrency0,
636
+ orderSizes,
637
+ orderTicks,
638
+ users.seller
639
+ );
640
+
641
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
642
+ assertEq(created.length, orderIds.length, "unexpected created order count");
643
+
644
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
645
+
646
+ IZoraLimitOrderBook.OrderBatch[] memory batches = new IZoraLimitOrderBook.OrderBatch[](1);
647
+ batches[0] = IZoraLimitOrderBook.OrderBatch({key: key, isCurrency0: isCurrency0, orderIds: orderIds});
648
+
649
+ limitOrderBook.fill(batches, address(0));
650
+ LimitOrderTypes.LimitOrder memory order0After = limitOrderBook.exposedOrder(orderIds[0]);
651
+ LimitOrderTypes.LimitOrder memory order1After = limitOrderBook.exposedOrder(orderIds[1]);
652
+ assertEq(uint256(order0After.status), uint256(LimitOrderTypes.OrderStatus.FILLED), "order0 post status");
653
+ assertEq(uint256(order1After.status), uint256(LimitOrderTypes.OrderStatus.FILLED), "order1 post status");
654
+ assertEq(_makerBalance(users.seller, orderCoin), 0, "maker balance should be zero");
655
+ }
656
+
657
+ function _balanceOf(address token, address account) private view returns (uint256) {
658
+ if (token == address(0)) {
659
+ return account.balance;
660
+ }
661
+ return IERC20(token).balanceOf(account);
662
+ }
663
+
664
+ function _fundMaker(address asset, address maker, uint256 amount) private {
665
+ if (asset == address(0)) {
666
+ vm.deal(maker, amount);
667
+ } else {
668
+ deal(asset, maker, amount);
669
+ vm.prank(maker);
670
+ IERC20(asset).approve(address(limitOrderBook), amount);
671
+ }
672
+ }
673
+
674
+ function test_fillWithNoResidual() public {
675
+ PoolKey memory key = creatorCoin.getPoolKey();
676
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
677
+ address orderCoin = _orderCoin(key, isCurrency0);
678
+
679
+ // Create orders with sizes that divide evenly into liquidity (no residual)
680
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 1, 100e18);
681
+
682
+ uint256 totalSize = orderSizes[0];
683
+ _fundAndApprove(users.seller, orderCoin, totalSize);
684
+
685
+ vm.recordLogs();
686
+ vm.prank(users.seller);
687
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
688
+
689
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
690
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
691
+
692
+ // Fill order - should handle zero residual gracefully
693
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
694
+ vm.recordLogs();
695
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, created.length, address(0));
696
+
697
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
698
+ assertEq(fills.length, 1, "should fill one order");
699
+ }
700
+
701
+ function test_fillWithReferral() public {
702
+ PoolKey memory key = creatorCoin.getPoolKey();
703
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
704
+ address orderCoin = _orderCoin(key, isCurrency0);
705
+
706
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 1, 50e18);
707
+
708
+ uint256 totalSize = orderSizes[0];
709
+ _fundAndApprove(users.seller, orderCoin, totalSize);
710
+
711
+ vm.recordLogs();
712
+ vm.prank(users.seller);
713
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
714
+
715
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
716
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
717
+
718
+ // Fill with referral address
719
+ address referral = makeAddr("referral");
720
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
721
+
722
+ vm.recordLogs();
723
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, created.length, referral);
724
+
725
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
726
+ assertEq(fills.length, 1, "should fill one order");
727
+ assertEq(fills[0].fillReferral, referral, "referral should be set");
728
+ assertGt(fills[0].fillReferralAmount, 0, "referral should receive fee");
729
+ }
730
+
731
+ function test_fillWithoutReferral() public {
732
+ PoolKey memory key = creatorCoin.getPoolKey();
733
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
734
+ address orderCoin = _orderCoin(key, isCurrency0);
735
+
736
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 1, 50e18);
737
+
738
+ uint256 totalSize = orderSizes[0];
739
+ _fundAndApprove(users.seller, orderCoin, totalSize);
740
+
741
+ vm.recordLogs();
742
+ vm.prank(users.seller);
743
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
744
+
745
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
746
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
747
+
748
+ // Fill without referral (address(0))
749
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
750
+
751
+ vm.recordLogs();
752
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, created.length, address(0));
753
+
754
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
755
+ assertEq(fills.length, 1, "should fill one order");
756
+ assertEq(fills[0].fillReferral, address(0), "referral should be zero");
757
+ assertEq(fills[0].fillReferralAmount, 0, "referral should receive no fee");
758
+ }
759
+
760
+ function _fundAndApprove(address user, address token, uint256 amount) internal {
761
+ if (token == address(0)) {
762
+ vm.deal(user, amount);
763
+ } else {
764
+ deal(token, user, amount);
765
+ vm.prank(user);
766
+ IERC20(token).approve(address(limitOrderBook), amount);
767
+ }
768
+ }
769
+
770
+ /// @notice Tests fill() with maxFillCount=0 (line 86-88)
771
+ /// @dev This tests the branch: if (maxFillCount == 0) maxFillCount = getMaxFillCount();
772
+ function test_fill_maxFillCountZero_usesDefault() public {
773
+ PoolKey memory key = creatorCoin.getPoolKey();
774
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
775
+ address orderCoin = _orderCoin(key, isCurrency0);
776
+
777
+ // Set max fill count to 2 so we can verify default is used
778
+ vm.prank(users.factoryOwner);
779
+ limitOrderBook.setMaxFillCount(2);
780
+
781
+ // Create 5 orders that can be filled
782
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 5, 20e18);
783
+ uint256 totalSize;
784
+ for (uint256 i; i < orderSizes.length; ++i) {
785
+ totalSize += orderSizes[i];
786
+ }
787
+
788
+ if (orderCoin == address(0)) {
789
+ vm.deal(users.seller, totalSize);
790
+ } else {
791
+ deal(orderCoin, users.seller, totalSize);
792
+ vm.startPrank(users.seller);
793
+ IERC20(orderCoin).approve(address(limitOrderBook), totalSize);
794
+ vm.stopPrank();
795
+ }
796
+
797
+ vm.recordLogs();
798
+ vm.prank(users.seller);
799
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
800
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
801
+ assertEq(created.length, orderSizes.length, "unexpected created order count");
802
+
803
+ // Move price past orders to make them fillable, but disable auto-fill so manual fill can consume them
804
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
805
+
806
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
807
+
808
+ // Call fill with maxFillCount = 0 (should use default of 2)
809
+ vm.recordLogs();
810
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, 0, address(0));
811
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
812
+
813
+ // Should fill 2 orders (the default max count)
814
+ assertEq(fills.length, 2, "should fill default max count of 2 orders");
815
+ }
816
+
817
+ /// @notice Tests batch fill with empty orderIds array (line 134)
818
+ /// @dev This tests the branch: if (batch.orderIds.length != 0)
819
+ function test_batchFill_emptyOrderIds_skips() public {
820
+ PoolKey memory key = creatorCoin.getPoolKey();
821
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
822
+
823
+ // Create batch with empty orderIds
824
+ IZoraLimitOrderBook.OrderBatch[] memory batches = new IZoraLimitOrderBook.OrderBatch[](2);
825
+ batches[0] = IZoraLimitOrderBook.OrderBatch({
826
+ key: key,
827
+ isCurrency0: isCurrency0,
828
+ orderIds: new bytes32[](0) // Empty array - should be skipped
829
+ });
830
+ batches[1] = IZoraLimitOrderBook.OrderBatch({
831
+ key: key,
832
+ isCurrency0: isCurrency0,
833
+ orderIds: new bytes32[](0) // Empty array - should be skipped
834
+ });
835
+
836
+ vm.recordLogs();
837
+ limitOrderBook.fill(batches, address(0));
838
+
839
+ // Should complete without reverting (skips empty batches)
840
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
841
+ assertEq(fills.length, 0, "should not fill anything from empty batches");
842
+ }
843
+
844
+ /// @notice Tests batch fill with mixed empty and non-empty batches
845
+ function test_batchFill_mixedEmptyAndNonEmpty_processesNonEmpty() public {
846
+ PoolKey memory key = creatorCoin.getPoolKey();
847
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
848
+ address orderCoin = _orderCoin(key, isCurrency0);
849
+
850
+ // Create one order
851
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 1, 25e18);
852
+ uint256 totalSize = orderSizes[0];
853
+
854
+ if (orderCoin == address(0)) {
855
+ vm.deal(users.seller, totalSize);
856
+ } else {
857
+ deal(orderCoin, users.seller, totalSize);
858
+ vm.startPrank(users.seller);
859
+ IERC20(orderCoin).approve(address(limitOrderBook), totalSize);
860
+ vm.stopPrank();
861
+ }
862
+
863
+ vm.recordLogs();
864
+ vm.prank(users.seller);
865
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
866
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
867
+ assertEq(created.length, 1, "should create 1 order");
868
+
869
+ // Move price past order to make it fillable, with auto-fill disabled
870
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
871
+
872
+ // Create batches: first empty, second with order
873
+ IZoraLimitOrderBook.OrderBatch[] memory batches = new IZoraLimitOrderBook.OrderBatch[](2);
874
+ batches[0] = IZoraLimitOrderBook.OrderBatch({
875
+ key: key,
876
+ isCurrency0: isCurrency0,
877
+ orderIds: new bytes32[](0) // Empty - should skip
878
+ });
879
+
880
+ bytes32[] memory orderIds = new bytes32[](1);
881
+ orderIds[0] = created[0].orderId;
882
+ batches[1] = IZoraLimitOrderBook.OrderBatch({
883
+ key: key,
884
+ isCurrency0: isCurrency0,
885
+ orderIds: orderIds // Non-empty - should process
886
+ });
887
+
888
+ vm.recordLogs();
889
+ limitOrderBook.fill(batches, address(0));
890
+
891
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
892
+ assertEq(fills.length, 1, "should fill the one order from non-empty batch");
893
+ }
894
+
895
+ /// @notice Verifies that once the pool tick crosses past the order boundary, the order
896
+ /// gets filled and coinOut is the counter asset (not same as coinIn).
897
+ function test_fillSucceedsOnceCrossed_rangeWalk() public {
898
+ PoolKey memory key = creatorCoin.getPoolKey();
899
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
900
+ address orderCoin = _orderCoin(key, isCurrency0);
901
+
902
+ // Create a single order out-of-the-money
903
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 1, 50e18);
904
+ uint256 totalSize = orderSizes[0];
905
+ _fundAndApprove(users.seller, orderCoin, totalSize);
906
+
907
+ vm.recordLogs();
908
+ vm.prank(users.seller);
909
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
910
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
911
+
912
+ bytes32 orderId = created[0].orderId;
913
+
914
+ // Move price past the order to make it crossed (using real swap, auto-fill disabled)
915
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
916
+
917
+ // Now fill manually - should work since order is crossed
918
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
919
+ vm.recordLogs();
920
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, 10, address(0));
921
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
922
+
923
+ // Should fill
924
+ assertEq(fills.length, 1, "should fill after crossing");
925
+
926
+ // Verify coinIn != coinOut (counter asset received)
927
+ assertTrue(fills[0].coinIn != fills[0].coinOut, "coinIn should differ from coinOut");
928
+ assertEq(fills[0].coinIn, orderCoin, "coinIn should be the order coin");
929
+ assertGt(fills[0].amountOut, 0, "should have non-zero amountOut");
930
+
931
+ // Verify order is now FILLED
932
+ LimitOrderTypes.LimitOrder memory orderAfter = limitOrderBook.exposedOrder(orderId);
933
+ assertEq(uint256(orderAfter.status), uint256(LimitOrderTypes.OrderStatus.FILLED), "order should be FILLED");
934
+ }
935
+
936
+ /// @notice Same as above but using the batch fill path.
937
+ function test_fillSucceedsOnceCrossed_batchFill() public {
938
+ PoolKey memory key = creatorCoin.getPoolKey();
939
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
940
+ address orderCoin = _orderCoin(key, isCurrency0);
941
+
942
+ // Create a single order out-of-the-money
943
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 1, 50e18);
944
+ uint256 totalSize = orderSizes[0];
945
+ _fundAndApprove(users.seller, orderCoin, totalSize);
946
+
947
+ vm.recordLogs();
948
+ vm.prank(users.seller);
949
+ bytes32[] memory orderIds = limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(
950
+ key,
951
+ isCurrency0,
952
+ orderSizes,
953
+ orderTicks,
954
+ users.seller
955
+ );
956
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
957
+
958
+ // Move price past the order to make it crossed
959
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
960
+
961
+ // Fill via batch - should work since order is crossed
962
+ IZoraLimitOrderBook.OrderBatch[] memory batches = new IZoraLimitOrderBook.OrderBatch[](1);
963
+ batches[0] = IZoraLimitOrderBook.OrderBatch({key: key, isCurrency0: isCurrency0, orderIds: orderIds});
964
+
965
+ vm.recordLogs();
966
+ limitOrderBook.fill(batches, address(0));
967
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
968
+
969
+ // Should fill
970
+ assertEq(fills.length, 1, "should fill after crossing via batch");
971
+ assertTrue(fills[0].coinIn != fills[0].coinOut, "coinIn should differ from coinOut");
972
+ }
973
+
974
+ /// @notice Tests that after crossing, fill correctly pays out the counter asset.
975
+ /// This is a simpler version that just verifies correct payout after real price movement.
976
+ function test_fillAfterCrossing_correctPayout() public {
977
+ PoolKey memory key = creatorCoin.getPoolKey();
978
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
979
+ address orderCoin = _orderCoin(key, isCurrency0);
980
+ address counterCoin = isCurrency0 ? Currency.unwrap(key.currency1) : Currency.unwrap(key.currency0);
981
+
982
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 1, 50e18);
983
+ uint256 totalSize = orderSizes[0];
984
+ _fundAndApprove(users.seller, orderCoin, totalSize);
985
+
986
+ vm.recordLogs();
987
+ vm.prank(users.seller);
988
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
989
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
990
+
991
+ // Move price past the order
992
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
993
+
994
+ // Fill
995
+ (int24 startTick, int24 endTick) = _tickWindow(created, key);
996
+ vm.recordLogs();
997
+ limitOrderBook.fill(key, isCurrency0, startTick, endTick, 10, address(0));
998
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
999
+
1000
+ assertEq(fills.length, 1, "should fill");
1001
+ assertEq(fills[0].coinIn, orderCoin, "coinIn should be order coin");
1002
+ assertEq(fills[0].coinOut, counterCoin, "coinOut should be counter coin");
1003
+ assertGt(fills[0].amountOut, 0, "should receive counter asset");
1004
+ }
1005
+ }