@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.
- package/.turbo/turbo-build$colon$js.log +47 -45
- package/CHANGELOG.md +61 -0
- package/abis/IWETH.json +118 -0
- package/abis/IZoraLimitOrderBook.json +5 -0
- package/abis/LimitOrderLiquidity.json +7 -0
- package/abis/LimitOrderViews.json +62 -0
- package/abis/SwapWithLimitOrders.json +18 -11
- package/abis/ZoraLimitOrderBook.json +28 -0
- package/cache/solidity-files-cache.json +1 -1
- package/dist/index.cjs +29 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +29 -8
- package/dist/index.js.map +1 -1
- package/dist/wagmiGenerated.d.ts +37 -9
- package/dist/wagmiGenerated.d.ts.map +1 -1
- package/out/BytesLib.sol/BytesLib.json +1 -1
- package/out/CoinCommon.sol/CoinCommon.json +1 -1
- package/out/CoinConfigurationVersions.sol/CoinConfigurationVersions.json +1 -1
- package/out/CoinConstants.sol/CoinConstants.json +1 -1
- package/out/DopplerMath.sol/DopplerMath.json +1 -1
- package/out/FixedPoint96.sol/FixedPoint96.json +1 -1
- package/out/ISwapRouter.sol/ISwapRouter.json +1 -1
- package/out/IUniswapV3SwapCallback.sol/IUniswapV3SwapCallback.json +1 -1
- package/out/IWETH.sol/IWETH.json +1 -0
- package/out/IZoraHookRegistry.sol/IZoraHookRegistry.json +1 -1
- package/out/IZoraLimitOrderBook.sol/IZoraLimitOrderBook.json +1 -1
- package/out/IZoraLimitOrderBookCoinsInterface.sol/IZoraLimitOrderBookCoinsInterface.json +1 -1
- package/out/IZoraV4CoinHook.sol/IZoraV4CoinHook.json +1 -1
- package/out/LimitOrderBitmap.sol/LimitOrderBitmap.json +1 -1
- package/out/LimitOrderCommon.sol/LimitOrderCommon.json +1 -1
- package/out/LimitOrderCreate.sol/LimitOrderCreate.json +1 -1
- package/out/LimitOrderFill.sol/LimitOrderFill.json +1 -1
- package/out/LimitOrderLiquidity.sol/LimitOrderLiquidity.json +1 -1
- package/out/LimitOrderQueues.sol/LimitOrderQueues.json +1 -1
- package/out/LimitOrderStorage.sol/LimitOrderStorage.json +1 -1
- package/out/LimitOrderTypes.sol/LimitOrderTypes.json +1 -1
- package/out/LimitOrderViews.sol/LimitOrderViews.json +1 -0
- package/out/LimitOrderWithdraw.sol/LimitOrderWithdraw.json +1 -1
- package/out/LiquidityAmounts.sol/LiquidityAmounts.json +1 -1
- package/out/Path.sol/Path.json +1 -1
- package/out/Permit2Payments.sol/Permit2Payments.json +1 -1
- package/out/SimpleAccessManaged.sol/SimpleAccessManaged.json +1 -1
- package/out/SimpleAccessManager.sol/SimpleAccessManager.json +1 -1
- package/out/SqrtPriceMath.sol/SqrtPriceMath.json +1 -1
- package/out/SwapLimitOrders.sol/SwapLimitOrders.json +1 -1
- package/out/SwapWithLimitOrders.sol/SwapWithLimitOrders.json +1 -1
- package/out/UniV4SwapToCurrency.sol/UniV4SwapToCurrency.json +1 -1
- package/out/UnsafeMath.sol/UnsafeMath.json +1 -1
- package/out/V3ToV4SwapLib.sol/V3ToV4SwapLib.json +1 -1
- package/out/ZoraLimitOrderBook.sol/ZoraLimitOrderBook.json +1 -1
- package/out/build-info/{69718f10d1dc37f0.json → 876cc09bc44cc8a7.json} +1 -1
- package/out/uniswap/BitMath.sol/BitMath.json +1 -1
- package/out/uniswap/CustomRevert.sol/CustomRevert.json +1 -1
- package/out/uniswap/FullMath.sol/FullMath.json +1 -1
- package/out/uniswap/SafeCast.sol/SafeCast.json +1 -1
- package/out/uniswap/TickMath.sol/TickMath.json +1 -1
- package/package/wagmiGenerated.ts +28 -7
- package/package.json +1 -1
- package/src/IZoraLimitOrderBook.sol +2 -0
- package/src/ZoraLimitOrderBook.sol +22 -8
- package/src/libs/LimitOrderBitmap.sol +0 -51
- package/src/libs/LimitOrderCommon.sol +48 -30
- package/src/libs/LimitOrderCreate.sol +5 -18
- package/src/libs/LimitOrderFill.sol +32 -161
- package/src/libs/LimitOrderLiquidity.sol +92 -71
- package/src/libs/LimitOrderViews.sol +168 -0
- package/src/libs/LimitOrderWithdraw.sol +13 -4
- package/src/libs/SwapLimitOrders.sol +14 -7
- package/src/router/SwapWithLimitOrders.sol +40 -26
- package/test/LimitOrderBitmap.t.sol +13 -7
- package/test/LimitOrderFill.t.sol +43 -0
- package/test/LimitOrderLibraries.t.sol +18 -10
- package/test/LimitOrderLiquidityPayouts.t.sol +280 -3
- package/test/LimitOrderWithdraw.t.sol +28 -1
- package/test/SwapWithLimitOrders.t.sol +3 -3
- package/test/SwapWithLimitOrdersRouter.t.sol +108 -11
- package/test/unit/LimitOrderBitmapUnit.t.sol +0 -134
- package/test/unit/LimitOrderCreateUnit.t.sol +32 -0
- package/test/unit/SwapLimitOrdersUnit.t.sol +231 -33
- package/test/unit/SwapLimitOrdersValidation.t.sol +20 -34
- package/test/unit/SwapWithLimitOrdersUnit.t.sol +21 -88
- package/test/utils/BaseTest.sol +29 -8
- package/test/utils/MockWETH.sol +39 -0
- 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
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
241
|
-
// Test that
|
|
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
|
|
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
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
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,
|
|
652
|
+
(PoolKey memory loggedPoolKey, , , int128 amount0, int128 amount1, uint160 sqrtPriceX96, CreatedOrder[] memory orders) = abi.decode(
|
|
590
653
|
log.data,
|
|
591
|
-
(PoolKey,
|
|
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
|
-
|
|
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
|
|
649
|
-
|
|
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 {
|