@zoralabs/limit-orders 0.2.0 → 0.2.2

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 (154) hide show
  1. package/.turbo/turbo-build$colon$js.log +50 -49
  2. package/CHANGELOG.md +73 -0
  3. package/abis/ISetLimitOrderConfig.json +27 -0
  4. package/abis/IWETH.json +118 -0
  5. package/abis/IZoraLimitOrderBook.json +5 -0
  6. package/abis/LimitOrderLiquidity.json +7 -0
  7. package/abis/LimitOrderViews.json +62 -0
  8. package/abis/{SimpleAccessManaged.json → Ownable.json} +29 -10
  9. package/abis/Ownable2Step.json +115 -0
  10. package/abis/PermittedCallers.json +181 -0
  11. package/abis/SwapWithLimitOrders.json +134 -14
  12. package/abis/ZoraLimitOrderBook.json +187 -35
  13. package/cache/solidity-files-cache.json +1 -1
  14. package/dist/index.cjs +219 -34
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.js +219 -34
  17. package/dist/index.js.map +1 -1
  18. package/dist/wagmiGenerated.d.ts +254 -41
  19. package/dist/wagmiGenerated.d.ts.map +1 -1
  20. package/out/BalanceDelta.sol/BalanceDeltaLibrary.json +1 -1
  21. package/out/BeforeSwapDelta.sol/BeforeSwapDeltaLibrary.json +1 -1
  22. package/out/BitMath.sol/BitMath.json +1 -1
  23. package/out/BytesLib.sol/BytesLib.json +1 -1
  24. package/out/CoinCommon.sol/CoinCommon.json +1 -1
  25. package/out/CoinConfigurationVersions.sol/CoinConfigurationVersions.json +1 -1
  26. package/out/CoinConstants.sol/CoinConstants.json +1 -1
  27. package/out/Context.sol/Context.json +1 -1
  28. package/out/Currency.sol/CurrencyLibrary.json +1 -1
  29. package/out/CurrencyReserves.sol/CurrencyReserves.json +1 -1
  30. package/out/CustomRevert.sol/CustomRevert.json +1 -1
  31. package/out/DopplerMath.sol/DopplerMath.json +1 -1
  32. package/out/FixedPoint128.sol/FixedPoint128.json +1 -1
  33. package/out/FixedPoint96.sol/FixedPoint96.json +1 -1
  34. package/out/FullMath.sol/FullMath.json +1 -1
  35. package/out/IAllowanceTransfer.sol/IAllowanceTransfer.json +1 -1
  36. package/out/ICoin.sol/ICoin.json +1 -1
  37. package/out/ICoin.sol/IHasCoinType.json +1 -1
  38. package/out/ICoin.sol/IHasPoolKey.json +1 -1
  39. package/out/ICoin.sol/IHasSwapPath.json +1 -1
  40. package/out/ICoin.sol/IHasTotalSupplyForPositions.json +1 -1
  41. package/out/IDeployedCoinVersionLookup.sol/IDeployedCoinVersionLookup.json +1 -1
  42. package/out/IDopplerErrors.sol/IDopplerErrors.json +1 -1
  43. package/out/IEIP712.sol/IEIP712.json +1 -1
  44. package/out/IERC1363.sol/IERC1363.json +1 -1
  45. package/out/IERC165.sol/IERC165.json +1 -1
  46. package/out/IERC20.sol/IERC20.json +1 -1
  47. package/out/IERC20Minimal.sol/IERC20Minimal.json +1 -1
  48. package/out/IERC6909Claims.sol/IERC6909Claims.json +1 -1
  49. package/out/IERC7572.sol/IERC7572.json +1 -1
  50. package/out/IExtsload.sol/IExtsload.json +1 -1
  51. package/out/IExttload.sol/IExttload.json +1 -1
  52. package/out/IHasRewardsRecipients.sol/IHasRewardsRecipients.json +1 -1
  53. package/out/IHooks.sol/IHooks.json +1 -1
  54. package/out/IMsgSender.sol/IMsgSender.json +1 -1
  55. package/out/IPoolManager.sol/IPoolManager.json +1 -1
  56. package/out/IProtocolFees.sol/IProtocolFees.json +1 -1
  57. package/out/ISetLimitOrderConfig.sol/ISetLimitOrderConfig.json +1 -0
  58. package/out/ISupportsLimitOrderFill.sol/ISupportsLimitOrderFill.json +1 -1
  59. package/out/ISwapPathRouter.sol/ISwapPathRouter.json +1 -1
  60. package/out/ISwapRouter.sol/ISwapRouter.json +1 -1
  61. package/out/IUniswapV3SwapCallback.sol/IUniswapV3SwapCallback.json +1 -1
  62. package/out/IUpgradeableV4Hook.sol/IUpgradeableDestinationV4Hook.json +1 -1
  63. package/out/IUpgradeableV4Hook.sol/IUpgradeableDestinationV4HookWithUpdateableFee.json +1 -1
  64. package/out/IUpgradeableV4Hook.sol/IUpgradeableV4Hook.json +1 -1
  65. package/out/IWETH.sol/IWETH.json +1 -0
  66. package/out/IZoraHookRegistry.sol/IZoraHookRegistry.json +1 -1
  67. package/out/IZoraLimitOrderBook.sol/IZoraLimitOrderBook.json +1 -1
  68. package/out/IZoraLimitOrderBookCoinsInterface.sol/IZoraLimitOrderBookCoinsInterface.json +1 -1
  69. package/out/IZoraV4CoinHook.sol/IZoraV4CoinHook.json +1 -1
  70. package/out/LimitOrderBitmap.sol/LimitOrderBitmap.json +1 -1
  71. package/out/LimitOrderCommon.sol/LimitOrderCommon.json +1 -1
  72. package/out/LimitOrderCreate.sol/LimitOrderCreate.json +1 -1
  73. package/out/LimitOrderFill.sol/LimitOrderFill.json +1 -1
  74. package/out/LimitOrderLiquidity.sol/LimitOrderLiquidity.json +1 -1
  75. package/out/LimitOrderQueues.sol/LimitOrderQueues.json +1 -1
  76. package/out/LimitOrderStorage.sol/LimitOrderStorage.json +1 -1
  77. package/out/LimitOrderTypes.sol/LimitOrderTypes.json +1 -1
  78. package/out/LimitOrderViews.sol/LimitOrderViews.json +1 -0
  79. package/out/LimitOrderWithdraw.sol/LimitOrderWithdraw.json +1 -1
  80. package/out/LiquidityAmounts.sol/LiquidityAmounts.json +1 -1
  81. package/out/LiquidityMath.sol/LiquidityMath.json +1 -1
  82. package/out/Lock.sol/Lock.json +1 -1
  83. package/out/NonzeroDeltaCount.sol/NonzeroDeltaCount.json +1 -1
  84. package/out/Ownable.sol/Ownable.json +1 -0
  85. package/out/Ownable2Step.sol/Ownable2Step.json +1 -0
  86. package/out/Path.sol/Path.json +1 -1
  87. package/out/PathKey.sol/PathKeyLibrary.json +1 -1
  88. package/out/Permit2Payments.sol/Permit2Payments.json +1 -1
  89. package/out/PermittedCallers.sol/PermittedCallers.json +1 -0
  90. package/out/PoolId.sol/PoolIdLibrary.json +1 -1
  91. package/out/Position.sol/Position.json +1 -1
  92. package/out/SafeCast.sol/SafeCast.json +1 -1
  93. package/out/SafeCast160.sol/SafeCast160.json +1 -1
  94. package/out/SafeERC20.sol/SafeERC20.json +1 -1
  95. package/out/SqrtPriceMath.sol/SqrtPriceMath.json +1 -1
  96. package/out/StateLibrary.sol/StateLibrary.json +1 -1
  97. package/out/SwapLimitOrders.sol/SwapLimitOrders.json +1 -1
  98. package/out/SwapWithLimitOrders.sol/SwapWithLimitOrders.json +1 -1
  99. package/out/TickBitmap.sol/TickBitmap.json +1 -1
  100. package/out/TickMath.sol/TickMath.json +1 -1
  101. package/out/TransientSlot.sol/TransientSlot.json +1 -1
  102. package/out/TransientStateLibrary.sol/TransientStateLibrary.json +1 -1
  103. package/out/UniV4SwapToCurrency.sol/UniV4SwapToCurrency.json +1 -1
  104. package/out/UnsafeMath.sol/UnsafeMath.json +1 -1
  105. package/out/V3ToV4SwapLib.sol/V3ToV4SwapLib.json +1 -1
  106. package/out/ZoraLimitOrderBook.sol/ZoraLimitOrderBook.json +1 -1
  107. package/out/build-info/37e0124d88d60569.json +1 -0
  108. package/out/uniswap/BitMath.sol/BitMath.json +1 -1
  109. package/out/uniswap/CustomRevert.sol/CustomRevert.json +1 -1
  110. package/out/uniswap/FullMath.sol/FullMath.json +1 -1
  111. package/out/uniswap/SafeCast.sol/SafeCast.json +1 -1
  112. package/out/uniswap/TickMath.sol/TickMath.json +1 -1
  113. package/package/wagmiGenerated.ts +218 -33
  114. package/package.json +1 -1
  115. package/src/IZoraLimitOrderBook.sol +5 -5
  116. package/src/ZoraLimitOrderBook.sol +24 -41
  117. package/src/access/PermittedCallers.sol +41 -0
  118. package/src/libs/LimitOrderBitmap.sol +0 -51
  119. package/src/libs/LimitOrderCommon.sol +48 -30
  120. package/src/libs/LimitOrderCreate.sol +5 -18
  121. package/src/libs/LimitOrderFill.sol +32 -161
  122. package/src/libs/LimitOrderLiquidity.sol +92 -71
  123. package/src/libs/LimitOrderViews.sol +168 -0
  124. package/src/libs/LimitOrderWithdraw.sol +13 -4
  125. package/src/libs/SwapLimitOrders.sol +14 -7
  126. package/src/router/ISetLimitOrderConfig.sol +12 -0
  127. package/src/router/SwapWithLimitOrders.sol +46 -33
  128. package/test/LimitOrderAccessControl.t.sol +173 -156
  129. package/test/LimitOrderBitmap.t.sol +13 -7
  130. package/test/LimitOrderFill.t.sol +42 -4
  131. package/test/LimitOrderLibraries.t.sol +18 -10
  132. package/test/LimitOrderLiquidityPayouts.t.sol +280 -3
  133. package/test/LimitOrderWithdraw.t.sol +28 -1
  134. package/test/SwapWithLimitOrders.t.sol +3 -5
  135. package/test/SwapWithLimitOrdersRouter.t.sol +108 -13
  136. package/test/gas/LimitOrderFillGas.t.sol +0 -7
  137. package/test/gas/LimitOrderSwapGas.t.sol +0 -6
  138. package/test/unit/LimitOrderBitmapUnit.t.sol +0 -134
  139. package/test/unit/LimitOrderCreateUnit.t.sol +32 -0
  140. package/test/unit/SwapLimitOrdersUnit.t.sol +231 -33
  141. package/test/unit/SwapLimitOrdersValidation.t.sol +28 -42
  142. package/test/unit/SwapWithLimitOrdersUnit.t.sol +21 -88
  143. package/test/utils/BaseTest.sol +34 -22
  144. package/test/utils/MockWETH.sol +39 -0
  145. package/test/utils/TestableZoraLimitOrderBook.sol +5 -7
  146. package/abis/IAuthority.json +0 -31
  147. package/abis/SimpleAccessManager.json +0 -351
  148. package/out/IAuthority.sol/IAuthority.json +0 -1
  149. package/out/SimpleAccessManaged.sol/SimpleAccessManaged.json +0 -1
  150. package/out/SimpleAccessManager.sol/SimpleAccessManager.json +0 -1
  151. package/out/build-info/69718f10d1dc37f0.json +0 -1
  152. package/src/access/SimpleAccessManaged.sol +0 -76
  153. package/src/access/SimpleAccessManager.sol +0 -268
  154. package/test/SimpleAccessManager.t.sol +0 -420
@@ -67,7 +67,8 @@ contract LimitOrderLibrariesTest is BaseTest {
67
67
  assertEq(created.length, 3, "should create three orders");
68
68
 
69
69
  // Verify tick queue linked list structure
70
- QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, tick);
70
+ int24 fillableTick = _fillableTick(isCurrency0, tick, key.tickSpacing);
71
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, fillableTick);
71
72
  assertEq(tickQueue.length, 3, "queue length should be 3");
72
73
  assertEq(tickQueue.head, created[0].orderId, "head should be first order");
73
74
  assertEq(tickQueue.tail, created[2].orderId, "tail should be last order");
@@ -108,7 +109,8 @@ contract LimitOrderLibrariesTest is BaseTest {
108
109
  vm.prank(users.seller);
109
110
  limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
110
111
 
111
- QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, tick);
112
+ int24 fillableTick = _fillableTick(isCurrency0, tick, key.tickSpacing);
113
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, fillableTick);
112
114
  assertEq(tickQueue.length, 2, "queue length should be 2");
113
115
  assertEq(tickQueue.head, created[1].orderId, "head should be second order");
114
116
  assertEq(tickQueue.tail, created[2].orderId, "tail unchanged");
@@ -149,7 +151,8 @@ contract LimitOrderLibrariesTest is BaseTest {
149
151
  vm.prank(users.seller);
150
152
  limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
151
153
 
152
- QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, tick);
154
+ int24 fillableTick = _fillableTick(isCurrency0, tick, key.tickSpacing);
155
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, fillableTick);
153
156
  assertEq(tickQueue.length, 2, "queue length should be 2");
154
157
  assertEq(tickQueue.head, created[0].orderId, "head unchanged");
155
158
  assertEq(tickQueue.tail, created[1].orderId, "tail should be second order");
@@ -190,7 +193,8 @@ contract LimitOrderLibrariesTest is BaseTest {
190
193
  vm.prank(users.seller);
191
194
  limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
192
195
 
193
- QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, tick);
196
+ int24 fillableTick = _fillableTick(isCurrency0, tick, key.tickSpacing);
197
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(created[0].poolKeyHash, orderCoin, fillableTick);
194
198
  assertEq(tickQueue.length, 2, "queue length should be 2");
195
199
  assertEq(tickQueue.head, created[0].orderId, "head unchanged");
196
200
  assertEq(tickQueue.tail, created[2].orderId, "tail unchanged");
@@ -237,7 +241,8 @@ contract LimitOrderLibrariesTest is BaseTest {
237
241
 
238
242
  // Verify bitmap is set
239
243
  bytes32 poolKeyHash = keccak256(abi.encode(key));
240
- assertTrue(_isTickInitialized(poolKeyHash, orderCoin, orderTicks[0], key.tickSpacing), "tick should be initialized");
244
+ int24 fillableTick = _fillableTick(isCurrency0, orderTicks[0], key.tickSpacing);
245
+ assertTrue(_isTickInitialized(poolKeyHash, orderCoin, fillableTick, key.tickSpacing), "tick should be initialized");
241
246
  }
242
247
 
243
248
  function test_bitmapSetIfFirstWhenNonEmpty() public {
@@ -262,9 +267,10 @@ contract LimitOrderLibrariesTest is BaseTest {
262
267
 
263
268
  // Verify bitmap is still set (second enqueue didn't break it)
264
269
  bytes32 poolKeyHash = keccak256(abi.encode(key));
265
- assertTrue(_isTickInitialized(poolKeyHash, orderCoin, tick, key.tickSpacing), "tick should be initialized");
270
+ int24 fillableTick = _fillableTick(isCurrency0, tick, key.tickSpacing);
271
+ assertTrue(_isTickInitialized(poolKeyHash, orderCoin, fillableTick, key.tickSpacing), "tick should be initialized");
266
272
 
267
- QueueSnapshot memory tickQueue = _tickQueueSnapshot(poolKeyHash, orderCoin, tick);
273
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(poolKeyHash, orderCoin, fillableTick);
268
274
  assertEq(tickQueue.length, 2, "should have 2 orders at same tick");
269
275
  }
270
276
 
@@ -290,7 +296,8 @@ contract LimitOrderLibrariesTest is BaseTest {
290
296
  vm.prank(users.seller);
291
297
  limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
292
298
 
293
- assertFalse(_isTickInitialized(poolKeyHash, orderCoin, orderTicks[0], key.tickSpacing), "tick should be cleared");
299
+ int24 fillableTick = _fillableTick(isCurrency0, orderTicks[0], key.tickSpacing);
300
+ assertFalse(_isTickInitialized(poolKeyHash, orderCoin, fillableTick, key.tickSpacing), "tick should be cleared");
294
301
  }
295
302
 
296
303
  function test_bitmapClearIfEmptyWhenStillHasOrders() public {
@@ -323,9 +330,10 @@ contract LimitOrderLibrariesTest is BaseTest {
323
330
  vm.prank(users.seller);
324
331
  limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
325
332
 
326
- assertTrue(_isTickInitialized(poolKeyHash, orderCoin, tick, key.tickSpacing), "tick should still be initialized");
333
+ int24 fillableTick = _fillableTick(isCurrency0, tick, key.tickSpacing);
334
+ assertTrue(_isTickInitialized(poolKeyHash, orderCoin, fillableTick, key.tickSpacing), "tick should still be initialized");
327
335
 
328
- QueueSnapshot memory tickQueue = _tickQueueSnapshot(poolKeyHash, orderCoin, tick);
336
+ QueueSnapshot memory tickQueue = _tickQueueSnapshot(poolKeyHash, orderCoin, fillableTick);
329
337
  assertEq(tickQueue.length, 1, "should have 1 order remaining");
330
338
  }
331
339
 
@@ -19,6 +19,7 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
19
19
  import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
20
20
 
21
21
  import {SimpleERC20} from "@zoralabs/coins/test/mocks/SimpleERC20.sol";
22
+ import {MockWETH} from "./utils/MockWETH.sol";
22
23
 
23
24
  contract MockPoolManager {
24
25
  using CurrencyLibrary for Currency;
@@ -29,8 +30,12 @@ contract MockPoolManager {
29
30
 
30
31
  uint256 public swapCalls;
31
32
  uint256 public takeCalls;
33
+ uint256 public syncCalls;
34
+ uint256 public settleCalls;
32
35
  Currency public lastTakeCurrency;
33
36
  uint256 public lastTakeAmount;
37
+ Currency public lastSyncCurrency;
38
+ uint256 public lastSettleValue;
34
39
 
35
40
  function setModifyLiquidityResponse(int128 amount0, int128 amount1, int128 fee0, int128 fee1) external {
36
41
  liquidityDelta = toBalanceDelta(amount0, amount1);
@@ -63,9 +68,14 @@ contract MockPoolManager {
63
68
  }
64
69
  }
65
70
 
66
- function sync(Currency) external {}
71
+ function sync(Currency currency) external {
72
+ ++syncCalls;
73
+ lastSyncCurrency = currency;
74
+ }
67
75
 
68
76
  function settle() external payable returns (uint256) {
77
+ ++settleCalls;
78
+ lastSettleValue = msg.value;
69
79
  return msg.value;
70
80
  }
71
81
 
@@ -74,6 +84,11 @@ contract MockPoolManager {
74
84
 
75
85
  contract LimitOrderLiquidityHarness {
76
86
  LimitOrderTypes.LimitOrder internal order;
87
+ address internal weth;
88
+
89
+ function setWeth(address weth_) external {
90
+ weth = weth_;
91
+ }
77
92
 
78
93
  function configureOrder(address maker, bool isCurrency0, int24 tickLower, int24 tickUpper, uint128 liquidity) external {
79
94
  order.maker = maker;
@@ -99,7 +114,7 @@ contract LimitOrderLiquidityHarness {
99
114
  address coinIn,
100
115
  IDeployedCoinVersionLookup versionLookup
101
116
  ) external returns (Currency coinOut, uint128 makerAmount, uint128 referralAmount) {
102
- return LimitOrderLiquidity.burnAndPayout(IPoolManager(address(poolManager)), key, order, orderId, feeRecipient, coinIn, versionLookup);
117
+ return LimitOrderLiquidity.burnAndPayout(IPoolManager(address(poolManager)), key, order, orderId, feeRecipient, coinIn, versionLookup, weth);
103
118
  }
104
119
 
105
120
  function burnAndRefund(MockPoolManager poolManager, PoolKey memory key, bytes32 orderId, address recipient) external returns (uint128 amountOut) {
@@ -112,13 +127,16 @@ contract LimitOrderLiquidityHarness {
112
127
  order.liquidity,
113
128
  orderId,
114
129
  recipient,
115
- order.isCurrency0
130
+ order.isCurrency0,
131
+ weth
116
132
  );
117
133
  }
118
134
 
119
135
  function refundResidual(PoolKey memory key, bool isCurrency0, address maker, uint128 amount) external {
120
136
  LimitOrderLiquidity.refundResidual(key, isCurrency0, maker, amount);
121
137
  }
138
+
139
+ receive() external payable {}
122
140
  }
123
141
 
124
142
  contract MockCoinVersionLookup is IDeployedCoinVersionLookup {
@@ -167,6 +185,7 @@ contract LimitOrderLiquidityPayoutsTest is Test {
167
185
  MockCoinVersionLookup internal versionLookup;
168
186
  SimpleERC20 internal currency0Token;
169
187
  SimpleERC20 internal currency1Token;
188
+ MockWETH internal weth;
170
189
  PoolKey internal poolKey;
171
190
  address internal maker;
172
191
 
@@ -179,6 +198,8 @@ contract LimitOrderLiquidityPayoutsTest is Test {
179
198
 
180
199
  currency0Token = new SimpleERC20("Token0", "TK0");
181
200
  currency1Token = new SimpleERC20("Token1", "TK1");
201
+ weth = new MockWETH();
202
+ harness.setWeth(address(weth));
182
203
 
183
204
  poolKey = PoolKey({
184
205
  currency0: Currency.wrap(address(currency0Token)),
@@ -249,6 +270,20 @@ contract LimitOrderLiquidityPayoutsTest is Test {
249
270
  assertEq(Currency.unwrap(poolManager.lastTakeCurrency()), address(currency1Token));
250
271
  }
251
272
 
273
+ function test_burnAndRefundPaysBothCurrenciesWhenPositive() public {
274
+ poolManager.setModifyLiquidityResponse(int128(10), int128(20), 0, 0);
275
+ deal(address(currency0Token), address(poolManager), 100e18);
276
+ deal(address(currency1Token), address(poolManager), 100e18);
277
+ address recipient = makeAddr("dual-recipient");
278
+
279
+ uint128 amountOut = harness.burnAndRefund(poolManager, poolKey, ORDER_ID, recipient);
280
+
281
+ assertEq(amountOut, 10, "amountOut should match order currency payout");
282
+ assertEq(currency0Token.balanceOf(recipient), 10, "recipient receives currency0");
283
+ assertEq(currency1Token.balanceOf(recipient), 20, "recipient receives currency1");
284
+ assertEq(poolManager.takeCalls(), 2, "both currencies should be taken");
285
+ }
286
+
252
287
  function test_burnAndPayoutWithoutReferralRoutesAllProceeds() public {
253
288
  poolManager.setModifyLiquidityResponse(0, int128(120), 0, 0);
254
289
  deal(address(currency1Token), address(poolManager), 200e18);
@@ -269,6 +304,52 @@ contract LimitOrderLiquidityPayoutsTest is Test {
269
304
  assertEq(poolManager.takeCalls(), 1, "single take call expected");
270
305
  }
271
306
 
307
+ function test_burnAndPayoutPaysWethForNativePayouts() public {
308
+ PoolKey memory nativeKey = PoolKey({
309
+ currency0: CurrencyLibrary.ADDRESS_ZERO,
310
+ currency1: Currency.wrap(address(currency1Token)),
311
+ fee: 3000,
312
+ tickSpacing: 60,
313
+ hooks: IHooks(address(0))
314
+ });
315
+
316
+ harness.setOrderSide(false);
317
+ poolManager.setModifyLiquidityResponse(int128(50), 0, 0, 0);
318
+ vm.deal(address(poolManager), 1 ether);
319
+
320
+ uint256 wethBefore = weth.balanceOf(maker);
321
+ (, uint128 makerAmount, ) = harness.burnAndPayout(poolManager, nativeKey, ORDER_ID, address(0), address(currency1Token), versionLookup);
322
+
323
+ assertEq(makerAmount, 50, "maker payout should match native delta");
324
+ assertEq(weth.balanceOf(maker), wethBefore + 50, "maker should receive WETH");
325
+ assertEq(maker.balance, 0, "maker should not receive native ETH");
326
+ }
327
+
328
+ function test_burnAndPayoutPaysBothCurrenciesWhenPositive() public {
329
+ // This test now verifies that when both deltas are positive,
330
+ // only ONE currency is paid out (the payout currency) after swapping
331
+ poolManager.setModifyLiquidityResponse(int128(15), int128(25), 0, 0);
332
+
333
+ // Simulate swap: 15 of currency0 swaps to get some currency1
334
+ // (mock doesn't track amounts, just that swap occurred)
335
+ poolManager.setSwapResponse(0, int128(0));
336
+
337
+ deal(address(currency0Token), address(poolManager), 100e18);
338
+ deal(address(currency1Token), address(poolManager), 100e18);
339
+
340
+ uint256 makerCurrency0Before = currency0Token.balanceOf(maker);
341
+ uint256 makerCurrency1Before = currency1Token.balanceOf(maker);
342
+
343
+ (, uint128 makerAmount, ) = harness.burnAndPayout(poolManager, poolKey, ORDER_ID, address(0), address(currency0Token), versionLookup);
344
+
345
+ // With the fix: only currency1 is paid out (NOT currency0)
346
+ assertEq(currency0Token.balanceOf(maker), makerCurrency0Before, "maker should NOT receive currency0");
347
+ assertGt(currency1Token.balanceOf(maker), makerCurrency1Before, "maker should receive currency1");
348
+
349
+ // Verify a swap occurred to convert currency0 to currency1
350
+ assertEq(poolManager.swapCalls(), 1, "swap should occur to consolidate to single currency");
351
+ }
352
+
272
353
  function test_burnAndPayoutSplitsReferralShares() public {
273
354
  poolManager.setModifyLiquidityResponse(0, int128(150), 0, int128(30));
274
355
  deal(address(currency1Token), address(poolManager), 300e18);
@@ -330,4 +411,200 @@ contract LimitOrderLiquidityPayoutsTest is Test {
330
411
  assertEq(poolManager.swapCalls(), 1, "swap path should execute");
331
412
  assertEq(currency1Token.balanceOf(maker), makerBefore + 40, "maker receives swapped currency");
332
413
  }
414
+
415
+ /// @notice Test that with dual positive deltas, only currency1 (payout currency) is paid out
416
+ /// @dev This simulates the audit bug scenario where positions have fees in both tokens
417
+ function test_dualPositiveDeltas_payoutsCurrency1Only() public {
418
+ // Order is selling currency0, so payout currency is currency1
419
+ harness.setOrderSide(true); // isCurrency0 = true
420
+
421
+ // Simulate dual positive deltas: both amount0 and amount1 are positive
422
+ // This happens when a position is crossed in both directions
423
+ poolManager.setModifyLiquidityResponse(int128(50), int128(100), 0, 0);
424
+
425
+ // Simulate swap: swap 50 of currency0 to get 45 of currency1
426
+ poolManager.setSwapResponse(0, int128(45));
427
+
428
+ // Fund pool manager with both currencies
429
+ deal(address(currency0Token), address(poolManager), 500e18);
430
+ deal(address(currency1Token), address(poolManager), 500e18);
431
+
432
+ uint256 maker0Before = currency0Token.balanceOf(maker);
433
+ uint256 maker1Before = currency1Token.balanceOf(maker);
434
+
435
+ (Currency coinOut, uint128 makerAmount, ) = harness.burnAndPayout(poolManager, poolKey, ORDER_ID, address(0), address(0), versionLookup);
436
+
437
+ // Verify only currency1 is paid out (NOT currency0)
438
+ assertEq(Currency.unwrap(coinOut), address(currency1Token), "payout currency should be currency1");
439
+ assertEq(currency0Token.balanceOf(maker), maker0Before, "maker should NOT receive currency0");
440
+
441
+ // Verify total payout in currency1 includes both the original amount1 + swapped amount0
442
+ // Expected: 100 (original currency1) + 45 (swapped from currency0) = 145
443
+ assertEq(currency1Token.balanceOf(maker), maker1Before + 145, "maker should receive combined amount in currency1");
444
+ assertEq(makerAmount, 145, "makerAmount should be combined total");
445
+
446
+ // Verify a swap occurred to convert currency0 to currency1
447
+ assertEq(poolManager.swapCalls(), 1, "swap should occur to convert currency0 to currency1");
448
+ }
449
+
450
+ /// @notice Test that with dual positive deltas, only currency0 (payout currency) is paid out
451
+ /// @dev This tests the opposite direction - selling currency1, expecting currency0 payout
452
+ function test_dualPositiveDeltas_payoutsCurrency0Only() public {
453
+ // Order is selling currency1, so payout currency is currency0
454
+ harness.setOrderSide(false); // isCurrency0 = false
455
+
456
+ // Simulate dual positive deltas
457
+ poolManager.setModifyLiquidityResponse(int128(80), int128(60), 0, 0);
458
+
459
+ // Simulate swap: swap 60 of currency1 to get 55 of currency0
460
+ poolManager.setSwapResponse(int128(55), 0);
461
+
462
+ // Fund pool manager
463
+ deal(address(currency0Token), address(poolManager), 500e18);
464
+ deal(address(currency1Token), address(poolManager), 500e18);
465
+
466
+ uint256 maker0Before = currency0Token.balanceOf(maker);
467
+ uint256 maker1Before = currency1Token.balanceOf(maker);
468
+
469
+ (Currency coinOut, uint128 makerAmount, ) = harness.burnAndPayout(poolManager, poolKey, ORDER_ID, address(0), address(0), versionLookup);
470
+
471
+ // Verify only currency0 is paid out (NOT currency1)
472
+ assertEq(Currency.unwrap(coinOut), address(currency0Token), "payout currency should be currency0");
473
+ assertEq(currency1Token.balanceOf(maker), maker1Before, "maker should NOT receive currency1");
474
+
475
+ // Verify total payout in currency0 includes both the original amount0 + swapped amount1
476
+ // Expected: 80 (original currency0) + 55 (swapped from currency1) = 135
477
+ assertEq(currency0Token.balanceOf(maker), maker0Before + 135, "maker should receive combined amount in currency0");
478
+ assertEq(makerAmount, 135, "makerAmount should be combined total");
479
+
480
+ // Verify a swap occurred
481
+ assertEq(poolManager.swapCalls(), 1, "swap should occur to convert currency1 to currency0");
482
+ }
483
+
484
+ /// @notice Test that with dual positive deltas and referral fees, both maker and referral receive single currency
485
+ function test_dualPositiveDeltas_makerAndReferral_singleCurrencyPayout() public {
486
+ address referral = makeAddr("referral");
487
+ harness.setOrderSide(true); // isCurrency0 = true, payout in currency1
488
+
489
+ // Simulate dual positive deltas with fees for referral
490
+ // liquidity: (60 amount0, 120 amount1), fees: (20 amount0, 30 amount1)
491
+ poolManager.setModifyLiquidityResponse(int128(60), int128(120), int128(20), int128(30));
492
+
493
+ // Simulate swaps for both maker and referral portions
494
+ // First swap (maker): swap 40 of currency0 to get 35 of currency1
495
+ // Second swap (referral): swap 20 of currency0 to get 18 of currency1
496
+ poolManager.setSwapResponse(0, int128(35));
497
+
498
+ deal(address(currency0Token), address(poolManager), 500e18);
499
+ deal(address(currency1Token), address(poolManager), 500e18);
500
+
501
+ uint256 maker0Before = currency0Token.balanceOf(maker);
502
+ uint256 maker1Before = currency1Token.balanceOf(maker);
503
+ uint256 ref0Before = currency0Token.balanceOf(referral);
504
+ uint256 ref1Before = currency1Token.balanceOf(referral);
505
+
506
+ (Currency makerCoinOut, uint128 makerAmount, uint128 referralAmount) = harness.burnAndPayout(
507
+ poolManager,
508
+ poolKey,
509
+ ORDER_ID,
510
+ referral,
511
+ address(0),
512
+ versionLookup
513
+ );
514
+
515
+ // Verify maker receives only currency1
516
+ assertEq(Currency.unwrap(makerCoinOut), address(currency1Token), "maker payout should be currency1");
517
+ assertEq(currency0Token.balanceOf(maker), maker0Before, "maker should NOT receive currency0");
518
+
519
+ // Verify referral receives only currency1
520
+ assertEq(currency0Token.balanceOf(referral), ref0Before, "referral should NOT receive currency0");
521
+
522
+ // Both should have increased currency1 balances
523
+ assertGt(currency1Token.balanceOf(maker), maker1Before, "maker should receive currency1");
524
+ assertGt(currency1Token.balanceOf(referral), ref1Before, "referral should receive currency1");
525
+
526
+ // Verify swaps occurred (one for maker, one for referral)
527
+ assertEq(poolManager.swapCalls(), 2, "two swaps should occur (maker + referral)");
528
+ }
529
+
530
+ function test_burnAndPayoutBypassesSwapPathWhenPayoutCurrencyMismatches() public {
531
+ TestSwapPathCoin swapCoin = new TestSwapPathCoin(Currency.wrap(address(currency0Token)));
532
+ versionLookup.setVersion(address(swapCoin), 4);
533
+
534
+ poolManager.setModifyLiquidityResponse(int128(22), 0, 0, 0);
535
+ poolManager.setSwapResponse(0, int128(22));
536
+
537
+ deal(address(currency0Token), address(poolManager), 200e18);
538
+ deal(address(currency1Token), address(poolManager), 200e18);
539
+
540
+ uint256 makerBefore = currency1Token.balanceOf(maker);
541
+
542
+ harness.burnAndPayout(poolManager, poolKey, ORDER_ID, address(0), address(swapCoin), versionLookup);
543
+
544
+ assertEq(poolManager.swapCalls(), 1, "swap should occur using fallback single-hop path");
545
+ assertEq(currency1Token.balanceOf(maker), makerBefore + 22, "maker receives currency1 from swap");
546
+ }
547
+
548
+ /// @notice MKT-27: Documents that wrong coin is passed but validation provides safety net
549
+ /// @dev Demonstrates the root cause bug in _fillOrder():
550
+ /// - _fillOrder() passes INPUT coin (CoinA) to burnAndPayout
551
+ /// - Should pass OUTPUT coin (WETH) instead
552
+ /// - Validation in _resolvePayoutPath() catches the mismatch and falls back
553
+ /// - Result: User correctly receives WETH (due to validation, not correct logic)
554
+ ///
555
+ /// Scenario: Pool CoinA/WETH, CoinA has path to USDC, Order: Sell CoinA for WETH
556
+ /// Root bug: Looks at CoinA's path (wrong coin!)
557
+ /// Safety net: Validation sees path leads to USDC not WETH, falls back
558
+ ///
559
+ /// This test verifies the validation works, but we should still fix the root cause.
560
+ function test_burnAndPayoutUsesWrongCoinPayoutPath_MKT27() public {
561
+ // Create CoinA with payout path to USDC (currency0Token)
562
+ // This simulates a coin whose payout path leads to unexpected currency
563
+ TestSwapPathCoin coinA = new TestSwapPathCoin(Currency.wrap(address(currency0Token))); // path to USDC
564
+ versionLookup.setVersion(address(coinA), 4);
565
+
566
+ // Create pool: CoinA / WETH (currency1Token)
567
+ PoolKey memory testPoolKey = PoolKey({
568
+ currency0: Currency.wrap(address(coinA)), // CoinA
569
+ currency1: Currency.wrap(address(currency1Token)), // WETH
570
+ fee: 3000,
571
+ tickSpacing: 60,
572
+ hooks: poolKey.hooks
573
+ });
574
+
575
+ // Order: Sell CoinA (currency0) for WETH (currency1)
576
+ harness.setOrderSide(true); // isCurrency0 = true → payout should be currency1 (WETH)
577
+
578
+ // Simulate liquidity return: 100 units returned
579
+ poolManager.setModifyLiquidityResponse(int128(100), 0, 0, 0);
580
+
581
+ // Simulate fallback swap (validation rejects CoinA's path, uses single-hop)
582
+ poolManager.setSwapResponse(0, int128(100));
583
+
584
+ // Fund pool manager
585
+ deal(address(currency0Token), address(poolManager), 500e18); // USDC
586
+ deal(address(currency1Token), address(poolManager), 500e18); // WETH
587
+
588
+ uint256 makerWethBefore = currency1Token.balanceOf(maker);
589
+
590
+ // ROOT CAUSE BUG: Pass CoinA (input coin) - this is what _fillOrder() does
591
+ // SHOULD PASS: WETH (output coin) - this is what it should do
592
+ (Currency coinOut, , ) = harness.burnAndPayout(
593
+ poolManager,
594
+ testPoolKey,
595
+ ORDER_ID,
596
+ address(0),
597
+ address(coinA), // ← ROOT BUG: passing wrong coin
598
+ versionLookup
599
+ );
600
+
601
+ // VALIDATION SAFETY NET WORKS: User receives correct currency (WETH)
602
+ // Even though we passed wrong coin, validation caught it and fell back
603
+ assertEq(Currency.unwrap(coinOut), address(currency1Token), "validation correctly falls back to WETH");
604
+ assertEq(currency1Token.balanceOf(maker), makerWethBefore + 100, "maker receives WETH (correct)");
605
+
606
+ // NOTE: While validation prevents harm, _fillOrder() should still be fixed to pass
607
+ // the OUTPUT coin, not INPUT coin. This makes the code semantically correct and
608
+ // doesn't rely on validation as a crutch.
609
+ }
333
610
  }
@@ -54,6 +54,33 @@ contract LimitOrderWithdrawTest is BaseTest {
54
54
  }
55
55
  }
56
56
 
57
+ /// @notice Fillable orders must be filled, not withdrawn
58
+ function test_withdrawRevertsForFillableOrder() public {
59
+ PoolKey memory key = creatorCoin.getPoolKey();
60
+ bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
61
+ address orderCoin = _orderCoin(key, isCurrency0);
62
+
63
+ // Create orders directly
64
+ (uint256[] memory orderSizes, int24[] memory orderTicks) = _buildDeterministicOrders(key, isCurrency0, 1, 25e18);
65
+ uint256 totalSize = orderSizes[0];
66
+ _fundAndApprove(users.seller, orderCoin, totalSize);
67
+
68
+ vm.recordLogs();
69
+ vm.prank(users.seller);
70
+ limitOrderBook.create{value: orderCoin == address(0) ? totalSize : 0}(key, isCurrency0, orderSizes, orderTicks, users.seller);
71
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
72
+ assertEq(created.length, 1, "expected 1 order");
73
+
74
+ // Move price past order (makes it fillable)
75
+ _movePriceBeyondTicksWithAutoFillDisabled(created);
76
+
77
+ bytes32[] memory orderIds = _orderIds(created);
78
+
79
+ vm.expectRevert(IZoraLimitOrderBook.OrderFillable.selector);
80
+ vm.prank(users.seller);
81
+ limitOrderBook.withdraw(orderIds, orderCoin, 0, users.seller);
82
+ }
83
+
57
84
  function test_withdrawOrdersRevertsForMixedCoins() public {
58
85
  PoolKey memory creatorKey = creatorCoin.getPoolKey();
59
86
  PoolKey memory contentKey = contentCoin.getPoolKey();
@@ -322,7 +349,7 @@ contract LimitOrderWithdrawTest is BaseTest {
322
349
 
323
350
  CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
324
351
  bytes32 poolKeyHash = created[0].poolKeyHash;
325
- int24 tick = ticks[0];
352
+ int24 tick = _fillableTick(isCurrency0, ticks[0], key.tickSpacing);
326
353
 
327
354
  // Verify bitmap set
328
355
  assertTrue(_isTickInitialized(poolKeyHash, orderCoin, tick, key.tickSpacing), "tick should be initialized");
@@ -9,17 +9,15 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
9
9
  import {Vm} from "forge-std/Vm.sol";
10
10
 
11
11
  contract SwapWithLimitOrdersTest is BaseTest {
12
- function test_autosellSkipsDustPurchases() public {
12
+ function test_autosellCreatesOrdersForSmallPurchases() public {
13
13
  PoolKey memory key = creatorCoin.getPoolKey();
14
- bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
15
- address orderCoin = _orderCoin(key, isCurrency0);
16
14
 
17
15
  vm.recordLogs();
18
16
  _executeSingleHopSwapWithLimitOrders(users.buyer, key, 1e13, _defaultMultiples(), _defaultPercentages());
19
17
  CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
20
18
 
21
- assertEq(created.length, 0, "dust swap should not create orders");
22
- assertEq(_makerBalance(users.buyer, orderCoin), 0, "book balance should remain zero");
19
+ // Small purchases create orders (no minimum threshold)
20
+ assertGt(created.length, 0, "small swap should create orders");
23
21
  }
24
22
 
25
23
  function test_multiHopAutosellCreatesOrdersOnlyOnLastPool() public {