@zoralabs/limit-orders 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.turbo/turbo-build$colon$js.log +47 -45
  2. package/CHANGELOG.md +61 -0
  3. package/abis/IWETH.json +118 -0
  4. package/abis/IZoraLimitOrderBook.json +5 -0
  5. package/abis/LimitOrderLiquidity.json +7 -0
  6. package/abis/LimitOrderViews.json +62 -0
  7. package/abis/SwapWithLimitOrders.json +18 -11
  8. package/abis/ZoraLimitOrderBook.json +28 -0
  9. package/cache/solidity-files-cache.json +1 -1
  10. package/dist/index.cjs +29 -8
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.js +29 -8
  13. package/dist/index.js.map +1 -1
  14. package/dist/wagmiGenerated.d.ts +37 -9
  15. package/dist/wagmiGenerated.d.ts.map +1 -1
  16. package/out/BytesLib.sol/BytesLib.json +1 -1
  17. package/out/CoinCommon.sol/CoinCommon.json +1 -1
  18. package/out/CoinConfigurationVersions.sol/CoinConfigurationVersions.json +1 -1
  19. package/out/CoinConstants.sol/CoinConstants.json +1 -1
  20. package/out/DopplerMath.sol/DopplerMath.json +1 -1
  21. package/out/FixedPoint96.sol/FixedPoint96.json +1 -1
  22. package/out/ISwapRouter.sol/ISwapRouter.json +1 -1
  23. package/out/IUniswapV3SwapCallback.sol/IUniswapV3SwapCallback.json +1 -1
  24. package/out/IWETH.sol/IWETH.json +1 -0
  25. package/out/IZoraHookRegistry.sol/IZoraHookRegistry.json +1 -1
  26. package/out/IZoraLimitOrderBook.sol/IZoraLimitOrderBook.json +1 -1
  27. package/out/IZoraLimitOrderBookCoinsInterface.sol/IZoraLimitOrderBookCoinsInterface.json +1 -1
  28. package/out/IZoraV4CoinHook.sol/IZoraV4CoinHook.json +1 -1
  29. package/out/LimitOrderBitmap.sol/LimitOrderBitmap.json +1 -1
  30. package/out/LimitOrderCommon.sol/LimitOrderCommon.json +1 -1
  31. package/out/LimitOrderCreate.sol/LimitOrderCreate.json +1 -1
  32. package/out/LimitOrderFill.sol/LimitOrderFill.json +1 -1
  33. package/out/LimitOrderLiquidity.sol/LimitOrderLiquidity.json +1 -1
  34. package/out/LimitOrderQueues.sol/LimitOrderQueues.json +1 -1
  35. package/out/LimitOrderStorage.sol/LimitOrderStorage.json +1 -1
  36. package/out/LimitOrderTypes.sol/LimitOrderTypes.json +1 -1
  37. package/out/LimitOrderViews.sol/LimitOrderViews.json +1 -0
  38. package/out/LimitOrderWithdraw.sol/LimitOrderWithdraw.json +1 -1
  39. package/out/LiquidityAmounts.sol/LiquidityAmounts.json +1 -1
  40. package/out/Path.sol/Path.json +1 -1
  41. package/out/Permit2Payments.sol/Permit2Payments.json +1 -1
  42. package/out/SimpleAccessManaged.sol/SimpleAccessManaged.json +1 -1
  43. package/out/SimpleAccessManager.sol/SimpleAccessManager.json +1 -1
  44. package/out/SqrtPriceMath.sol/SqrtPriceMath.json +1 -1
  45. package/out/SwapLimitOrders.sol/SwapLimitOrders.json +1 -1
  46. package/out/SwapWithLimitOrders.sol/SwapWithLimitOrders.json +1 -1
  47. package/out/UniV4SwapToCurrency.sol/UniV4SwapToCurrency.json +1 -1
  48. package/out/UnsafeMath.sol/UnsafeMath.json +1 -1
  49. package/out/V3ToV4SwapLib.sol/V3ToV4SwapLib.json +1 -1
  50. package/out/ZoraLimitOrderBook.sol/ZoraLimitOrderBook.json +1 -1
  51. package/out/build-info/{69718f10d1dc37f0.json → 876cc09bc44cc8a7.json} +1 -1
  52. package/out/uniswap/BitMath.sol/BitMath.json +1 -1
  53. package/out/uniswap/CustomRevert.sol/CustomRevert.json +1 -1
  54. package/out/uniswap/FullMath.sol/FullMath.json +1 -1
  55. package/out/uniswap/SafeCast.sol/SafeCast.json +1 -1
  56. package/out/uniswap/TickMath.sol/TickMath.json +1 -1
  57. package/package/wagmiGenerated.ts +28 -7
  58. package/package.json +1 -1
  59. package/src/IZoraLimitOrderBook.sol +2 -0
  60. package/src/ZoraLimitOrderBook.sol +22 -8
  61. package/src/libs/LimitOrderBitmap.sol +0 -51
  62. package/src/libs/LimitOrderCommon.sol +48 -30
  63. package/src/libs/LimitOrderCreate.sol +5 -18
  64. package/src/libs/LimitOrderFill.sol +32 -161
  65. package/src/libs/LimitOrderLiquidity.sol +92 -71
  66. package/src/libs/LimitOrderViews.sol +168 -0
  67. package/src/libs/LimitOrderWithdraw.sol +13 -4
  68. package/src/libs/SwapLimitOrders.sol +14 -7
  69. package/src/router/SwapWithLimitOrders.sol +40 -26
  70. package/test/LimitOrderBitmap.t.sol +13 -7
  71. package/test/LimitOrderFill.t.sol +43 -0
  72. package/test/LimitOrderLibraries.t.sol +18 -10
  73. package/test/LimitOrderLiquidityPayouts.t.sol +280 -3
  74. package/test/LimitOrderWithdraw.t.sol +28 -1
  75. package/test/SwapWithLimitOrders.t.sol +3 -3
  76. package/test/SwapWithLimitOrdersRouter.t.sol +108 -11
  77. package/test/unit/LimitOrderBitmapUnit.t.sol +0 -134
  78. package/test/unit/LimitOrderCreateUnit.t.sol +32 -0
  79. package/test/unit/SwapLimitOrdersUnit.t.sol +231 -33
  80. package/test/unit/SwapLimitOrdersValidation.t.sol +20 -34
  81. package/test/unit/SwapWithLimitOrdersUnit.t.sol +21 -88
  82. package/test/utils/BaseTest.sol +29 -8
  83. package/test/utils/MockWETH.sol +39 -0
  84. package/test/utils/TestableZoraLimitOrderBook.sol +5 -7
@@ -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,7 +9,7 @@ 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
14
  bool isCurrency0 = Currency.unwrap(key.currency0) == address(creatorCoin);
15
15
  address orderCoin = _orderCoin(key, isCurrency0);
@@ -18,8 +18,8 @@ contract SwapWithLimitOrdersTest is BaseTest {
18
18
  _executeSingleHopSwapWithLimitOrders(users.buyer, key, 1e13, _defaultMultiples(), _defaultPercentages());
19
19
  CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
20
20
 
21
- assertEq(created.length, 0, "dust swap should not create orders");
22
- assertEq(_makerBalance(users.buyer, orderCoin), 0, "book balance should remain zero");
21
+ // Small purchases create orders (no minimum threshold)
22
+ assertGt(created.length, 0, "small swap should create orders");
23
23
  }
24
24
 
25
25
  function test_multiHopAutosellCreatesOrdersOnlyOnLastPool() public {
@@ -237,10 +237,10 @@ contract SwapWithLimitOrdersTestNonForked is SwapWithLimitOrdersTestBase {
237
237
  // TODO: Test unallocated coin handling
238
238
  }
239
239
 
240
- function test_limitOrderPlacement_respectsMinSize() public {
241
- // Test that small purchases below MIN_LIMIT_ORDER_SIZE don't create orders
240
+ function test_limitOrderPlacement_zeroSize() public {
241
+ // Test that zero-size purchases don't create orders
242
242
  // Should still execute swap but skip limit order ladder creation
243
- // TODO: Test MIN_LIMIT_ORDER_SIZE threshold
243
+ // TODO: Test zero size handling
244
244
  }
245
245
 
246
246
  function test_limitOrderPlacement_supportsMultipleOrders() public {
@@ -307,9 +307,72 @@ contract SwapWithLimitOrdersTestNonForked is SwapWithLimitOrdersTestBase {
307
307
  }
308
308
 
309
309
  function test_orderFilling_invertedDirection() public {
310
- // Verify fill direction is inverted (!isCoinCurrency0)
311
- // Check correct orders are targeted for filling
312
- // TODO: Requires verifying fill direction logic
310
+ // This test verifies the fix for audit issue #16
311
+ // https://github.com/kadenzipfel/zora-autosell-audit/issues/16
312
+ // The router should pass isCoinCurrency0 (not !isCoinCurrency0) to _fillOrders
313
+
314
+ PoolKey memory key = creatorCoin.getPoolKey();
315
+
316
+ // 1. Create first buyer's orders using swapWithLimitOrders
317
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
318
+ deal(address(zoraToken), users.buyer, DEFAULT_LIMIT_ORDER_AMOUNT);
319
+
320
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildDirectV4SwapParams(
321
+ users.buyer,
322
+ address(zoraToken),
323
+ DEFAULT_LIMIT_ORDER_AMOUNT,
324
+ key,
325
+ limitOrderConfig
326
+ );
327
+
328
+ vm.recordLogs();
329
+ _executeSwapWithLimitOrders(users.buyer, params);
330
+ CreatedOrderLog[] memory created = _decodeCreatedLogs(vm.getRecordedLogs());
331
+ assertGt(created.length, 0, "expected orders to be created");
332
+
333
+ // Store initial order state
334
+ address orderCoin = created[0].coin;
335
+ uint256 initialMakerBalance = _makerBalance(users.buyer, orderCoin);
336
+ assertGt(initialMakerBalance, 0, "buyer should have orders");
337
+
338
+ // 2. Mock the hook to not support ISupportsLimitOrderFill so router handles fills
339
+ bytes memory callData = abi.encodeWithSelector(IERC165.supportsInterface.selector, type(ISupportsLimitOrderFill).interfaceId);
340
+ vm.mockCall(address(key.hooks), callData, abi.encode(false));
341
+
342
+ // 3. Execute second swap that moves price beyond first orders AND creates new orders
343
+ // This ensures orders.length > 0 (from new orders) and tick moves past first orders
344
+ // Using a much larger swap to ensure we cross the first order ticks
345
+ LimitOrderConfig memory limitOrderConfig2 = _prepareLimitOrderParams(users.seller, _defaultMultiples(), _defaultPercentages());
346
+ uint256 largerSwapAmount = DEFAULT_LIMIT_ORDER_AMOUNT * 100; // 100x larger to move price significantly
347
+ deal(address(zoraToken), users.seller, largerSwapAmount);
348
+
349
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params2 = _buildDirectV4SwapParams(
350
+ users.seller,
351
+ address(zoraToken),
352
+ largerSwapAmount,
353
+ key,
354
+ limitOrderConfig2
355
+ );
356
+
357
+ vm.recordLogs();
358
+ _executeSwapWithLimitOrders(users.seller, params2);
359
+
360
+ // 5. Verify fills occurred by checking FilledOrderLog events
361
+ FilledOrderLog[] memory fills = _decodeFilledLogs(vm.getRecordedLogs());
362
+
363
+ // The bug: with !isCoinCurrency0, fills won't happen because wrong direction
364
+ // After fix: fills SHOULD happen
365
+ assertGt(fills.length, 0, "orders should have been filled by router");
366
+
367
+ // 6. Verify orders were filled for correct maker
368
+ for (uint256 i = 0; i < fills.length; i++) {
369
+ assertEq(fills[i].maker, users.buyer, "incorrect maker");
370
+ assertEq(fills[i].coinIn, orderCoin, "incorrect coin");
371
+ }
372
+
373
+ // 7. Verify maker balance decreased (orders filled and paid out)
374
+ uint256 finalMakerBalance = _makerBalance(users.buyer, orderCoin);
375
+ assertLt(finalMakerBalance, initialMakerBalance, "maker balance should decrease after fills");
313
376
  }
314
377
 
315
378
  function test_reverts_emptyV4Route() public {
@@ -586,13 +649,14 @@ contract SwapWithLimitOrdersTestNonForked is SwapWithLimitOrdersTestBase {
586
649
  address recipient = address(uint160(uint256(log.topics[2])));
587
650
  assertEq(sender, users.buyer, "sender indexed mismatch");
588
651
  assertEq(recipient, users.buyer, "recipient indexed mismatch");
589
- (PoolKey memory loggedPoolKey, int256 delta, , , CreatedOrder[] memory orders) = abi.decode(
652
+ (PoolKey memory loggedPoolKey, , , int128 amount0, int128 amount1, uint160 sqrtPriceX96, CreatedOrder[] memory orders) = abi.decode(
590
653
  log.data,
591
- (PoolKey, int256, int24, int24, CreatedOrder[])
654
+ (PoolKey, int24, int24, int128, int128, uint160, CreatedOrder[])
592
655
  );
593
656
  assertEq(Currency.unwrap(loggedPoolKey.currency0), Currency.unwrap(poolKey.currency0), "pool currency0 mismatch");
594
657
  assertEq(Currency.unwrap(loggedPoolKey.currency1), Currency.unwrap(poolKey.currency1), "pool currency1 mismatch");
595
- assertEq(delta, 0, "returned delta should be zero");
658
+ assertTrue(amount0 != 0 || amount1 != 0, "swap amounts should be non-zero");
659
+ assertGt(sqrtPriceX96, 0, "sqrtPriceX96 should be non-zero");
596
660
  assertGt(orders.length, 0, "should have created orders");
597
661
  sawExecuted = true;
598
662
  }
@@ -645,8 +709,41 @@ contract SwapWithLimitOrdersTestNonForked is SwapWithLimitOrdersTestBase {
645
709
  }
646
710
 
647
711
  function test_event_SwapWithLimitOrdersExecuted() public {
648
- // Verify SwapWithLimitOrdersExecuted event emitted with correct params
649
- // Check sender, recipient, poolKey, deltas, ticks, orderIds, ordersFilled
712
+ // Verify SwapWithLimitOrdersExecuted event emits price data fields
713
+ PoolKey memory poolKey = creatorCoin.getPoolKey();
714
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
715
+
716
+ uint256 inputAmount = DEFAULT_LIMIT_ORDER_AMOUNT;
717
+ deal(address(zoraToken), users.buyer, inputAmount);
718
+
719
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildDirectV4SwapParams(
720
+ users.buyer,
721
+ address(zoraToken),
722
+ inputAmount,
723
+ poolKey,
724
+ limitOrderConfig
725
+ );
726
+
727
+ vm.recordLogs();
728
+ _executeSwapWithLimitOrders(users.buyer, params);
729
+
730
+ SwapExecutedLog[] memory swaps = _decodeSwapExecutedLogs(vm.getRecordedLogs());
731
+ assertEq(swaps.length, 1, "expected single swap event");
732
+
733
+ // Verify price data fields are populated
734
+ SwapExecutedLog memory swap = swaps[0];
735
+ assertEq(swap.sender, users.buyer, "sender mismatch");
736
+ assertEq(swap.recipient, users.buyer, "recipient mismatch");
737
+
738
+ // amount0 and amount1 should be non-zero (one negative, one positive for a swap)
739
+ assertTrue(swap.amount0 != 0 || swap.amount1 != 0, "swap amounts should be non-zero");
740
+ assertTrue((swap.amount0 < 0 && swap.amount1 > 0) || (swap.amount0 > 0 && swap.amount1 < 0), "amounts should have opposite signs for a swap");
741
+
742
+ // sqrtPriceX96 should be a valid price (non-zero, reasonable range)
743
+ assertGt(swap.sqrtPriceX96, 0, "sqrtPriceX96 should be non-zero");
744
+
745
+ // Tick movement should be reflected
746
+ assertTrue(swap.tickBefore != swap.tickAfter || swap.amount0 == 0, "tick should move on swap");
650
747
  }
651
748
 
652
749
  function test_event_LimitOrdersCreated() public {