@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,653 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.13;
3
+
4
+ import {BaseTest} from "./utils/BaseTest.sol";
5
+
6
+ import {IZoraLimitOrderBook} from "../src/IZoraLimitOrderBook.sol";
7
+ import {LimitOrderTypes} from "../src/libs/LimitOrderTypes.sol";
8
+
9
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
10
+ import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
11
+
12
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
13
+ import {Vm} from "forge-std/Vm.sol";
14
+
15
+ contract LimitOrderWithdrawTest is BaseTest {
16
+ function test_withdrawOrdersCancelsAll() public {
17
+ PoolKey memory key = creatorCoin.getPoolKey();
18
+
19
+ vm.recordLogs();
20
+ _executeSingleHopSwapWithLimitOrders(users.seller, key, DEFAULT_LIMIT_ORDER_AMOUNT, _defaultMultiples(), _defaultPercentages());
21
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
22
+ assertGt(created.length, 0, "expected orders to be created");
23
+ _assertOpenOrderState(users.seller, created[0].coin, created[0].poolKeyHash, created, key.tickSpacing);
24
+
25
+ bytes32[] memory orderIds = _orderIds(created);
26
+ address orderCoin = created[0].coin;
27
+ uint256 tokenBalanceBefore = _balanceOf(orderCoin, users.seller);
28
+ uint256 epochBefore = _poolEpoch(created[0].poolKeyHash);
29
+ uint256 totalOrderSize = _sumOrderSizes(created);
30
+
31
+ vm.recordLogs();
32
+ vm.prank(users.seller);
33
+ limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
34
+ UpdatedOrderLog[] memory updates = _decodeUpdatedLogs(vm.getRecordedLogs());
35
+
36
+ uint256 cancelled;
37
+ for (uint256 i; i < updates.length; ++i) {
38
+ if (updates[i].maker != users.seller) continue;
39
+ if (!updates[i].isCancelled) continue;
40
+ ++cancelled;
41
+ }
42
+
43
+ assertEq(cancelled, orderIds.length, "all orders should cancel");
44
+
45
+ uint256 tokenBalanceAfter = _balanceOf(orderCoin, users.seller);
46
+ assertApproxEqAbs(tokenBalanceAfter, tokenBalanceBefore + totalOrderSize, 5, "token refund mismatch");
47
+ assertEq(_poolEpoch(created[0].poolKeyHash), epochBefore, "withdraw should not change epoch");
48
+
49
+ for (uint256 i; i < created.length; ++i) {
50
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[i].poolKeyHash, orderCoin, created[i].tick);
51
+ assertEq(tickQueue.length, 0, "tick queue length");
52
+ assertEq(tickQueue.balance, 0, "tick queue balance");
53
+ assertFalse(_isTickInitialized(created[i].poolKeyHash, orderCoin, created[i].tick, key.tickSpacing), "tick bitmap still set");
54
+ }
55
+ }
56
+
57
+ function test_withdrawOrdersRevertsForMixedCoins() public {
58
+ PoolKey memory creatorKey = creatorCoin.getPoolKey();
59
+ PoolKey memory contentKey = contentCoin.getPoolKey();
60
+
61
+ vm.recordLogs();
62
+ _executeSingleHopSwapWithLimitOrders(users.seller, creatorKey, DEFAULT_LIMIT_ORDER_AMOUNT, _defaultMultiples(), _defaultPercentages());
63
+ CreatedOrderLog[] memory creatorOrders = _decodeCreatedLogs(vm.getRecordedLogs());
64
+ assertGt(creatorOrders.length, 0, "expected creator orders");
65
+
66
+ // Content pool requires multi-hop routing: ZORA → SharedToken → ContentCoin
67
+ vm.recordLogs();
68
+ PoolKey[] memory contentRoute = new PoolKey[](2);
69
+ contentRoute[0] = creatorKey; // First hop: ZORA → SharedToken
70
+ contentRoute[1] = contentKey; // Second hop: SharedToken → ContentCoin
71
+ _executeMultiHopSwapWithLimitOrders(users.seller, contentRoute, DEFAULT_LIMIT_ORDER_AMOUNT, _defaultMultiples(), _defaultPercentages());
72
+ CreatedOrderLog[] memory contentOrders = _decodeCreatedLogs(vm.getRecordedLogs());
73
+ assertGt(contentOrders.length, 0, "expected content orders");
74
+
75
+ bytes32[] memory mixed = new bytes32[](creatorOrders.length + contentOrders.length);
76
+ for (uint256 i; i < creatorOrders.length; ++i) {
77
+ mixed[i] = creatorOrders[i].orderId;
78
+ }
79
+ for (uint256 i; i < contentOrders.length; ++i) {
80
+ mixed[creatorOrders.length + i] = contentOrders[i].orderId;
81
+ }
82
+
83
+ // First content order will trigger the mismatch
84
+ bytes32 mismatchOrderId = contentOrders[0].orderId;
85
+ address expectedCoin = creatorOrders[0].coin;
86
+ address actualCoin = contentOrders[0].coin;
87
+
88
+ vm.expectRevert(abi.encodeWithSelector(IZoraLimitOrderBook.CoinMismatch.selector, mismatchOrderId, expectedCoin, actualCoin));
89
+ vm.prank(users.seller);
90
+ limitOrderBook.withdraw(mixed, expectedCoin, 0, users.seller);
91
+
92
+ _assertOpenOrderState(users.seller, creatorOrders[0].coin, creatorOrders[0].poolKeyHash, creatorOrders, creatorKey.tickSpacing);
93
+ _assertOpenOrderState(users.seller, contentOrders[0].coin, contentOrders[0].poolKeyHash, contentOrders, contentKey.tickSpacing);
94
+ }
95
+
96
+ function test_withdrawOrdersRevertsForRecipientZero() public {
97
+ PoolKey memory key = creatorCoin.getPoolKey();
98
+
99
+ vm.recordLogs();
100
+ _executeSingleHopSwapWithLimitOrders(users.seller, key, DEFAULT_LIMIT_ORDER_AMOUNT, _defaultMultiples(), _defaultPercentages());
101
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
102
+ assertGt(created.length, 0, "expected orders to be created");
103
+
104
+ bytes32[] memory orderIds = _orderIds(created);
105
+ address orderCoin = created[0].coin;
106
+
107
+ vm.expectRevert(IZoraLimitOrderBook.AddressZero.selector);
108
+ vm.prank(users.seller);
109
+ limitOrderBook.withdraw(orderIds, orderCoin, 0, address(0));
110
+ }
111
+
112
+ function test_withdrawOrdersRevertsForNonMaker() public {
113
+ PoolKey memory key = creatorCoin.getPoolKey();
114
+
115
+ vm.recordLogs();
116
+ _executeSingleHopSwapWithLimitOrders(users.seller, key, DEFAULT_LIMIT_ORDER_AMOUNT, _defaultMultiples(), _defaultPercentages());
117
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
118
+ bytes32[] memory orderIds = _orderIds(created);
119
+ address orderCoin = created[0].coin;
120
+
121
+ vm.expectRevert(IZoraLimitOrderBook.OrderNotMaker.selector);
122
+ vm.prank(users.buyer);
123
+ limitOrderBook.withdraw(orderIds, orderCoin, 0, users.buyer);
124
+
125
+ _assertOpenOrderState(users.seller, created[0].coin, created[0].poolKeyHash, created, key.tickSpacing);
126
+ }
127
+
128
+ function test_withdrawOrdersRevertsOnInvalidOrder() public {
129
+ PoolKey memory key = creatorCoin.getPoolKey();
130
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
131
+
132
+ uint256[] memory orderSizes = new uint256[](1);
133
+ orderSizes[0] = 75e18;
134
+ (, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 1, 1);
135
+ (CreatedOrderLog[] memory created, address orderCoin) = _createOrders(users.seller, key, isCurrency0, orderSizes, orderTicks);
136
+
137
+ bytes32[] memory mixed = new bytes32[](2);
138
+ mixed[0] = created[0].orderId;
139
+ mixed[1] = bytes32(uint256(123));
140
+
141
+ vm.expectRevert(IZoraLimitOrderBook.InvalidOrder.selector);
142
+ vm.prank(users.seller);
143
+ limitOrderBook.withdraw(mixed, orderCoin, 0, users.seller);
144
+ }
145
+
146
+ function test_withdrawOrdersRevertsOnClosedOrder() public {
147
+ PoolKey memory key = creatorCoin.getPoolKey();
148
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
149
+
150
+ uint256[] memory orderSizes = new uint256[](1);
151
+ orderSizes[0] = 90e18;
152
+ (, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 1, 1);
153
+ (CreatedOrderLog[] memory created, address orderCoin) = _createOrders(users.seller, key, isCurrency0, orderSizes, orderTicks);
154
+
155
+ limitOrderBook.forceOrderStatus(created[0].orderId, LimitOrderTypes.OrderStatus.INACTIVE);
156
+
157
+ bytes32[] memory ids = new bytes32[](1);
158
+ ids[0] = created[0].orderId;
159
+
160
+ vm.expectRevert(IZoraLimitOrderBook.OrderClosed.selector);
161
+ vm.prank(users.seller);
162
+ limitOrderBook.withdraw(ids, orderCoin, 0, users.seller);
163
+ }
164
+
165
+ function test_cancelOrderFullCancellationMarksInactive() public {
166
+ PoolKey memory key = creatorCoin.getPoolKey();
167
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
168
+
169
+ uint256[] memory orderSizes = new uint256[](1);
170
+ orderSizes[0] = 500e18;
171
+ (, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 1, 1);
172
+ (CreatedOrderLog[] memory created, address orderCoin) = _createOrders(users.seller, key, isCurrency0, orderSizes, orderTicks);
173
+
174
+ bytes32[] memory ids = new bytes32[](1);
175
+ ids[0] = created[0].orderId;
176
+
177
+ vm.prank(users.seller);
178
+ limitOrderBook.withdraw(ids, orderCoin, 0, users.seller);
179
+
180
+ LimitOrderTypes.LimitOrder memory orderState = limitOrderBook.exposedOrder(created[0].orderId);
181
+ assertEq(uint8(orderState.status), uint8(LimitOrderTypes.OrderStatus.INACTIVE), "order should be marked inactive");
182
+
183
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, created[0].tick);
184
+ assertEq(tickQueue.length, 0, "tick queue length");
185
+ assertEq(tickQueue.balance, 0, "tick queue balance");
186
+ assertFalse(_isTickInitialized(created[0].poolKeyHash, orderCoin, created[0].tick, key.tickSpacing), "tick bitmap should clear");
187
+ }
188
+
189
+ function test_withdrawWithMinAmountOutStopsEarly() public {
190
+ PoolKey memory key = creatorCoin.getPoolKey();
191
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
192
+
193
+ uint256[] memory orderSizes = new uint256[](3);
194
+ orderSizes[0] = 100e18;
195
+ orderSizes[1] = 200e18;
196
+ orderSizes[2] = 300e18;
197
+ (, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, orderSizes.length, 1);
198
+ (CreatedOrderLog[] memory created, address orderCoin) = _createOrders(users.seller, key, isCurrency0, orderSizes, orderTicks);
199
+
200
+ bytes32[] memory orderIds = _orderIds(created);
201
+ uint256 tokenBalanceBefore = _balanceOf(orderCoin, users.seller);
202
+
203
+ // Use actual created order sizes (which may differ due to liquidity rounding)
204
+ uint256 actualFirstTwo = created[0].size + created[1].size;
205
+
206
+ vm.recordLogs();
207
+ vm.prank(users.seller);
208
+ limitOrderBook.withdraw(orderIds, orderCoin, actualFirstTwo, users.seller);
209
+ UpdatedOrderLog[] memory updates = _decodeUpdatedLogs(vm.getRecordedLogs());
210
+
211
+ // Should have cancelled exactly 2 orders
212
+ uint256 cancelled;
213
+ for (uint256 i; i < updates.length; ++i) {
214
+ if (updates[i].maker != users.seller) continue;
215
+ if (updates[i].isCancelled) ++cancelled;
216
+ }
217
+ assertEq(cancelled, 2, "should cancel first two orders");
218
+
219
+ // Third order should still be open
220
+ LimitOrderTypes.LimitOrder memory thirdOrder = limitOrderBook.exposedOrder(created[2].orderId);
221
+ assertEq(uint8(thirdOrder.status), uint8(LimitOrderTypes.OrderStatus.OPEN), "third order should still be open");
222
+
223
+ uint256 tokenBalanceAfter = _balanceOf(orderCoin, users.seller);
224
+ assertApproxEqAbs(tokenBalanceAfter, tokenBalanceBefore + actualFirstTwo, 5, "token refund should match first two orders");
225
+ }
226
+
227
+ function test_withdrawWithMinAmountOutRevertsIfNotReached() public {
228
+ PoolKey memory key = creatorCoin.getPoolKey();
229
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
230
+
231
+ uint256[] memory orderSizes = new uint256[](2);
232
+ orderSizes[0] = 100e18;
233
+ orderSizes[1] = 200e18;
234
+ (, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, orderSizes.length, 1);
235
+ (CreatedOrderLog[] memory created, address orderCoin) = _createOrders(users.seller, key, isCurrency0, orderSizes, orderTicks);
236
+
237
+ bytes32[] memory orderIds = _orderIds(created);
238
+
239
+ // Use actual created sizes and request slightly more
240
+ uint256 actualTotal = created[0].size + created[1].size;
241
+ uint256 minAmountOut = actualTotal + 1;
242
+
243
+ vm.expectRevert(abi.encodeWithSelector(IZoraLimitOrderBook.MinAmountNotReached.selector, actualTotal, minAmountOut));
244
+ vm.prank(users.seller);
245
+ limitOrderBook.withdraw(orderIds, orderCoin, minAmountOut, users.seller);
246
+ }
247
+
248
+ function test_withdrawWithZeroMinAmountOutCancelsAll() public {
249
+ PoolKey memory key = creatorCoin.getPoolKey();
250
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
251
+
252
+ uint256[] memory orderSizes = new uint256[](3);
253
+ orderSizes[0] = 100e18;
254
+ orderSizes[1] = 200e18;
255
+ orderSizes[2] = 300e18;
256
+ (, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, orderSizes.length, 1);
257
+ (CreatedOrderLog[] memory created, address orderCoin) = _createOrders(users.seller, key, isCurrency0, orderSizes, orderTicks);
258
+
259
+ bytes32[] memory orderIds = _orderIds(created);
260
+ uint256 tokenBalanceBefore = _balanceOf(orderCoin, users.seller);
261
+ uint256 totalSize = orderSizes[0] + orderSizes[1] + orderSizes[2];
262
+
263
+ vm.recordLogs();
264
+ vm.prank(users.seller);
265
+ limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
266
+ UpdatedOrderLog[] memory updates = _decodeUpdatedLogs(vm.getRecordedLogs());
267
+
268
+ // All orders should be cancelled
269
+ uint256 cancelled;
270
+ for (uint256 i; i < updates.length; ++i) {
271
+ if (updates[i].maker != users.seller) continue;
272
+ if (updates[i].isCancelled) ++cancelled;
273
+ }
274
+ assertEq(cancelled, 3, "all orders should cancel");
275
+
276
+ uint256 tokenBalanceAfter = _balanceOf(orderCoin, users.seller);
277
+ assertApproxEqAbs(tokenBalanceAfter, tokenBalanceBefore + totalSize, 5, "token refund should match total");
278
+ }
279
+
280
+ function test_withdrawRevertsOnEmptyOrderIds() public {
281
+ bytes32[] memory orderIds = new bytes32[](0);
282
+
283
+ vm.expectRevert(IZoraLimitOrderBook.InvalidOrder.selector);
284
+ vm.prank(users.seller);
285
+ limitOrderBook.withdraw(orderIds, address(0), 0, users.seller);
286
+ }
287
+
288
+ /// @notice Tests that withdrawing filled orders reverts appropriately
289
+ function test_withdraw_filledOrdersReverts() public {
290
+ PoolKey memory key = creatorCoin.getPoolKey();
291
+
292
+ vm.recordLogs();
293
+ _executeSingleHopSwapWithLimitOrders(users.seller, key, DEFAULT_LIMIT_ORDER_AMOUNT, _defaultMultiples(), _defaultPercentages());
294
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
295
+ address orderCoin = created[0].coin;
296
+
297
+ // Manually mark first order as filled
298
+ limitOrderBook.forceOrderStatus(created[0].orderId, LimitOrderTypes.OrderStatus.FILLED);
299
+
300
+ // Try to withdraw filled order - should revert
301
+ bytes32[] memory orderIds = new bytes32[](1);
302
+ orderIds[0] = created[0].orderId;
303
+
304
+ vm.prank(users.seller);
305
+ vm.expectRevert(IZoraLimitOrderBook.OrderClosed.selector);
306
+ limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
307
+ }
308
+
309
+ /// @notice Tests that bitmap is cleaned when last order at tick is withdrawn
310
+ function test_withdraw_lastOrderAtTick_cleansBitmap() public {
311
+ PoolKey memory key = creatorCoin.getPoolKey();
312
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
313
+ address orderCoin = _orderCoin(key, isCurrency0);
314
+
315
+ // Create single order
316
+ (uint256[] memory sizes, int24[] memory ticks) = _buildDeterministicOrders(key, isCurrency0, 1, 50e18);
317
+ _fundAndApprove(users.seller, orderCoin, sizes[0]);
318
+
319
+ vm.recordLogs();
320
+ vm.prank(users.seller);
321
+ limitOrderBook.create{value: orderCoin == address(0) ? sizes[0] : 0}(key, isCurrency0, sizes, ticks, users.seller);
322
+
323
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
324
+ bytes32 poolKeyHash = created[0].poolKeyHash;
325
+ int24 tick = ticks[0];
326
+
327
+ // Verify bitmap set
328
+ assertTrue(_isTickInitialized(poolKeyHash, orderCoin, tick, key.tickSpacing), "tick should be initialized");
329
+
330
+ // Withdraw order
331
+ bytes32[] memory orderIds = new bytes32[](1);
332
+ orderIds[0] = created[0].orderId;
333
+ vm.prank(users.seller);
334
+ limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
335
+
336
+ // Bitmap should be cleared
337
+ assertFalse(_isTickInitialized(poolKeyHash, orderCoin, tick, key.tickSpacing), "tick should be cleared");
338
+ }
339
+
340
+ function test_withdraw_filledOrder_makerBalanceUnchanged() public {
341
+ PoolKey memory key = creatorCoin.getPoolKey();
342
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
343
+ address orderCoin = _orderCoin(key, isCurrency0);
344
+
345
+ // Create 1 order
346
+ uint256[] memory orderSizes = new uint256[](1);
347
+ orderSizes[0] = 100 ether;
348
+ (, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 1, 1);
349
+ (CreatedOrderLog[] memory created, ) = _createOrders(users.seller, key, isCurrency0, orderSizes, orderTicks);
350
+
351
+ uint256 balanceAfterCreate = _makerBalance(users.seller, orderCoin);
352
+ assertGt(balanceAfterCreate, 0, "balance should be positive after create");
353
+
354
+ // Mark order as filled
355
+ limitOrderBook.forceOrderStatus(created[0].orderId, LimitOrderTypes.OrderStatus.FILLED);
356
+
357
+ // Try to withdraw filled order - should revert without touching balance
358
+ bytes32[] memory orderIds = new bytes32[](1);
359
+ orderIds[0] = created[0].orderId;
360
+
361
+ vm.prank(users.seller);
362
+ vm.expectRevert(IZoraLimitOrderBook.OrderClosed.selector);
363
+ limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
364
+
365
+ // Balance should be unchanged (transaction reverted)
366
+ assertEq(_makerBalance(users.seller, orderCoin), balanceAfterCreate, "balance should be unchanged after revert");
367
+ }
368
+
369
+ function test_withdraw_mixedFilledAndOpen_revertsOnFirstFilled() public {
370
+ PoolKey memory key = creatorCoin.getPoolKey();
371
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
372
+ address orderCoin = _orderCoin(key, isCurrency0);
373
+
374
+ // Create 3 orders
375
+ uint256[] memory orderSizes = new uint256[](3);
376
+ orderSizes[0] = 100 ether;
377
+ orderSizes[1] = 200 ether;
378
+ orderSizes[2] = 300 ether;
379
+ (, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 3, 1);
380
+ (CreatedOrderLog[] memory created, ) = _createOrders(users.seller, key, isCurrency0, orderSizes, orderTicks);
381
+
382
+ uint256 balanceAfterCreate = _makerBalance(users.seller, orderCoin);
383
+
384
+ // Mark second order as filled
385
+ limitOrderBook.forceOrderStatus(created[1].orderId, LimitOrderTypes.OrderStatus.FILLED);
386
+
387
+ // Try to withdraw all 3 orders - should revert when hitting the filled one
388
+ bytes32[] memory orderIds = new bytes32[](3);
389
+ orderIds[0] = created[0].orderId;
390
+ orderIds[1] = created[1].orderId;
391
+ orderIds[2] = created[2].orderId;
392
+
393
+ vm.prank(users.seller);
394
+ vm.expectRevert(IZoraLimitOrderBook.OrderClosed.selector);
395
+ limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
396
+
397
+ // Balance should be unchanged (entire transaction reverted, including first order)
398
+ assertEq(_makerBalance(users.seller, orderCoin), balanceAfterCreate, "balance unchanged after revert");
399
+ }
400
+
401
+ function test_withdraw_withMinAmountOut_filledOrder_revertsBeforeReachingThreshold() public {
402
+ PoolKey memory key = creatorCoin.getPoolKey();
403
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
404
+ address orderCoin = _orderCoin(key, isCurrency0);
405
+
406
+ // Create 2 orders
407
+ uint256[] memory orderSizes = new uint256[](2);
408
+ orderSizes[0] = 100 ether;
409
+ orderSizes[1] = 200 ether;
410
+ (, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 2, 1);
411
+ (CreatedOrderLog[] memory created, ) = _createOrders(users.seller, key, isCurrency0, orderSizes, orderTicks);
412
+
413
+ uint256 balanceAfterCreate = _makerBalance(users.seller, orderCoin);
414
+
415
+ // Mark first order as filled
416
+ limitOrderBook.forceOrderStatus(created[0].orderId, LimitOrderTypes.OrderStatus.FILLED);
417
+
418
+ // Try to withdraw both with minAmountOut - should revert on first filled order
419
+ bytes32[] memory orderIds = new bytes32[](2);
420
+ orderIds[0] = created[0].orderId;
421
+ orderIds[1] = created[1].orderId;
422
+
423
+ uint256 minAmountOut = created[1].size; // Even though we want the second order
424
+
425
+ vm.prank(users.seller);
426
+ vm.expectRevert(IZoraLimitOrderBook.OrderClosed.selector);
427
+ limitOrderBook.withdraw(orderIds, orderCoin, minAmountOut, users.seller);
428
+
429
+ // Balance unchanged
430
+ assertEq(_makerBalance(users.seller, orderCoin), balanceAfterCreate, "balance unchanged after revert");
431
+ }
432
+
433
+ function test_makerBalanceUpdated_emittedOnWithdraw() public {
434
+ PoolKey memory key = creatorCoin.getPoolKey();
435
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
436
+
437
+ uint256[] memory orderSizes = new uint256[](2);
438
+ orderSizes[0] = 100 ether;
439
+ orderSizes[1] = 200 ether;
440
+ (, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, orderSizes.length, 1);
441
+ (CreatedOrderLog[] memory created, address orderCoin) = _createOrders(users.seller, key, isCurrency0, orderSizes, orderTicks);
442
+
443
+ bytes32[] memory orderIds = _orderIds(created);
444
+
445
+ vm.recordLogs();
446
+ vm.prank(users.seller);
447
+ limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
448
+
449
+ // Find MakerBalanceUpdated events
450
+ Vm.Log[] memory logs = vm.getRecordedLogs();
451
+ uint256 eventCount;
452
+ uint256 finalBalance;
453
+ for (uint256 i; i < logs.length; ++i) {
454
+ if (logs[i].topics[0] == IZoraLimitOrderBook.MakerBalanceUpdated.selector) {
455
+ address eventMaker = address(uint160(uint256(logs[i].topics[1])));
456
+ address eventCoin = address(uint160(uint256(logs[i].topics[2])));
457
+ if (eventMaker == users.seller && eventCoin == orderCoin) {
458
+ finalBalance = abi.decode(logs[i].data, (uint256));
459
+ ++eventCount;
460
+ }
461
+ }
462
+ }
463
+
464
+ // Should have 2 events (one per order cancelled)
465
+ assertEq(eventCount, 2, "should emit 2 MakerBalanceUpdated events");
466
+ assertEq(finalBalance, 0, "final balance should be zero");
467
+ assertEq(_makerBalance(users.seller, orderCoin), 0, "maker balance should be zero");
468
+ }
469
+
470
+ function test_withdraw_ethBackedOrders() public {
471
+ PoolKey memory key = creatorCoin.getPoolKey();
472
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
473
+ address orderCoin = _orderCoin(key, isCurrency0);
474
+
475
+ // Skip if this pool doesn't use ETH
476
+ if (orderCoin != address(0)) {
477
+ return;
478
+ }
479
+
480
+ // Create ETH-backed orders
481
+ uint256[] memory orderSizes = new uint256[](2);
482
+ orderSizes[0] = 1 ether;
483
+ orderSizes[1] = 2 ether;
484
+ (, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, orderSizes.length, 1);
485
+ (CreatedOrderLog[] memory created, ) = _createOrders(users.seller, key, isCurrency0, orderSizes, orderTicks);
486
+
487
+ bytes32[] memory orderIds = _orderIds(created);
488
+ uint256 ethBalanceBefore = users.seller.balance;
489
+ uint256 totalSize = created[0].size + created[1].size;
490
+
491
+ // Withdraw ETH-backed orders
492
+ vm.prank(users.seller);
493
+ limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
494
+
495
+ // Verify ETH was refunded
496
+ uint256 ethBalanceAfter = users.seller.balance;
497
+ assertApproxEqAbs(ethBalanceAfter, ethBalanceBefore + totalSize, 5, "ETH refund mismatch");
498
+ }
499
+
500
+ function test_withdraw_ethBackedOrders_withMinAmountOut() public {
501
+ PoolKey memory key = creatorCoin.getPoolKey();
502
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
503
+ address orderCoin = _orderCoin(key, isCurrency0);
504
+
505
+ // Skip if this pool doesn't use ETH
506
+ if (orderCoin != address(0)) {
507
+ return;
508
+ }
509
+
510
+ // Create 3 ETH-backed orders
511
+ uint256[] memory orderSizes = new uint256[](3);
512
+ orderSizes[0] = 1 ether;
513
+ orderSizes[1] = 2 ether;
514
+ orderSizes[2] = 3 ether;
515
+ (, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, orderSizes.length, 1);
516
+ (CreatedOrderLog[] memory created, ) = _createOrders(users.seller, key, isCurrency0, orderSizes, orderTicks);
517
+
518
+ bytes32[] memory orderIds = _orderIds(created);
519
+ uint256 ethBalanceBefore = users.seller.balance;
520
+ uint256 firstTwoSize = created[0].size + created[1].size;
521
+
522
+ // Withdraw with minAmountOut - should stop after first two orders
523
+ vm.prank(users.seller);
524
+ limitOrderBook.withdraw(orderIds, orderCoin, firstTwoSize, users.seller);
525
+
526
+ // Verify first two orders' ETH was refunded
527
+ uint256 ethBalanceAfter = users.seller.balance;
528
+ assertApproxEqAbs(ethBalanceAfter, ethBalanceBefore + firstTwoSize, 5, "ETH refund mismatch");
529
+
530
+ // Third order should still be open
531
+ LimitOrderTypes.LimitOrder memory thirdOrder = limitOrderBook.exposedOrder(created[2].orderId);
532
+ assertEq(uint8(thirdOrder.status), uint8(LimitOrderTypes.OrderStatus.OPEN), "third order should still be open");
533
+ }
534
+
535
+ function _balanceOf(address token, address account) private view returns (uint256) {
536
+ if (token == address(0)) {
537
+ return account.balance;
538
+ }
539
+ return IERC20(token).balanceOf(account);
540
+ }
541
+
542
+ function _createOrders(
543
+ address maker,
544
+ PoolKey memory key,
545
+ bool isCurrency0,
546
+ uint256[] memory orderSizes,
547
+ int24[] memory orderTicks
548
+ ) private returns (CreatedOrderLog[] memory created, address orderCoin) {
549
+ require(orderSizes.length == orderTicks.length, "order configuration mismatch");
550
+
551
+ orderCoin = _orderCoin(key, isCurrency0);
552
+ uint256 totalSize;
553
+ for (uint256 i; i < orderSizes.length; ++i) {
554
+ totalSize += orderSizes[i];
555
+ }
556
+
557
+ _fundAndApprove(maker, orderCoin, totalSize);
558
+
559
+ vm.recordLogs();
560
+ vm.prank(maker);
561
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, maker);
562
+ created = _decodeCreatedLogs(vm.getRecordedLogs());
563
+ }
564
+
565
+ function _fundAndApprove(address maker, address coin, uint256 amount) private {
566
+ if (coin == address(0)) {
567
+ vm.deal(maker, amount);
568
+ return;
569
+ }
570
+
571
+ deal(coin, maker, amount);
572
+ vm.startPrank(maker);
573
+ IERC20(coin).approve(address(limitOrderBook), amount);
574
+ vm.stopPrank();
575
+ }
576
+
577
+ /// @notice Tests that reentrancy during withdrawal is prevented by CEI pattern
578
+ function test_withdraw_reentrancyPrevented() public {
579
+ PoolKey memory key = creatorCoin.getPoolKey();
580
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
581
+ address orderCoin = _orderCoin(key, isCurrency0);
582
+
583
+ // Create 2 orders for the seller
584
+ uint256[] memory orderSizes = new uint256[](2);
585
+ orderSizes[0] = 100 ether;
586
+ orderSizes[1] = 100 ether;
587
+ (, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 2, 1);
588
+ (CreatedOrderLog[] memory created, ) = _createOrders(users.seller, key, isCurrency0, orderSizes, orderTicks);
589
+
590
+ // Deploy malicious recipient that will try to re-enter
591
+ ReentrancyAttacker attacker = new ReentrancyAttacker(limitOrderBook, created[1].orderId, orderCoin);
592
+
593
+ // Withdraw first order to the attacker contract
594
+ // The attacker will try to withdraw the second order during the callback
595
+ bytes32[] memory orderIds = new bytes32[](1);
596
+ orderIds[0] = created[0].orderId;
597
+
598
+ vm.prank(users.seller);
599
+ limitOrderBook.withdraw(orderIds, orderCoin, 0, address(attacker));
600
+
601
+ // Verify first order was withdrawn successfully
602
+ LimitOrderTypes.LimitOrder memory firstOrder = limitOrderBook.exposedOrder(created[0].orderId);
603
+ assertEq(uint8(firstOrder.status), uint8(LimitOrderTypes.OrderStatus.INACTIVE), "first order should be inactive");
604
+
605
+ // Verify attacker's reentrancy attempt failed (second order still open)
606
+ // Note: The attack would fail with OrderClosed if order was already marked inactive,
607
+ // or it would succeed if state wasn't updated before external call
608
+ assertFalse(attacker.attackSucceeded(), "reentrancy attack should have failed");
609
+
610
+ // Second order should still be open (attacker couldn't steal it)
611
+ LimitOrderTypes.LimitOrder memory secondOrder = limitOrderBook.exposedOrder(created[1].orderId);
612
+ assertEq(uint8(secondOrder.status), uint8(LimitOrderTypes.OrderStatus.OPEN), "second order should still be open");
613
+ }
614
+ }
615
+
616
+ /// @notice Malicious contract that attempts reentrancy during token receipt
617
+ contract ReentrancyAttacker {
618
+ IZoraLimitOrderBook public limitOrderBook;
619
+ bytes32 public targetOrderId;
620
+ address public coin;
621
+ bool public attackSucceeded;
622
+ bool public attacked;
623
+
624
+ constructor(IZoraLimitOrderBook _limitOrderBook, bytes32 _targetOrderId, address _coin) {
625
+ limitOrderBook = _limitOrderBook;
626
+ targetOrderId = _targetOrderId;
627
+ coin = _coin;
628
+ }
629
+
630
+ /// @notice Called when receiving ERC20 tokens - attempts reentrancy
631
+ fallback() external payable {
632
+ _tryAttack();
633
+ }
634
+
635
+ receive() external payable {
636
+ _tryAttack();
637
+ }
638
+
639
+ function _tryAttack() internal {
640
+ if (!attacked) {
641
+ attacked = true;
642
+ // Try to withdraw another order during the callback
643
+ bytes32[] memory orderIds = new bytes32[](1);
644
+ orderIds[0] = targetOrderId;
645
+
646
+ try limitOrderBook.withdraw(orderIds, coin, 0, address(this)) {
647
+ attackSucceeded = true;
648
+ } catch {
649
+ attackSucceeded = false;
650
+ }
651
+ }
652
+ }
653
+ }