@zoralabs/limit-orders 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/.turbo/turbo-build$colon$js.log +85 -0
  2. package/AUDIT_NOTES.md +33 -0
  3. package/AUDIT_RFP.md +408 -0
  4. package/CHANGELOG.md +25 -0
  5. package/GAS_COMPARISON_RESULTS.md +194 -0
  6. package/LICENSE +21 -0
  7. package/README.md +650 -0
  8. package/SPEC.md +291 -0
  9. package/abis/BalanceDeltaLibrary.json +15 -0
  10. package/abis/BeforeSwapDeltaLibrary.json +15 -0
  11. package/abis/CurrencyLibrary.json +25 -0
  12. package/abis/CustomRevert.json +28 -0
  13. package/abis/IAllowanceTransfer.json +486 -0
  14. package/abis/IAuthority.json +31 -0
  15. package/abis/ICoin.json +1074 -0
  16. package/abis/IDeployedCoinVersionLookup.json +21 -0
  17. package/abis/IDopplerErrors.json +44 -0
  18. package/abis/IEIP712.json +15 -0
  19. package/abis/IERC1363.json +373 -0
  20. package/abis/IERC165.json +21 -0
  21. package/abis/IERC20.json +185 -0
  22. package/abis/IERC20Minimal.json +172 -0
  23. package/abis/IERC6909Claims.json +288 -0
  24. package/abis/IERC7572.json +21 -0
  25. package/abis/IExtsload.json +64 -0
  26. package/abis/IExttload.json +40 -0
  27. package/abis/IHasCoinType.json +15 -0
  28. package/abis/IHasPoolKey.json +42 -0
  29. package/abis/IHasRewardsRecipients.json +54 -0
  30. package/abis/IHasSwapPath.json +60 -0
  31. package/abis/IHasTotalSupplyForPositions.json +15 -0
  32. package/abis/IHooks.json +789 -0
  33. package/abis/IMsgSender.json +15 -0
  34. package/abis/IPoolManager.json +1286 -0
  35. package/abis/IProtocolFees.json +174 -0
  36. package/abis/ISupportsLimitOrderFill.json +15 -0
  37. package/abis/ISwapPathRouter.json +92 -0
  38. package/abis/ISwapRouter.json +219 -0
  39. package/abis/IUniswapV3SwapCallback.json +25 -0
  40. package/abis/IUpgradeableDestinationV4Hook.json +84 -0
  41. package/abis/IUpgradeableDestinationV4HookWithUpdateableFee.json +95 -0
  42. package/abis/IUpgradeableV4Hook.json +112 -0
  43. package/abis/IZoraHookRegistry.json +188 -0
  44. package/abis/IZoraLimitOrderBook.json +623 -0
  45. package/abis/IZoraLimitOrderBookCoinsInterface.json +67 -0
  46. package/abis/IZoraV4CoinHook.json +610 -0
  47. package/abis/Permit2Payments.json +7 -0
  48. package/abis/Position.json +7 -0
  49. package/abis/SafeCast.json +7 -0
  50. package/abis/SafeCast160.json +7 -0
  51. package/abis/SafeERC20.json +34 -0
  52. package/abis/SimpleAccessManaged.json +57 -0
  53. package/abis/SimpleAccessManager.json +351 -0
  54. package/abis/SqrtPriceMath.json +22 -0
  55. package/abis/StateLibrary.json +80 -0
  56. package/abis/SwapLimitOrders.json +22 -0
  57. package/abis/SwapWithLimitOrders.json +457 -0
  58. package/abis/TickBitmap.json +18 -0
  59. package/abis/TickMath.json +24 -0
  60. package/abis/V3ToV4SwapLib.json +28 -0
  61. package/abis/ZoraLimitOrderBook.json +771 -0
  62. package/cache/solidity-files-cache.json +1 -0
  63. package/dist/index.cjs +760 -0
  64. package/dist/index.cjs.map +1 -0
  65. package/dist/index.d.ts +2 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +731 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/wagmiGenerated.d.ts +1012 -0
  70. package/dist/wagmiGenerated.d.ts.map +1 -0
  71. package/foundry.toml +29 -0
  72. package/gas_comparison.py +49 -0
  73. package/out/BalanceDelta.sol/BalanceDeltaLibrary.json +1 -0
  74. package/out/BeforeSwapDelta.sol/BeforeSwapDeltaLibrary.json +1 -0
  75. package/out/BitMath.sol/BitMath.json +1 -0
  76. package/out/BytesLib.sol/BytesLib.json +1 -0
  77. package/out/CoinCommon.sol/CoinCommon.json +1 -0
  78. package/out/CoinConfigurationVersions.sol/CoinConfigurationVersions.json +1 -0
  79. package/out/CoinConstants.sol/CoinConstants.json +1 -0
  80. package/out/Context.sol/Context.json +1 -0
  81. package/out/Currency.sol/CurrencyLibrary.json +1 -0
  82. package/out/CurrencyReserves.sol/CurrencyReserves.json +1 -0
  83. package/out/CustomRevert.sol/CustomRevert.json +1 -0
  84. package/out/DopplerMath.sol/DopplerMath.json +1 -0
  85. package/out/FixedPoint128.sol/FixedPoint128.json +1 -0
  86. package/out/FixedPoint96.sol/FixedPoint96.json +1 -0
  87. package/out/FullMath.sol/FullMath.json +1 -0
  88. package/out/IAllowanceTransfer.sol/IAllowanceTransfer.json +1 -0
  89. package/out/IAuthority.sol/IAuthority.json +1 -0
  90. package/out/ICoin.sol/ICoin.json +1 -0
  91. package/out/ICoin.sol/IHasCoinType.json +1 -0
  92. package/out/ICoin.sol/IHasPoolKey.json +1 -0
  93. package/out/ICoin.sol/IHasSwapPath.json +1 -0
  94. package/out/ICoin.sol/IHasTotalSupplyForPositions.json +1 -0
  95. package/out/IDeployedCoinVersionLookup.sol/IDeployedCoinVersionLookup.json +1 -0
  96. package/out/IDopplerErrors.sol/IDopplerErrors.json +1 -0
  97. package/out/IEIP712.sol/IEIP712.json +1 -0
  98. package/out/IERC1363.sol/IERC1363.json +1 -0
  99. package/out/IERC165.sol/IERC165.json +1 -0
  100. package/out/IERC20.sol/IERC20.json +1 -0
  101. package/out/IERC20Minimal.sol/IERC20Minimal.json +1 -0
  102. package/out/IERC6909Claims.sol/IERC6909Claims.json +1 -0
  103. package/out/IERC7572.sol/IERC7572.json +1 -0
  104. package/out/IExtsload.sol/IExtsload.json +1 -0
  105. package/out/IExttload.sol/IExttload.json +1 -0
  106. package/out/IHasRewardsRecipients.sol/IHasRewardsRecipients.json +1 -0
  107. package/out/IHooks.sol/IHooks.json +1 -0
  108. package/out/IMsgSender.sol/IMsgSender.json +1 -0
  109. package/out/IPoolManager.sol/IPoolManager.json +1 -0
  110. package/out/IProtocolFees.sol/IProtocolFees.json +1 -0
  111. package/out/ISupportsLimitOrderFill.sol/ISupportsLimitOrderFill.json +1 -0
  112. package/out/ISwapPathRouter.sol/ISwapPathRouter.json +1 -0
  113. package/out/ISwapRouter.sol/ISwapRouter.json +1 -0
  114. package/out/IUniswapV3SwapCallback.sol/IUniswapV3SwapCallback.json +1 -0
  115. package/out/IUpgradeableV4Hook.sol/IUpgradeableDestinationV4Hook.json +1 -0
  116. package/out/IUpgradeableV4Hook.sol/IUpgradeableDestinationV4HookWithUpdateableFee.json +1 -0
  117. package/out/IUpgradeableV4Hook.sol/IUpgradeableV4Hook.json +1 -0
  118. package/out/IZoraHookRegistry.sol/IZoraHookRegistry.json +1 -0
  119. package/out/IZoraLimitOrderBook.sol/IZoraLimitOrderBook.json +1 -0
  120. package/out/IZoraLimitOrderBookCoinsInterface.sol/IZoraLimitOrderBookCoinsInterface.json +1 -0
  121. package/out/IZoraV4CoinHook.sol/IZoraV4CoinHook.json +1 -0
  122. package/out/LimitOrderBitmap.sol/LimitOrderBitmap.json +1 -0
  123. package/out/LimitOrderCommon.sol/LimitOrderCommon.json +1 -0
  124. package/out/LimitOrderCreate.sol/LimitOrderCreate.json +1 -0
  125. package/out/LimitOrderFill.sol/LimitOrderFill.json +1 -0
  126. package/out/LimitOrderLiquidity.sol/LimitOrderLiquidity.json +1 -0
  127. package/out/LimitOrderQueues.sol/LimitOrderQueues.json +1 -0
  128. package/out/LimitOrderStorage.sol/LimitOrderStorage.json +1 -0
  129. package/out/LimitOrderTypes.sol/LimitOrderTypes.json +1 -0
  130. package/out/LimitOrderWithdraw.sol/LimitOrderWithdraw.json +1 -0
  131. package/out/LiquidityAmounts.sol/LiquidityAmounts.json +1 -0
  132. package/out/LiquidityMath.sol/LiquidityMath.json +1 -0
  133. package/out/Lock.sol/Lock.json +1 -0
  134. package/out/NonzeroDeltaCount.sol/NonzeroDeltaCount.json +1 -0
  135. package/out/Path.sol/Path.json +1 -0
  136. package/out/PathKey.sol/PathKeyLibrary.json +1 -0
  137. package/out/Permit2Payments.sol/Permit2Payments.json +1 -0
  138. package/out/PoolId.sol/PoolIdLibrary.json +1 -0
  139. package/out/Position.sol/Position.json +1 -0
  140. package/out/SafeCast.sol/SafeCast.json +1 -0
  141. package/out/SafeCast160.sol/SafeCast160.json +1 -0
  142. package/out/SafeERC20.sol/SafeERC20.json +1 -0
  143. package/out/SimpleAccessManaged.sol/SimpleAccessManaged.json +1 -0
  144. package/out/SimpleAccessManager.sol/SimpleAccessManager.json +1 -0
  145. package/out/SqrtPriceMath.sol/SqrtPriceMath.json +1 -0
  146. package/out/StateLibrary.sol/StateLibrary.json +1 -0
  147. package/out/SwapLimitOrders.sol/SwapLimitOrders.json +1 -0
  148. package/out/SwapWithLimitOrders.sol/SwapWithLimitOrders.json +1 -0
  149. package/out/TickBitmap.sol/TickBitmap.json +1 -0
  150. package/out/TickMath.sol/TickMath.json +1 -0
  151. package/out/TransientSlot.sol/TransientSlot.json +1 -0
  152. package/out/TransientStateLibrary.sol/TransientStateLibrary.json +1 -0
  153. package/out/UniV4SwapToCurrency.sol/UniV4SwapToCurrency.json +1 -0
  154. package/out/UnsafeMath.sol/UnsafeMath.json +1 -0
  155. package/out/V3ToV4SwapLib.sol/V3ToV4SwapLib.json +1 -0
  156. package/out/ZoraLimitOrderBook.sol/ZoraLimitOrderBook.json +1 -0
  157. package/out/build-info/69718f10d1dc37f0.json +1 -0
  158. package/out/uniswap/BitMath.sol/BitMath.json +1 -0
  159. package/out/uniswap/CustomRevert.sol/CustomRevert.json +1 -0
  160. package/out/uniswap/FullMath.sol/FullMath.json +1 -0
  161. package/out/uniswap/SafeCast.sol/SafeCast.json +1 -0
  162. package/out/uniswap/TickMath.sol/TickMath.json +1 -0
  163. package/package/index.ts +1 -0
  164. package/package/wagmiGenerated.ts +738 -0
  165. package/package.json +57 -0
  166. package/remappings.txt +11 -0
  167. package/src/IZoraLimitOrderBook.sol +195 -0
  168. package/src/ZoraLimitOrderBook.sol +220 -0
  169. package/src/access/SimpleAccessManaged.sol +76 -0
  170. package/src/access/SimpleAccessManager.sol +268 -0
  171. package/src/libs/LimitOrderBitmap.sol +84 -0
  172. package/src/libs/LimitOrderCommon.sol +91 -0
  173. package/src/libs/LimitOrderCreate.sol +277 -0
  174. package/src/libs/LimitOrderFill.sol +362 -0
  175. package/src/libs/LimitOrderLiquidity.sol +222 -0
  176. package/src/libs/LimitOrderQueues.sol +101 -0
  177. package/src/libs/LimitOrderStorage.sol +34 -0
  178. package/src/libs/LimitOrderTypes.sol +41 -0
  179. package/src/libs/LimitOrderWithdraw.sol +100 -0
  180. package/src/libs/Permit2Payments.sol +41 -0
  181. package/src/libs/SwapLimitOrders.sol +209 -0
  182. package/src/router/SwapWithLimitOrders.sol +454 -0
  183. package/test/LimitOrderAccessControl.t.sol +461 -0
  184. package/test/LimitOrderBitmap.t.sol +194 -0
  185. package/test/LimitOrderCreate.t.sol +348 -0
  186. package/test/LimitOrderFill.t.sol +1005 -0
  187. package/test/LimitOrderLibraries.t.sol +354 -0
  188. package/test/LimitOrderLiquidityPayouts.t.sol +333 -0
  189. package/test/LimitOrderV4Pools.t.sol +157 -0
  190. package/test/LimitOrderWithdraw.t.sol +653 -0
  191. package/test/SimpleAccessManager.t.sol +420 -0
  192. package/test/SwapWithLimitOrders.t.sol +107 -0
  193. package/test/SwapWithLimitOrdersRouter.t.sol +1073 -0
  194. package/test/gas/LimitOrderFillGas.t.sol +1008 -0
  195. package/test/gas/LimitOrderSwapGas.t.sol +403 -0
  196. package/test/gas/logs/gas_benchmarks_fill_20251201.log +30 -0
  197. package/test/gas/logs/gas_benchmarks_swap_20251201.log +27 -0
  198. package/test/unit/LimitOrderBitmapUnit.t.sol +276 -0
  199. package/test/unit/LimitOrderCreateUnit.t.sol +358 -0
  200. package/test/unit/SwapLimitOrdersUnit.t.sol +672 -0
  201. package/test/unit/SwapLimitOrdersValidation.t.sol +423 -0
  202. package/test/unit/SwapWithLimitOrdersUnit.t.sol +321 -0
  203. package/test/utils/BaseTest.sol +793 -0
  204. package/test/utils/TestableZoraLimitOrderBook.sol +54 -0
  205. package/tsconfig.build.json +10 -0
  206. package/tsconfig.json +9 -0
  207. package/tsup.config.ts +11 -0
  208. package/wagmi.config.ts +18 -0
@@ -0,0 +1,1073 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.13;
3
+
4
+ import {BaseTest} from "./utils/BaseTest.sol";
5
+ import {SwapWithLimitOrders} from "../src/router/SwapWithLimitOrders.sol";
6
+ import {V3ToV4SwapLib} from "@zoralabs/coins/src/libs/V3ToV4SwapLib.sol";
7
+ import {ISupportsLimitOrderFill} from "@zoralabs/coins/src/interfaces/ISupportsLimitOrderFill.sol";
8
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
9
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
10
+ import {PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol";
11
+ import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
12
+ import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
13
+ import {LimitOrderConfig} from "../src/libs/SwapLimitOrders.sol";
14
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
15
+ import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol";
16
+ import {AddressConstants} from "@zoralabs/coins/test/utils/hookmate/constants/AddressConstants.sol";
17
+
18
+ interface IZoraLimitOrderBookFillTickRange {
19
+ function fill(PoolKey calldata key, bool isCurrency0, int24 startTick, int24 endTick, uint256 maxFillCount, address fillReferral) external;
20
+ }
21
+
22
+ import {Vm} from "forge-std/Vm.sol";
23
+
24
+ abstract contract SwapWithLimitOrdersTestBase is BaseTest {
25
+ using PoolIdLibrary for PoolKey;
26
+
27
+ function _executeSwapWithLimitOrders(address caller, SwapWithLimitOrders.SwapWithLimitOrdersParams memory params) internal returns (BalanceDelta delta) {
28
+ vm.startPrank(caller);
29
+
30
+ // Handle ETH transfers
31
+ uint256 value = params.inputCurrency == address(0) ? params.inputAmount : 0;
32
+
33
+ // Handle ERC20 approvals via Permit2
34
+ if (params.inputCurrency != address(0)) {
35
+ address permit2 = AddressConstants.getPermit2Address();
36
+ IERC20(params.inputCurrency).approve(permit2, type(uint256).max);
37
+
38
+ // Approve swapWithLimitOrders as spender in Permit2
39
+ IAllowanceTransfer(permit2).approve(params.inputCurrency, address(swapWithLimitOrders), uint160(type(uint160).max), type(uint48).max);
40
+ }
41
+
42
+ // Execute swap with limit order placement
43
+ delta = swapWithLimitOrders.swapWithLimitOrders{value: value}(params);
44
+
45
+ vm.stopPrank();
46
+ }
47
+
48
+ function _buildDirectV4SwapParams(
49
+ address recipient,
50
+ address inputCurrency,
51
+ uint256 inputAmount,
52
+ PoolKey memory targetPool,
53
+ LimitOrderConfig memory limitOrderConfig
54
+ ) internal pure returns (SwapWithLimitOrders.SwapWithLimitOrdersParams memory) {
55
+ PoolKey[] memory v4Route = new PoolKey[](1);
56
+ v4Route[0] = targetPool;
57
+
58
+ return
59
+ SwapWithLimitOrders.SwapWithLimitOrdersParams({
60
+ recipient: recipient,
61
+ limitOrderConfig: limitOrderConfig,
62
+ inputCurrency: inputCurrency,
63
+ inputAmount: inputAmount,
64
+ v3Route: bytes(""),
65
+ v4Route: v4Route,
66
+ minAmountOut: 0
67
+ });
68
+ }
69
+
70
+ function _buildMultiHopV4SwapParams(
71
+ address recipient,
72
+ address inputCurrency,
73
+ uint256 inputAmount,
74
+ PoolKey[] memory v4Route,
75
+ LimitOrderConfig memory limitOrderConfig
76
+ ) internal pure returns (SwapWithLimitOrders.SwapWithLimitOrdersParams memory) {
77
+ return
78
+ SwapWithLimitOrders.SwapWithLimitOrdersParams({
79
+ recipient: recipient,
80
+ limitOrderConfig: limitOrderConfig,
81
+ inputCurrency: inputCurrency,
82
+ inputAmount: inputAmount,
83
+ v3Route: bytes(""),
84
+ v4Route: v4Route,
85
+ minAmountOut: 0
86
+ });
87
+ }
88
+ }
89
+
90
+ contract SwapWithLimitOrdersTestNonForked is SwapWithLimitOrdersTestBase {
91
+ function test_swapWithLimitOrders_directV4Swap() public {
92
+ // Test direct V4 swap with single pool in v4Route
93
+ // No V3 route, single pool, limit orders placed and filled
94
+
95
+ PoolKey memory poolKey = creatorCoin.getPoolKey();
96
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
97
+
98
+ // Give buyer ZORA tokens
99
+ uint256 inputAmount = DEFAULT_LIMIT_ORDER_AMOUNT;
100
+ deal(address(zoraToken), users.buyer, inputAmount);
101
+
102
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildDirectV4SwapParams(
103
+ users.buyer, // recipient
104
+ address(zoraToken), // inputCurrency
105
+ inputAmount,
106
+ poolKey, // targetPool
107
+ limitOrderConfig
108
+ );
109
+
110
+ _executeSwapWithLimitOrders(users.buyer, params);
111
+
112
+ // Note: orderIds and ordersFilled are no longer returned
113
+ // They can be extracted from events if needed for testing
114
+ }
115
+
116
+ function test_swapWithLimitOrders_multiHopV4Swap() public {
117
+ // Test multi-hop V4 swap (e.g., ZORA -> Creator Coin -> Content Coin)
118
+ // Multiple pools in v4Route, limit orders on final coin
119
+
120
+ PoolKey[] memory v4Route = new PoolKey[](2);
121
+ v4Route[0] = creatorCoin.getPoolKey();
122
+ v4Route[1] = contentCoin.getPoolKey();
123
+
124
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
125
+
126
+ uint256 inputAmount = DEFAULT_LIMIT_ORDER_AMOUNT;
127
+ deal(address(zoraToken), users.buyer, inputAmount);
128
+
129
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildMultiHopV4SwapParams(
130
+ users.buyer, // recipient
131
+ address(zoraToken), // inputCurrency
132
+ inputAmount,
133
+ v4Route,
134
+ limitOrderConfig
135
+ );
136
+
137
+ vm.recordLogs();
138
+ _executeSwapWithLimitOrders(users.buyer, params);
139
+
140
+ // Extract order IDs from events
141
+ SwapExecutedLog[] memory swaps = _decodeSwapExecutedLogs(vm.getRecordedLogs());
142
+ require(swaps.length > 0, "expected swap event");
143
+
144
+ // Verify orders created
145
+ assertGt(swaps[0].orders.length, 0, "should have created orders");
146
+ }
147
+
148
+ function test_swapWithLimitOrders_hookCallsFill() public {
149
+ // Test that hook calls zoraLimitOrderBook.fill during swap
150
+ // We use vm.expectCall to verify the fill function is actually called
151
+
152
+ PoolKey memory poolKey = creatorCoin.getPoolKey();
153
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
154
+
155
+ uint256 inputAmount = DEFAULT_LIMIT_ORDER_AMOUNT;
156
+ deal(address(zoraToken), users.buyer, inputAmount);
157
+
158
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildDirectV4SwapParams(
159
+ users.buyer,
160
+ address(zoraToken),
161
+ inputAmount,
162
+ poolKey,
163
+ limitOrderConfig
164
+ );
165
+
166
+ // Expect that zoraLimitOrderBook.fill is called by the hook during the swap
167
+ // Use our interface to get the correct selector for the tick-range fill overload
168
+ // Only use the selector without parameters for partial matching
169
+ vm.expectCall(address(limitOrderBook), abi.encodeWithSelector(IZoraLimitOrderBookFillTickRange.fill.selector));
170
+
171
+ vm.recordLogs();
172
+ // Execute swap with limit order placement - this should trigger the hook's afterSwap which calls fill
173
+ _executeSwapWithLimitOrders(users.buyer, params);
174
+
175
+ // Extract order IDs from events
176
+ SwapExecutedLog[] memory swaps = _decodeSwapExecutedLogs(vm.getRecordedLogs());
177
+ require(swaps.length > 0, "expected swap event");
178
+
179
+ // Verify orders were created
180
+ assertGt(swaps[0].orders.length, 0, "should have created orders");
181
+ assertEq(swaps[0].orders.length, 5, "should have 5 orders for 5 percentages");
182
+
183
+ // If we reach here without reverting, vm.expectCall passed, meaning fill was called
184
+ }
185
+
186
+ function test_swapWithLimitOrders_hookDoesNotSupportLimitOrderFill() public {
187
+ // Test with hook that doesn't implement ISupportsLimitOrderFill
188
+ // Router SHOULD fill orders (backwards compatibility)
189
+ // ordersFilled should reflect actual fills
190
+ // TODO: Test with legacy hook or current hook before interface is added
191
+ }
192
+
193
+ function test_swapWithLimitOrders_oldHookWithoutERC165() public {
194
+ // Test with very old hook that doesn't support ERC165 at all
195
+ // Router should handle fills gracefully
196
+ // TODO: Test with very old hook implementation
197
+ }
198
+
199
+ function test_limitOrderPlacement_createsExpectedSizes() public {
200
+ // Verify order sizes match percentages from LimitOrderParams
201
+ // Check allocated + unallocated = total coins purchased
202
+
203
+ PoolKey memory poolKey = creatorCoin.getPoolKey();
204
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
205
+
206
+ uint256 inputAmount = DEFAULT_LIMIT_ORDER_AMOUNT;
207
+ deal(address(zoraToken), users.buyer, inputAmount);
208
+
209
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildDirectV4SwapParams(
210
+ users.buyer,
211
+ address(zoraToken),
212
+ inputAmount,
213
+ poolKey,
214
+ limitOrderConfig
215
+ );
216
+
217
+ vm.recordLogs();
218
+ _executeSwapWithLimitOrders(users.buyer, params);
219
+
220
+ // Extract order IDs from events
221
+ SwapExecutedLog[] memory swaps = _decodeSwapExecutedLogs(vm.getRecordedLogs());
222
+ require(swaps.length > 0, "expected swap event");
223
+
224
+ // Verify orders created with expected count
225
+ assertEq(swaps[0].orders.length, 5, "should have 5 orders for 5 percentages");
226
+ }
227
+
228
+ function test_limitOrderPlacement_alignsTicks() public {
229
+ // Verify order ticks match multiples from LimitOrderParams
230
+ // Check ticks are aligned to tick spacing
231
+ // TODO: Implement tick calculation verification
232
+ }
233
+
234
+ function test_limitOrderPlacement_sendsUnallocatedToMaker() public {
235
+ // Verify any unallocated coins go to maker/recipient
236
+ // Check maker balance increases by unallocated amount
237
+ // TODO: Test unallocated coin handling
238
+ }
239
+
240
+ function test_limitOrderPlacement_respectsMinSize() public {
241
+ // Test that small purchases below MIN_LIMIT_ORDER_SIZE don't create orders
242
+ // Should still execute swap but skip limit order ladder creation
243
+ // TODO: Test MIN_LIMIT_ORDER_SIZE threshold
244
+ }
245
+
246
+ function test_limitOrderPlacement_supportsMultipleOrders() public {
247
+ // Test creating multiple orders with different multiples
248
+ // Verify all orders are tracked in the limit order book
249
+
250
+ PoolKey memory poolKey = creatorCoin.getPoolKey();
251
+
252
+ // Use more multiples to create more orders
253
+ uint256[] memory multiples = new uint256[](5);
254
+ multiples[0] = 2e18;
255
+ multiples[1] = 3e18;
256
+ multiples[2] = 4e18;
257
+ multiples[3] = 5e18;
258
+ multiples[4] = 10e18;
259
+
260
+ uint256[] memory percentages = new uint256[](5);
261
+ percentages[0] = 2000; // 20%
262
+ percentages[1] = 2000; // 20%
263
+ percentages[2] = 2000; // 20%
264
+ percentages[3] = 2000; // 20%
265
+ percentages[4] = 2000; // 20%
266
+
267
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, multiples, percentages);
268
+
269
+ uint256 inputAmount = DEFAULT_LIMIT_ORDER_AMOUNT;
270
+ deal(address(zoraToken), users.buyer, inputAmount);
271
+
272
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildDirectV4SwapParams(
273
+ users.buyer,
274
+ address(zoraToken),
275
+ inputAmount,
276
+ poolKey,
277
+ limitOrderConfig
278
+ );
279
+
280
+ vm.recordLogs();
281
+ _executeSwapWithLimitOrders(users.buyer, params);
282
+
283
+ // Extract order IDs from events
284
+ SwapExecutedLog[] memory swaps = _decodeSwapExecutedLogs(vm.getRecordedLogs());
285
+ require(swaps.length > 0, "expected swap event");
286
+
287
+ // Verify all orders were created
288
+ assertEq(swaps[0].orders.length, 5, "should have created 5 orders");
289
+ }
290
+
291
+ function test_orderFilling_hookFillsLimitOrders() public {
292
+ // Verify that hook fills limit orders during subsequent swaps
293
+ // TODO: Revisit this test later - requires debugging hook's fill logic
294
+ // to ensure swaps cross the exact tick ranges where orders are placed
295
+ }
296
+
297
+ function test_orderFilling_respectsMaxFillCount() public {
298
+ // Verify only maxFillCount orders are filled
299
+ // Test with more available orders than maxFillCount
300
+ // TODO: Requires setting up pre-existing orders and crossing ticks
301
+ }
302
+
303
+ function test_orderFilling_crossedTickRange() public {
304
+ // Verify only orders in crossed tick range are filled
305
+ // Orders outside range should remain unfilled
306
+ // TODO: Requires controlling tick movement during swap
307
+ }
308
+
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
313
+ }
314
+
315
+ function test_reverts_emptyV4Route() public {
316
+ // Should revert with EmptyV4Route() when v4Route is empty
317
+
318
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
319
+
320
+ deal(address(zoraToken), users.buyer, DEFAULT_LIMIT_ORDER_AMOUNT);
321
+
322
+ // Create params with empty v4Route
323
+ PoolKey[] memory emptyRoute = new PoolKey[](0);
324
+
325
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = SwapWithLimitOrders.SwapWithLimitOrdersParams({
326
+ recipient: users.buyer,
327
+ limitOrderConfig: limitOrderConfig,
328
+ inputCurrency: address(zoraToken),
329
+ inputAmount: DEFAULT_LIMIT_ORDER_AMOUNT,
330
+ v3Route: bytes(""),
331
+ v4Route: emptyRoute,
332
+ minAmountOut: 0
333
+ });
334
+
335
+ vm.prank(users.buyer);
336
+ IERC20(address(zoraToken)).approve(address(swapWithLimitOrders), DEFAULT_LIMIT_ORDER_AMOUNT);
337
+
338
+ vm.expectRevert(SwapWithLimitOrders.EmptyV4Route.selector);
339
+ vm.prank(users.buyer);
340
+ swapWithLimitOrders.swapWithLimitOrders(params);
341
+ }
342
+
343
+ function test_reverts_insufficientOutputAmount() public {
344
+ // Should revert when final swap output < minAmountOut
345
+ // Slippage protection test
346
+
347
+ PoolKey memory poolKey = creatorCoin.getPoolKey();
348
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
349
+
350
+ uint256 inputAmount = DEFAULT_LIMIT_ORDER_AMOUNT;
351
+ deal(address(zoraToken), users.buyer, inputAmount);
352
+
353
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildDirectV4SwapParams(
354
+ users.buyer,
355
+ address(zoraToken),
356
+ inputAmount,
357
+ poolKey,
358
+ limitOrderConfig
359
+ );
360
+
361
+ // Set unrealistically high minAmountOut to trigger revert
362
+ params.minAmountOut = type(uint256).max;
363
+
364
+ // Setup Permit2 approval
365
+ vm.startPrank(users.buyer);
366
+ address permit2 = AddressConstants.getPermit2Address();
367
+ IERC20(address(zoraToken)).approve(permit2, type(uint256).max);
368
+ IAllowanceTransfer(permit2).approve(address(zoraToken), address(swapWithLimitOrders), uint160(type(uint160).max), type(uint48).max);
369
+ vm.stopPrank();
370
+
371
+ vm.expectRevert(SwapWithLimitOrders.InsufficientOutputAmount.selector);
372
+ vm.prank(users.buyer);
373
+ swapWithLimitOrders.swapWithLimitOrders(params);
374
+ }
375
+
376
+ function test_reverts_insufficientInputCurrency() public {
377
+ // Should revert when msg.value < inputAmount (for ETH)
378
+ // With Permit2: Should revert when there's no Permit2 allowance (AllowanceExpired)
379
+
380
+ PoolKey memory poolKey = creatorCoin.getPoolKey();
381
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
382
+
383
+ uint256 inputAmount = DEFAULT_LIMIT_ORDER_AMOUNT;
384
+
385
+ // Give buyer some ZORA but don't set up Permit2 allowance
386
+ deal(address(zoraToken), users.buyer, inputAmount);
387
+
388
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildDirectV4SwapParams(
389
+ users.buyer,
390
+ address(zoraToken), // ZORA
391
+ inputAmount,
392
+ poolKey,
393
+ limitOrderConfig
394
+ );
395
+
396
+ // Don't approve Permit2 or set allowance - will revert with AllowanceExpired
397
+ vm.prank(users.buyer);
398
+ vm.expectRevert(abi.encodeWithSignature("AllowanceExpired(uint256)", 0));
399
+ swapWithLimitOrders.swapWithLimitOrders(params);
400
+ }
401
+
402
+ function test_reverts_whenV3OutputDoesNotMatchV4Input() public {
403
+ // Craft mismatched V3/V4 routes so validation fails before any swaps execute.
404
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
405
+
406
+ PoolKey[] memory v4Route = new PoolKey[](1);
407
+ v4Route[0] = creatorCoin.getPoolKey();
408
+
409
+ // Encode a V3 path whose output token is contentCoin, which is NOT part of the first V4 pool.
410
+ bytes memory v3Route = abi.encodePacked(address(zoraToken), uint24(3000), address(contentCoin));
411
+
412
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = SwapWithLimitOrders.SwapWithLimitOrdersParams({
413
+ recipient: users.buyer,
414
+ limitOrderConfig: limitOrderConfig,
415
+ inputCurrency: address(zoraToken),
416
+ inputAmount: DEFAULT_LIMIT_ORDER_AMOUNT,
417
+ v3Route: v3Route,
418
+ v4Route: v4Route,
419
+ minAmountOut: 0
420
+ });
421
+
422
+ vm.expectRevert(V3ToV4SwapLib.V3RouteDoesNotConnectToV4RouteStart.selector);
423
+ vm.prank(users.buyer);
424
+ swapWithLimitOrders.swapWithLimitOrders(params);
425
+ }
426
+
427
+ function test_swapUsesMakerAllowanceEvenWhenCallerDiffers() public {
428
+ PoolKey memory poolKey = creatorCoin.getPoolKey();
429
+
430
+ // Now msg.sender pays for the swap, recipient receives outputs and owns orders
431
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
432
+ uint256 inputAmount = DEFAULT_LIMIT_ORDER_AMOUNT;
433
+
434
+ deal(address(zoraToken), users.buyer, inputAmount);
435
+
436
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildDirectV4SwapParams(
437
+ users.buyer, // Recipient who will own the orders
438
+ address(zoraToken),
439
+ inputAmount,
440
+ poolKey,
441
+ limitOrderConfig
442
+ );
443
+
444
+ uint256 buyerBalanceBefore = IERC20(address(zoraToken)).balanceOf(users.buyer);
445
+
446
+ _executeSwapWithLimitOrders(users.buyer, params);
447
+
448
+ // Buyer (msg.sender) paid for the swap and owns the orders
449
+ assertLt(IERC20(address(zoraToken)).balanceOf(users.buyer), buyerBalanceBefore, "buyer should fund the swap");
450
+ }
451
+
452
+ function test_routerFallsBackWhenHookDoesNotSupportFill() public {
453
+ PoolKey memory poolKey = creatorCoin.getPoolKey();
454
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
455
+
456
+ // Force IERC165 check to return false so router must handle fills.
457
+ bytes memory callData = abi.encodeWithSelector(IERC165.supportsInterface.selector, type(ISupportsLimitOrderFill).interfaceId);
458
+ vm.mockCall(address(poolKey.hooks), callData, abi.encode(false));
459
+
460
+ uint256 inputAmount = DEFAULT_LIMIT_ORDER_AMOUNT;
461
+ deal(address(zoraToken), users.buyer, inputAmount);
462
+
463
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildDirectV4SwapParams(
464
+ users.buyer,
465
+ address(zoraToken),
466
+ inputAmount,
467
+ poolKey,
468
+ limitOrderConfig
469
+ );
470
+
471
+ vm.recordLogs();
472
+ _executeSwapWithLimitOrders(users.buyer, params);
473
+
474
+ // Extract order IDs from events
475
+ SwapExecutedLog[] memory swaps = _decodeSwapExecutedLogs(vm.getRecordedLogs());
476
+ require(swaps.length > 0, "expected swap event");
477
+
478
+ assertGt(swaps[0].orders.length, 0, "orders should still be created when hook lacks fill support");
479
+
480
+ vm.clearMockedCalls();
481
+ }
482
+
483
+ function test_routerDoesNotFillWhenMaxFillCountIsZero() public {
484
+ PoolKey memory poolKey = creatorCoin.getPoolKey();
485
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
486
+
487
+ uint256 previousMax = limitOrderBook.getMaxFillCount();
488
+ vm.prank(users.factoryOwner);
489
+ limitOrderBook.setMaxFillCount(0);
490
+
491
+ bytes memory callData = abi.encodeWithSelector(IERC165.supportsInterface.selector, type(ISupportsLimitOrderFill).interfaceId);
492
+ vm.mockCall(address(poolKey.hooks), callData, abi.encode(false));
493
+
494
+ uint256 inputAmount = DEFAULT_LIMIT_ORDER_AMOUNT;
495
+ deal(address(zoraToken), users.buyer, inputAmount);
496
+
497
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildDirectV4SwapParams(
498
+ users.buyer,
499
+ address(zoraToken),
500
+ inputAmount,
501
+ poolKey,
502
+ limitOrderConfig
503
+ );
504
+
505
+ _executeSwapWithLimitOrders(users.buyer, params);
506
+ uint256 ordersFilled = 0; // Note: ordersFilled no longer returned
507
+
508
+ assertEq(ordersFilled, 0, "max fill count zero should short-circuit fills");
509
+
510
+ vm.clearMockedCalls();
511
+ vm.prank(users.factoryOwner);
512
+ limitOrderBook.setMaxFillCount(previousMax);
513
+ }
514
+
515
+ function test_partialAllocationRoutesUnallocatedCoinsToMaker() public {
516
+ PoolKey memory poolKey = creatorCoin.getPoolKey();
517
+
518
+ uint256[] memory customMultiples = new uint256[](2);
519
+ customMultiples[0] = 2e18;
520
+ customMultiples[1] = 4e18;
521
+ uint256[] memory customPercentages = new uint256[](2);
522
+ customPercentages[0] = 2500;
523
+ customPercentages[1] = 2500; // keep 50% unallocated
524
+
525
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, customMultiples, customPercentages);
526
+
527
+ uint256 inputAmount = DEFAULT_LIMIT_ORDER_AMOUNT;
528
+ deal(address(zoraToken), users.buyer, inputAmount);
529
+
530
+ uint256 makerBalanceBefore = IERC20(address(creatorCoin)).balanceOf(users.buyer);
531
+
532
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildDirectV4SwapParams(
533
+ users.buyer,
534
+ address(zoraToken),
535
+ inputAmount,
536
+ poolKey,
537
+ limitOrderConfig
538
+ );
539
+
540
+ vm.recordLogs();
541
+ _executeSwapWithLimitOrders(users.buyer, params);
542
+
543
+ // Extract order IDs from events
544
+ SwapExecutedLog[] memory swaps = _decodeSwapExecutedLogs(vm.getRecordedLogs());
545
+ require(swaps.length > 0, "expected swap event");
546
+
547
+ assertEq(swaps[0].orders.length, 2, "expected two orders for two percentages");
548
+ uint256 makerBalanceAfter = IERC20(address(creatorCoin)).balanceOf(users.buyer);
549
+ assertGt(makerBalanceAfter, makerBalanceBefore, "unallocated coins should be returned to maker");
550
+ }
551
+
552
+ function test_emitsSwapAndCreateEvents() public {
553
+ PoolKey memory poolKey = creatorCoin.getPoolKey();
554
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
555
+
556
+ uint256 inputAmount = DEFAULT_LIMIT_ORDER_AMOUNT;
557
+ deal(address(zoraToken), users.buyer, inputAmount);
558
+
559
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildDirectV4SwapParams(
560
+ users.buyer,
561
+ address(zoraToken),
562
+ inputAmount,
563
+ poolKey,
564
+ limitOrderConfig
565
+ );
566
+
567
+ vm.recordLogs();
568
+ _executeSwapWithLimitOrders(users.buyer, params);
569
+
570
+ Vm.Log[] memory logs = vm.getRecordedLogs();
571
+ bool sawCreated;
572
+ bool sawExecuted;
573
+
574
+ for (uint256 i; i < logs.length; ++i) {
575
+ Vm.Log memory log = logs[i];
576
+ if (log.topics.length == 0) continue;
577
+
578
+ if (log.topics[0] == LIMIT_ORDER_CREATED_TOPIC) {
579
+ address maker = address(uint160(uint256(log.topics[1])));
580
+ assertEq(maker, users.buyer, "maker indexed value mismatch");
581
+ (, , , uint128 orderSize, ) = abi.decode(log.data, (bytes32, bool, int24, uint128, bytes32));
582
+ assertGt(orderSize, 0, "order size should be positive");
583
+ sawCreated = true;
584
+ } else if (log.topics[0] == SWAP_WITH_LIMIT_ORDERS_EXECUTED_TOPIC && log.topics.length >= 3) {
585
+ address sender = address(uint160(uint256(log.topics[1])));
586
+ address recipient = address(uint160(uint256(log.topics[2])));
587
+ assertEq(sender, users.buyer, "sender indexed mismatch");
588
+ assertEq(recipient, users.buyer, "recipient indexed mismatch");
589
+ (PoolKey memory loggedPoolKey, int256 delta, , , CreatedOrder[] memory orders) = abi.decode(
590
+ log.data,
591
+ (PoolKey, int256, int24, int24, CreatedOrder[])
592
+ );
593
+ assertEq(Currency.unwrap(loggedPoolKey.currency0), Currency.unwrap(poolKey.currency0), "pool currency0 mismatch");
594
+ assertEq(Currency.unwrap(loggedPoolKey.currency1), Currency.unwrap(poolKey.currency1), "pool currency1 mismatch");
595
+ assertEq(delta, 0, "returned delta should be zero");
596
+ assertGt(orders.length, 0, "should have created orders");
597
+ sawExecuted = true;
598
+ }
599
+ }
600
+
601
+ assertTrue(sawCreated, "LimitOrdersCreated event missing");
602
+ assertTrue(sawExecuted, "SwapWithLimitOrdersExecuted event missing");
603
+ }
604
+
605
+ function test_unlockCallback_revertsForExternalCaller() public {
606
+ vm.expectRevert(SwapWithLimitOrders.OnlyPoolManager.selector);
607
+ vm.prank(users.buyer);
608
+ swapWithLimitOrders.unlockCallback(bytes(""));
609
+ }
610
+
611
+ function test_reverts_nonPositiveCoinDelta() public {
612
+ // Should revert when swap results in zero or negative coin delta
613
+ // TODO: Implement test for non-positive coin delta
614
+ }
615
+
616
+ function test_inputCurrency_ETH() public {
617
+ // Test with ETH as input currency (address(0))
618
+ // Verify msg.value is used correctly
619
+ }
620
+
621
+ function test_inputCurrency_ERC20() public {
622
+ // Test with ERC20 as input currency
623
+ // Verify token is transferred from maker
624
+ // Verify allowance is checked
625
+ }
626
+
627
+ function test_inputCurrency_transfersFromMaker() public {
628
+ // Verify input currency is pulled from limitOrderConfig.maker
629
+ // Not from msg.sender if different
630
+ }
631
+
632
+ function test_settlement_ETHInput() public {
633
+ // Verify ETH is settled correctly with poolManager
634
+ // Check settle() called with value
635
+ }
636
+
637
+ function test_settlement_ERC20Input() public {
638
+ // Verify ERC20 is settled correctly
639
+ // Check sync() and transfer() and settle() sequence
640
+ }
641
+
642
+ function test_settlement_outputToRecipient() public {
643
+ // Verify output currency is sent to params.recipient
644
+ // Check poolManager.take() called correctly
645
+ }
646
+
647
+ function test_event_SwapWithLimitOrdersExecuted() public {
648
+ // Verify SwapWithLimitOrdersExecuted event emitted with correct params
649
+ // Check sender, recipient, poolKey, deltas, ticks, orderIds, ordersFilled
650
+ }
651
+
652
+ function test_event_LimitOrdersCreated() public {
653
+ // Verify LimitOrdersCreated event emitted
654
+ // Check maker, orderIds, and totalOrderSize
655
+ }
656
+
657
+ function test_event_LimitOrdersFilled() public {
658
+ // Verify LimitOrdersFilled event emitted when router handles fills
659
+ // Should NOT emit when hook handles fills
660
+ }
661
+
662
+ function test_integration_fullFlow_ERC20_to_ContentCoin() public {
663
+ // End-to-end test: Creator Coin -> Content Coin (V4 only)
664
+ // Verify ERC20 transfer, swap, order placement, filling
665
+ }
666
+
667
+ function test_integration_orderPlacedAndFilledInSameCall() public {
668
+ // Verify orders can be placed and immediately filled if tick range crossed
669
+ // Test round-trip efficiency
670
+ }
671
+
672
+ function test_zeroUnallocatedCoins() public {
673
+ // Test when 100% of coins are allocated to orders
674
+ // unallocated should be 0
675
+ }
676
+
677
+ function test_partialAllocation() public {
678
+ // Test when percentages don't sum to 100%
679
+ // Some coins should remain unallocated
680
+ }
681
+
682
+ function test_roundingInOrderSizes() public {
683
+ // Test order size rounding with small percentages
684
+ // Verify no dust orders are created
685
+ }
686
+
687
+ function test_maxTickBoundaries() public {
688
+ // Test limit order placement with extreme multiples that hit max/min tick limits
689
+ // Verify ticks are clamped correctly
690
+ }
691
+ }
692
+
693
+ contract SwapWithLimitOrdersTestForked is SwapWithLimitOrdersTestBase {
694
+ // USDC_ADDRESS and ZORA_TOKEN_ADDRESS are inherited from ContractAddresses
695
+
696
+ function setUp() public override {
697
+ super.setUpWithBlockNumber(37877563);
698
+ }
699
+
700
+ function _encodeV3Path(address tokenA, uint24 feeA, address tokenB, uint24 feeB, address tokenC) internal pure returns (bytes memory) {
701
+ return abi.encodePacked(tokenA, feeA, tokenB, feeB, tokenC);
702
+ }
703
+
704
+ function _encodeV3PathSingle(address tokenA, uint24 fee, address tokenB) internal pure returns (bytes memory) {
705
+ return abi.encodePacked(tokenA, fee, tokenB);
706
+ }
707
+
708
+ function _buildV3ToV4SwapParams(
709
+ address recipient,
710
+ uint256 inputAmount,
711
+ bytes memory v3Route,
712
+ PoolKey[] memory v4Route,
713
+ LimitOrderConfig memory limitOrderConfig
714
+ ) internal pure returns (SwapWithLimitOrders.SwapWithLimitOrdersParams memory) {
715
+ return
716
+ SwapWithLimitOrders.SwapWithLimitOrdersParams({
717
+ recipient: recipient,
718
+ limitOrderConfig: limitOrderConfig,
719
+ inputCurrency: address(0), // ETH
720
+ inputAmount: inputAmount,
721
+ v3Route: v3Route,
722
+ v4Route: v4Route,
723
+ minAmountOut: 0
724
+ });
725
+ }
726
+
727
+ function test_swapWithLimitOrders_withV3Route() public {
728
+ // Test V3 + V4 swap (e.g., ETH -> ZORA via V3, then ZORA -> Coin via V4)
729
+ // V3 route populated, V4 route with target pool
730
+
731
+ uint256 inputAmount = 0.1 ether;
732
+ vm.deal(users.buyer, inputAmount);
733
+
734
+ // Create V3 path: ETH -> USDC -> ZORA
735
+ bytes memory v3Route = _encodeV3Path(
736
+ address(weth),
737
+ 3000, // WETH/USDC 0.3%
738
+ USDC_ADDRESS,
739
+ 3000, // USDC/ZORA 0.3%
740
+ ZORA_TOKEN_ADDRESS
741
+ );
742
+
743
+ // V4 route: Just the target pool (coin paired with ZORA)
744
+ PoolKey[] memory v4Route = new PoolKey[](1);
745
+ v4Route[0] = creatorCoin.getPoolKey();
746
+
747
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
748
+
749
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildV3ToV4SwapParams(users.buyer, inputAmount, v3Route, v4Route, limitOrderConfig);
750
+
751
+ vm.recordLogs();
752
+ _executeSwapWithLimitOrders(users.buyer, params);
753
+
754
+ // Extract order IDs from events
755
+ SwapExecutedLog[] memory swaps = _decodeSwapExecutedLogs(vm.getRecordedLogs());
756
+ require(swaps.length > 0, "expected swap event");
757
+
758
+ // Verify orders were created
759
+ assertGt(swaps[0].orders.length, 0, "should have created orders");
760
+ assertEq(swaps[0].orders.length, 5, "should have 5 orders for 5 percentages");
761
+
762
+ // Verify ETH was spent
763
+ assertEq(users.buyer.balance, 0, "buyer should have spent all ETH");
764
+
765
+ // Note: With default percentages (100% allocated), all coins go into orders
766
+ // The buyer will receive coins when orders are filled by subsequent swaps
767
+ }
768
+
769
+ function test_swapWithLimitOrders_withV3AndMultiHopV4() public {
770
+ // Test V3 + multi-hop V4 (e.g., ETH -> ZORA -> Creator -> Content)
771
+ // V3 route + multiple V4 pools
772
+
773
+ uint256 inputAmount = 0.1 ether;
774
+ vm.deal(users.buyer, inputAmount);
775
+
776
+ // Create V3 path: ETH -> USDC -> ZORA
777
+ bytes memory v3Route = _encodeV3Path(
778
+ address(weth),
779
+ 3000, // WETH/USDC 0.3%
780
+ USDC_ADDRESS,
781
+ 3000, // USDC/ZORA 0.3%
782
+ ZORA_TOKEN_ADDRESS
783
+ );
784
+
785
+ // Multi-hop V4 route: ZORA -> Creator Coin -> Content Coin
786
+ PoolKey[] memory v4Route = new PoolKey[](2);
787
+ v4Route[0] = creatorCoin.getPoolKey();
788
+ v4Route[1] = contentCoin.getPoolKey();
789
+
790
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
791
+
792
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildV3ToV4SwapParams(users.buyer, inputAmount, v3Route, v4Route, limitOrderConfig);
793
+
794
+ vm.recordLogs();
795
+ _executeSwapWithLimitOrders(users.buyer, params);
796
+
797
+ // Extract order IDs from events
798
+ SwapExecutedLog[] memory swaps = _decodeSwapExecutedLogs(vm.getRecordedLogs());
799
+ require(swaps.length > 0, "expected swap event");
800
+
801
+ // Verify orders were created
802
+ assertGt(swaps[0].orders.length, 0, "should have created orders");
803
+ assertEq(swaps[0].orders.length, 5, "should have 5 orders for 5 percentages");
804
+
805
+ // Verify ETH was spent
806
+ assertEq(users.buyer.balance, 0, "buyer should have spent all ETH");
807
+
808
+ // Note: With default percentages (100% allocated), all coins go into orders
809
+ // The buyer will receive content coins when orders are filled by subsequent swaps
810
+ }
811
+
812
+ function test_RevertWhen_InsufficientInputCurrencyETH() public {
813
+ uint256 inputAmount = 1 ether;
814
+ uint256 insufficientAmount = 0.5 ether;
815
+
816
+ // Create V3 path: ETH -> USDC -> ZORA
817
+ bytes memory v3Route = _encodeV3Path(
818
+ address(weth),
819
+ 3000, // WETH/USDC 0.3%
820
+ USDC_ADDRESS,
821
+ 3000, // USDC/ZORA 0.3%
822
+ ZORA_TOKEN_ADDRESS
823
+ );
824
+
825
+ PoolKey[] memory v4Route = new PoolKey[](1);
826
+ v4Route[0] = creatorCoin.getPoolKey();
827
+
828
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
829
+
830
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildV3ToV4SwapParams(users.buyer, inputAmount, v3Route, v4Route, limitOrderConfig);
831
+
832
+ // Should revert with InsufficientInputCurrency
833
+ vm.deal(users.buyer, insufficientAmount);
834
+ vm.expectRevert(abi.encodeWithSelector(V3ToV4SwapLib.InsufficientInputCurrency.selector, inputAmount, insufficientAmount));
835
+
836
+ vm.prank(users.buyer);
837
+ swapWithLimitOrders.swapWithLimitOrders{value: insufficientAmount}(params);
838
+ }
839
+
840
+ function test_RevertWhen_InsufficientOutputAmount() public {
841
+ uint256 inputAmount = 0.1 ether;
842
+ vm.deal(users.buyer, inputAmount);
843
+
844
+ // Create V3 path: ETH -> USDC -> ZORA
845
+ bytes memory v3Route = _encodeV3Path(
846
+ address(weth),
847
+ 3000, // WETH/USDC 0.3%
848
+ USDC_ADDRESS,
849
+ 3000, // USDC/ZORA 0.3%
850
+ ZORA_TOKEN_ADDRESS
851
+ );
852
+
853
+ PoolKey[] memory v4Route = new PoolKey[](1);
854
+ v4Route[0] = creatorCoin.getPoolKey();
855
+
856
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
857
+
858
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildV3ToV4SwapParams(users.buyer, inputAmount, v3Route, v4Route, limitOrderConfig);
859
+
860
+ // Set impossibly high minAmountOut to trigger revert
861
+ params.minAmountOut = type(uint256).max;
862
+
863
+ // Should revert with InsufficientOutputAmount
864
+ vm.expectRevert(SwapWithLimitOrders.InsufficientOutputAmount.selector);
865
+
866
+ vm.prank(users.buyer);
867
+ swapWithLimitOrders.swapWithLimitOrders{value: inputAmount}(params);
868
+ }
869
+
870
+ function test_reverts_v3RouteDoesNotConnectToV4Route() public {
871
+ // Should revert when V3 output currency doesn't match V4 route start
872
+ // Route validation test
873
+
874
+ uint256 inputAmount = 1 ether;
875
+ vm.deal(users.buyer, inputAmount);
876
+
877
+ // Create V3 path that ends with USDC
878
+ bytes memory v3Route = _encodeV3PathSingle(
879
+ address(weth),
880
+ 3000, // WETH/USDC 0.3%
881
+ USDC_ADDRESS
882
+ );
883
+
884
+ // Create V4 route that starts with ZORA (not USDC - mismatch!)
885
+ PoolKey[] memory v4Route = new PoolKey[](1);
886
+ v4Route[0] = creatorCoin.getPoolKey();
887
+
888
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
889
+
890
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildV3ToV4SwapParams(users.buyer, inputAmount, v3Route, v4Route, limitOrderConfig);
891
+
892
+ // Should revert with V3RouteDoesNotConnectToV4RouteStart
893
+ vm.prank(users.buyer);
894
+ vm.expectRevert(abi.encodeWithSelector(V3ToV4SwapLib.V3RouteDoesNotConnectToV4RouteStart.selector));
895
+ swapWithLimitOrders.swapWithLimitOrders{value: inputAmount}(params);
896
+ }
897
+
898
+ function test_integration_fullFlow_ETH_to_ZORA_to_CreatorCoin() public {
899
+ // End-to-end test: ETH -> V3(ZORA) -> V4(Creator Coin)
900
+ // Verify all steps: V3 swap, V4 swap, order placement, order filling
901
+
902
+ uint256 inputAmount = 0.1 ether;
903
+ vm.deal(users.buyer, inputAmount);
904
+
905
+ // Create V3 path: ETH -> USDC -> ZORA
906
+ bytes memory v3Route = _encodeV3Path(
907
+ address(weth),
908
+ 3000, // WETH/USDC 0.3%
909
+ USDC_ADDRESS,
910
+ 3000, // USDC/ZORA 0.3%
911
+ ZORA_TOKEN_ADDRESS
912
+ );
913
+
914
+ // V4 route: ZORA -> Creator Coin
915
+ PoolKey[] memory v4Route = new PoolKey[](1);
916
+ v4Route[0] = creatorCoin.getPoolKey();
917
+
918
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
919
+
920
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = _buildV3ToV4SwapParams(users.buyer, inputAmount, v3Route, v4Route, limitOrderConfig);
921
+
922
+ vm.recordLogs();
923
+ _executeSwapWithLimitOrders(users.buyer, params);
924
+
925
+ // Extract order IDs from events
926
+ SwapExecutedLog[] memory swaps = _decodeSwapExecutedLogs(vm.getRecordedLogs());
927
+ require(swaps.length > 0, "expected swap event");
928
+
929
+ // Verify orders were created
930
+ assertGt(swaps[0].orders.length, 0, "should have created orders");
931
+ assertEq(swaps[0].orders.length, 5, "should have 5 orders for 5 percentages");
932
+
933
+ // Verify ETH was spent
934
+ assertEq(users.buyer.balance, 0, "buyer should have spent all ETH");
935
+
936
+ // Note: With default percentages (100% allocated), all coins go into orders
937
+ // The buyer will receive coins when orders are filled by subsequent swaps
938
+ }
939
+
940
+ function test_swapWithLimitOrders_ERC20toV3toV4_singleHop() public {
941
+ // Give buyer USDC tokens
942
+ uint256 usdcAmount = 100 * 10 ** 6; // 100 USDC
943
+ deal(USDC_ADDRESS, users.buyer, usdcAmount);
944
+
945
+ // Create V3 path: USDC -> ZORA
946
+ bytes memory v3Route = _encodeV3PathSingle(USDC_ADDRESS, 3000, ZORA_TOKEN_ADDRESS);
947
+
948
+ // Create V4 route: ZORA -> Coin
949
+ PoolKey[] memory v4Route = new PoolKey[](1);
950
+ v4Route[0] = creatorCoin.getPoolKey();
951
+
952
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
953
+
954
+ // Build swap params with USDC as input currency
955
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = SwapWithLimitOrders.SwapWithLimitOrdersParams({
956
+ recipient: users.buyer,
957
+ limitOrderConfig: limitOrderConfig,
958
+ inputCurrency: USDC_ADDRESS, // ERC20 input
959
+ inputAmount: usdcAmount,
960
+ v3Route: v3Route,
961
+ v4Route: v4Route,
962
+ minAmountOut: 0
963
+ });
964
+
965
+ // Execute swap - this should FAIL with V3RouteCannotStartWithInputCurrency
966
+ _executeSwapWithLimitOrders(users.buyer, params);
967
+
968
+ // If we get here after implementation, verify USDC was spent and coins received
969
+ assertEq(IERC20(USDC_ADDRESS).balanceOf(users.buyer), 0, "buyer should have spent all USDC");
970
+ assertGt(IERC20(address(creatorCoin)).balanceOf(users.buyer), 0, "buyer should have received coins");
971
+ }
972
+
973
+ function test_swapWithLimitOrders_ZORAtoV4() public {
974
+ // Give buyer ZORA tokens
975
+ uint256 zoraAmount = 10 ether;
976
+ deal(ZORA_TOKEN_ADDRESS, users.buyer, zoraAmount);
977
+
978
+ // Create V4 route: ZORA -> Coin (no V3 swap)
979
+ PoolKey[] memory v4Route = new PoolKey[](1);
980
+ v4Route[0] = creatorCoin.getPoolKey();
981
+
982
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
983
+
984
+ // Build swap params with ZORA as input currency, no V3 route
985
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = SwapWithLimitOrders.SwapWithLimitOrdersParams({
986
+ recipient: users.buyer,
987
+ limitOrderConfig: limitOrderConfig,
988
+ inputCurrency: ZORA_TOKEN_ADDRESS, // ERC20 input
989
+ inputAmount: zoraAmount,
990
+ v3Route: bytes(""), // No V3 swap
991
+ v4Route: v4Route,
992
+ minAmountOut: 0
993
+ });
994
+
995
+ // Execute swap - this should PASS
996
+ _executeSwapWithLimitOrders(users.buyer, params);
997
+
998
+ // Verify ZORA was spent and coins received
999
+ assertEq(IERC20(ZORA_TOKEN_ADDRESS).balanceOf(users.buyer), 0, "buyer should have spent all ZORA");
1000
+ assertGt(IERC20(address(creatorCoin)).balanceOf(users.buyer), 0, "buyer should have received coins");
1001
+ }
1002
+
1003
+ function test_reverts_ERC20InputWithoutApproval() public {
1004
+ // Give buyer USDC tokens
1005
+ uint256 usdcAmount = 100 * 10 ** 6; // 100 USDC
1006
+ deal(USDC_ADDRESS, users.buyer, usdcAmount);
1007
+
1008
+ // Create V3 path: USDC -> ZORA
1009
+ bytes memory v3Route = _encodeV3PathSingle(USDC_ADDRESS, 3000, ZORA_TOKEN_ADDRESS);
1010
+
1011
+ // Create V4 route: ZORA -> Coin
1012
+ PoolKey[] memory v4Route = new PoolKey[](1);
1013
+ v4Route[0] = creatorCoin.getPoolKey();
1014
+
1015
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
1016
+
1017
+ // Build swap params
1018
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = SwapWithLimitOrders.SwapWithLimitOrdersParams({
1019
+ recipient: users.buyer,
1020
+ limitOrderConfig: limitOrderConfig,
1021
+ inputCurrency: USDC_ADDRESS,
1022
+ inputAmount: usdcAmount,
1023
+ v3Route: v3Route,
1024
+ v4Route: v4Route,
1025
+ minAmountOut: 0
1026
+ });
1027
+
1028
+ // Execute without approval - should revert
1029
+ vm.startPrank(users.buyer);
1030
+ // Don't approve - just call directly
1031
+ vm.expectRevert();
1032
+ swapWithLimitOrders.swapWithLimitOrders(params);
1033
+ vm.stopPrank();
1034
+ }
1035
+
1036
+ function test_reverts_ERC20InputInsufficientBalance() public {
1037
+ // Give buyer only 50 USDC but try to swap 100 USDC
1038
+ uint256 balanceAmount = 50 * 10 ** 6; // 50 USDC
1039
+ uint256 swapAmount = 100 * 10 ** 6; // 100 USDC
1040
+ deal(USDC_ADDRESS, users.buyer, balanceAmount);
1041
+
1042
+ // Create V3 path: USDC -> ZORA
1043
+ bytes memory v3Route = _encodeV3PathSingle(USDC_ADDRESS, 3000, ZORA_TOKEN_ADDRESS);
1044
+
1045
+ // Create V4 route: ZORA -> Coin
1046
+ PoolKey[] memory v4Route = new PoolKey[](1);
1047
+ v4Route[0] = creatorCoin.getPoolKey();
1048
+
1049
+ LimitOrderConfig memory limitOrderConfig = _prepareLimitOrderParams(users.buyer, _defaultMultiples(), _defaultPercentages());
1050
+
1051
+ // Build swap params with more than available balance
1052
+ SwapWithLimitOrders.SwapWithLimitOrdersParams memory params = SwapWithLimitOrders.SwapWithLimitOrdersParams({
1053
+ recipient: users.buyer,
1054
+ limitOrderConfig: limitOrderConfig,
1055
+ inputCurrency: USDC_ADDRESS,
1056
+ inputAmount: swapAmount,
1057
+ v3Route: v3Route,
1058
+ v4Route: v4Route,
1059
+ minAmountOut: 0
1060
+ });
1061
+
1062
+ // Execute with insufficient balance - should revert from Permit2
1063
+ vm.startPrank(users.buyer);
1064
+ address permit2 = AddressConstants.getPermit2Address();
1065
+ IERC20(USDC_ADDRESS).approve(permit2, type(uint256).max);
1066
+ IAllowanceTransfer(permit2).approve(USDC_ADDRESS, address(swapWithLimitOrders), uint160(type(uint160).max), type(uint48).max);
1067
+
1068
+ // With Permit2, when transferring more than balance, the underlying ERC20 transferFrom fails
1069
+ vm.expectRevert("TRANSFER_FROM_FAILED");
1070
+ swapWithLimitOrders.swapWithLimitOrders(params);
1071
+ vm.stopPrank();
1072
+ }
1073
+ }