@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,209 @@
1
+ // SPDX-License-Identifier: ZORA-DELAYED-OSL-v1
2
+ // This software is licensed under the Zora Delayed Open Source License.
3
+ // Under this license, you may use, copy, modify, and distribute this software for
4
+ // non-commercial purposes only. Commercial use and competitive products are prohibited
5
+ // until the "Open Date" (3 years from first public distribution or earlier at Zora's discretion),
6
+ // at which point this software automatically becomes available under the MIT License.
7
+ // Full license terms available at: https://docs.zora.co/coins/license
8
+ pragma solidity ^0.8.28;
9
+
10
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
11
+ import {TickMath} from "@zoralabs/coins/src/utils/uniswap/TickMath.sol";
12
+ import {FullMath} from "@zoralabs/coins/src/utils/uniswap/FullMath.sol";
13
+ import {DopplerMath} from "@zoralabs/coins/src/libs/DopplerMath.sol";
14
+
15
+ /// @dev Configuration for a limit order ladder
16
+ /// @param multiples Price multiples for each order (e.g., 2e18 = 2x current price)
17
+ /// @param percentages Percentage of total size for each order (basis points, must sum ≤ 10000)
18
+ struct LimitOrderConfig {
19
+ uint256[] multiples;
20
+ uint256[] percentages;
21
+ }
22
+
23
+ /// @dev Computed limit orders ready for execution
24
+ /// @param sizes Amount of coins in each order
25
+ /// @param ticks Uniswap tick for each order
26
+ /// @param multiples Price multiple used for each order (tracks which config entry)
27
+ /// @param percentages Percentage used for each order (tracks which config entry)
28
+ struct Orders {
29
+ uint256[] sizes;
30
+ int24[] ticks;
31
+ uint256[] multiples;
32
+ uint256[] percentages;
33
+ }
34
+
35
+ /// @title SwapLimitOrders
36
+ /// @notice Computes limit order ladders on coin swaps
37
+ library SwapLimitOrders {
38
+ /// @dev 1.0x price multiplier (e.g., 2e18 = 2x)
39
+ uint256 internal constant MULTIPLE_SCALE = 1e18;
40
+
41
+ /// @dev 100% in basis points (e.g., 5000 = 50%)
42
+ uint256 internal constant PERCENT_SCALE = 10_000;
43
+
44
+ /// @dev sqrt(1e18) - scales sqrt calculations without precision loss
45
+ uint256 internal constant SQRT_MULTIPLE_SCALE = 1e9;
46
+
47
+ /// @dev Minimum coins to create orders - prevents dust
48
+ uint256 internal constant MIN_LIMIT_ORDER_SIZE = 1e18;
49
+
50
+ /// @notice Multiples and percentages arrays have different lengths
51
+ error LengthMismatch();
52
+
53
+ /// @notice Percentages sum exceeds 100%
54
+ error PercentOverflow();
55
+
56
+ /// @notice A percentage is zero
57
+ error InvalidPercent();
58
+
59
+ /// @notice A multiple is ≤ 1.0x
60
+ error InvalidMultiple();
61
+
62
+ /// @notice Validates a limit order configuration
63
+ /// @param config The configuration to validate
64
+ /// @return totalPercent Sum of all percentages (for caller's use)
65
+ /// @dev Reverts if:
66
+ /// - Arrays are empty or mismatched length
67
+ /// - Any percentage is zero
68
+ /// - Any multiple is ≤ 1.0x
69
+ /// - Percentages sum > 100%
70
+ function validate(LimitOrderConfig memory config) internal pure returns (uint256 totalPercent) {
71
+ uint256 length = config.multiples.length;
72
+
73
+ require(length > 0 && length == config.percentages.length, LengthMismatch());
74
+
75
+ unchecked {
76
+ for (uint256 i; i < length; ++i) {
77
+ require(config.percentages[i] != 0, InvalidPercent());
78
+ require(config.multiples[i] > MULTIPLE_SCALE, InvalidMultiple());
79
+ totalPercent += config.percentages[i]; // Bounded by PERCENT_SCALE check below
80
+ }
81
+ }
82
+
83
+ require(totalPercent <= PERCENT_SCALE, PercentOverflow());
84
+ }
85
+
86
+ /// @notice Computes limit order sizes and ticks from a configuration
87
+ /// @param key The Uniswap pool
88
+ /// @param isCurrency0 True if placing orders for currency0, false for currency1
89
+ /// @param totalSize Total coins to distribute across orders
90
+ /// @param baseTick Current pool tick (orders placed at least 1 tick spacing away)
91
+ /// @param sqrtPriceX96 Current pool sqrt price
92
+ /// @param config The limit order configuration
93
+ /// @return o Orders ready for creation (may have fewer entries than config if some rounded to zero)
94
+ /// @return allocated Amount of totalSize allocated to orders
95
+ /// @return unallocated Amount of totalSize not allocated (dust or partial fill)
96
+ /// @dev Orders are sized sequentially: each order takes its percentage of remaining balance.
97
+ /// Orders with zero size after rounding are skipped - arrays shrink to match.
98
+ /// Returns empty arrays if totalSize < MIN_LIMIT_ORDER_SIZE.
99
+ function computeOrders(
100
+ PoolKey memory key,
101
+ bool isCurrency0,
102
+ uint128 totalSize,
103
+ int24 baseTick,
104
+ uint160 sqrtPriceX96,
105
+ LimitOrderConfig memory config
106
+ ) internal pure returns (Orders memory o, uint128 allocated, uint128 unallocated) {
107
+ if (totalSize < MIN_LIMIT_ORDER_SIZE) {
108
+ unallocated = uint128(totalSize);
109
+ return (o, allocated, unallocated);
110
+ }
111
+
112
+ uint256 orderCount = config.multiples.length;
113
+
114
+ o.sizes = new uint256[](orderCount);
115
+ o.ticks = new int24[](orderCount);
116
+ o.multiples = new uint256[](orderCount);
117
+ o.percentages = new uint256[](orderCount);
118
+
119
+ uint128 remaining = totalSize;
120
+ uint256 count;
121
+
122
+ for (uint256 i; i < orderCount; ++i) {
123
+ uint256 orderSize = FullMath.mulDiv(uint256(remaining), config.percentages[i], PERCENT_SCALE);
124
+ if (orderSize == 0) continue;
125
+
126
+ allocated += uint128(orderSize);
127
+ remaining -= uint128(orderSize);
128
+
129
+ int24 targetTick = _tickForMultiple(key, isCurrency0, baseTick, sqrtPriceX96, config.multiples[i]);
130
+
131
+ o.sizes[count] = orderSize;
132
+ o.ticks[count] = targetTick;
133
+ o.multiples[count] = config.multiples[i];
134
+ o.percentages[count] = config.percentages[i];
135
+
136
+ unchecked {
137
+ ++count;
138
+ }
139
+ }
140
+
141
+ assembly ("memory-safe") {
142
+ // Shrink arrays in place so the caller only sees populated entries
143
+ mstore(mload(o), count)
144
+ mstore(mload(add(o, 0x20)), count)
145
+ mstore(mload(add(o, 0x40)), count)
146
+ mstore(mload(add(o, 0x60)), count)
147
+ }
148
+
149
+ unallocated = remaining;
150
+ }
151
+
152
+ /// @notice Converts a price multiple to a valid Uniswap tick
153
+ /// @param key The pool (for tick spacing and bounds)
154
+ /// @param isCurrency0 True for buys (tick > base), false for sells (tick < base)
155
+ /// @param baseTick Current pool tick
156
+ /// @param sqrtPriceX96 Current pool sqrt price
157
+ /// @param multiple Desired price multiple (e.g., 2e18 = 2x)
158
+ /// @return aligned Valid tick respecting spacing, bounds, and minimum separation from baseTick
159
+ function _tickForMultiple(
160
+ PoolKey memory key,
161
+ bool isCurrency0,
162
+ int24 baseTick,
163
+ uint160 sqrtPriceX96,
164
+ uint256 multiple
165
+ ) private pure returns (int24 aligned) {
166
+ require(multiple > MULTIPLE_SCALE, InvalidMultiple());
167
+
168
+ uint256 sqrtMultiplier = _sqrtMultiple(multiple);
169
+ if (!isCurrency0) {
170
+ sqrtMultiplier = (SQRT_MULTIPLE_SCALE * SQRT_MULTIPLE_SCALE) / sqrtMultiplier;
171
+ }
172
+
173
+ uint256 scaled = FullMath.mulDiv(uint256(sqrtPriceX96), sqrtMultiplier, SQRT_MULTIPLE_SCALE);
174
+ if (scaled > type(uint160).max) scaled = type(uint160).max;
175
+
176
+ int24 rawTick = TickMath.getTickAtSqrtPrice(uint160(scaled));
177
+ aligned = DopplerMath.alignTickToTickSpacing(isCurrency0, rawTick, key.tickSpacing);
178
+
179
+ int24 maxTick = TickMath.maxUsableTick(key.tickSpacing);
180
+ int24 minTick = -maxTick;
181
+
182
+ if (aligned > maxTick) {
183
+ aligned = maxTick;
184
+ } else if (aligned < minTick) {
185
+ aligned = minTick;
186
+ }
187
+
188
+ int24 minAway = baseTick + (isCurrency0 ? key.tickSpacing : -key.tickSpacing);
189
+ if (isCurrency0) {
190
+ if (aligned < minAway) aligned = minAway;
191
+ } else {
192
+ if (aligned > minAway) aligned = minAway;
193
+ }
194
+ }
195
+
196
+ /// @notice Computes square root of a 1e18-scaled value using Babylonian method
197
+ /// @param multiple Value to take sqrt of (e.g., 4e18 → 2e9)
198
+ /// @return result Square root with 1e9 scaling
199
+ /// @dev Uses iterative approximation: x_next = (x + multiple/x) / 2
200
+ function _sqrtMultiple(uint256 multiple) private pure returns (uint256 result) {
201
+ result = multiple;
202
+ uint256 x = (multiple + 1) >> 1;
203
+ while (x < result) {
204
+ result = x;
205
+ x = (multiple / x + x) >> 1;
206
+ }
207
+ require(result != 0, InvalidMultiple());
208
+ }
209
+ }
@@ -0,0 +1,454 @@
1
+ // SPDX-License-Identifier: ZORA-DELAYED-OSL-v1
2
+ // This software is licensed under the Zora Delayed Open Source License.
3
+ // Under this license, you may use, copy, modify, and distribute this software for
4
+ // non-commercial purposes only. Commercial use and competitive products are prohibited
5
+ // until the "Open Date" (3 years from first public distribution or earlier at Zora's discretion),
6
+ // at which point this software automatically becomes available under the MIT License.
7
+ // Full license terms available at: https://docs.zora.co/coins/license
8
+ pragma solidity ^0.8.28;
9
+
10
+ import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
11
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
12
+ import {BalanceDelta, BalanceDeltaLibrary} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
13
+ import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol";
14
+ import {PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol";
15
+ import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol";
16
+ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
17
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
18
+
19
+ import {IZoraLimitOrderBook} from "../IZoraLimitOrderBook.sol";
20
+ import {SwapLimitOrders, LimitOrderConfig, Orders} from "../libs/SwapLimitOrders.sol";
21
+ import {ISwapRouter} from "@zoralabs/shared-contracts/interfaces/uniswap/ISwapRouter.sol";
22
+ import {ISupportsLimitOrderFill} from "@zoralabs/coins/src/interfaces/ISupportsLimitOrderFill.sol";
23
+ import {IMsgSender} from "@zoralabs/coins/src/interfaces/IMsgSender.sol";
24
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
25
+ import {TransientSlot} from "@openzeppelin/contracts/utils/TransientSlot.sol";
26
+ import {Path} from "@zoralabs/shared-contracts/libs/UniswapV3/Path.sol";
27
+ import {V3ToV4SwapLib} from "@zoralabs/coins/src/libs/V3ToV4SwapLib.sol";
28
+ import {SimpleAccessManaged} from "../access/SimpleAccessManaged.sol";
29
+ import {Permit2Payments} from "../libs/Permit2Payments.sol";
30
+
31
+ /// @title SwapWithLimitOrders
32
+ /// @notice Standalone router contract that executes swaps with automatic limit order placement and filling.
33
+ /// @dev This contract uses the poolManager unlock/callback pattern to execute swaps, place limit orders
34
+ /// based on the tick range crossed during the swap, and attempt to fill those orders in a single transaction.
35
+ /// Users call swapWithLimitOrders() directly, which triggers the unlock callback flow.
36
+ /// Uses Permit2 for token approvals, matching the universal-router pattern.
37
+ /// @author oveddan
38
+ contract SwapWithLimitOrders is IMsgSender, Permit2Payments {
39
+ using SafeERC20 for IERC20;
40
+ using BalanceDeltaLibrary for BalanceDelta;
41
+ using CurrencyLibrary for Currency;
42
+ using PoolIdLibrary for PoolKey;
43
+ using Path for bytes;
44
+
45
+ /// @notice The Uniswap V4 pool manager
46
+ IPoolManager public immutable poolManager;
47
+
48
+ /// @notice The limit order book contract
49
+ IZoraLimitOrderBook public immutable zoraLimitOrderBook;
50
+
51
+ /// @notice The Uniswap V3 swap router
52
+ ISwapRouter public immutable swapRouter;
53
+
54
+ /// @notice Canonical limit order configuration
55
+ LimitOrderConfig private _limitOrderConfig;
56
+
57
+ /// @notice Transient storage slot for tracking the current maker during swap execution
58
+ bytes32 private constant _MAKER_SLOT = keccak256("SwapWithLimitOrders.maker");
59
+
60
+ /// @notice Parameters for executing a swap with limit order placement
61
+ struct SwapWithLimitOrdersParams {
62
+ address recipient; // Who receives the swap output
63
+ LimitOrderConfig limitOrderConfig; // Limit order configuration
64
+ address inputCurrency; // Currency to use for swap (address(0) for ETH)
65
+ uint256 inputAmount; // Amount of input currency
66
+ bytes v3Route; // V3 route from input → backing currency (empty if not needed)
67
+ PoolKey[] v4Route; // V4 route including target pool as last element
68
+ uint256 minAmountOut; // Minimum amount of coins to receive from final swap
69
+ }
70
+
71
+ /// @notice Internal callback data passed to unlockCallback
72
+ struct CallbackData {
73
+ address recipient;
74
+ PoolKey[] v4Route; // Target pool is last element
75
+ uint256 currencyAmount; // Amount after V3 swap
76
+ address currencyReceived; // Currency received from V3 swap
77
+ uint256 minAmountOut;
78
+ LimitOrderConfig limitOrderConfig; // Limit order configuration for order creation
79
+ }
80
+
81
+ /// @notice Data returned from unlockCallback
82
+ struct UnlockResult {
83
+ uint256 coinAmount; // Amount of coins received
84
+ address coinAddress; // Address of the coin
85
+ bool isCoinCurrency0; // Whether coin is currency0 in target pool
86
+ int24 currentTick; // Tick after swaps
87
+ uint160 sqrtPriceX96; // Price after swaps
88
+ }
89
+
90
+ /// @notice Represents a limit order created with its configuration
91
+ struct CreatedOrder {
92
+ bytes32 orderId; // The order ID
93
+ uint256 multiple; // The price multiple used (e.g., 2e18 for 2x)
94
+ uint256 percentage; // The percentage of swap output allocated (basis points)
95
+ }
96
+
97
+ /// @notice Emitted when a swap with limit order placement is executed
98
+ /// @param orders Array of created orders with their configuration. Only includes orders
99
+ /// that were actually created (skipped rungs due to rounding are omitted).
100
+ event SwapWithLimitOrdersExecuted(
101
+ address indexed sender,
102
+ address indexed recipient,
103
+ PoolKey poolKey,
104
+ BalanceDelta delta,
105
+ int24 tickBeforeSwap,
106
+ int24 tickAfterSwap,
107
+ CreatedOrder[] orders
108
+ );
109
+
110
+ /// @notice Emitted when limit order config is updated
111
+ event LimitOrderConfigUpdated(uint256[] multiples, uint256[] percentages);
112
+
113
+ /// @notice Error thrown when caller is not the pool manager
114
+ error OnlyPoolManager();
115
+
116
+ /// @notice Error thrown when caller is not the authority
117
+ error OnlyAuthority();
118
+
119
+ /// @notice Error thrown when config does not match canonical config
120
+ error InvalidLimitOrderConfig();
121
+
122
+ /// @notice Error thrown when swap delta is zero
123
+ error ZeroSwapDelta();
124
+
125
+ /// @notice Error thrown when final swap output is below minimum
126
+ error InsufficientOutputAmount();
127
+
128
+ /// @notice Error thrown when v4Route is empty
129
+ error EmptyV4Route();
130
+
131
+ /// @notice Constructor
132
+ /// @param poolManager_ The Uniswap V4 pool manager
133
+ /// @param zoraLimitOrderBook_ The limit order book contract
134
+ /// @param swapRouter_ The Uniswap V3 swap router
135
+ /// @param permit2_ The Permit2 contract address (0x000000000022D473030F116dDEE9F6B43aC78BA3)
136
+ constructor(IPoolManager poolManager_, IZoraLimitOrderBook zoraLimitOrderBook_, ISwapRouter swapRouter_, address permit2_) Permit2Payments(permit2_) {
137
+ require(address(poolManager_) != address(0), "PoolManager cannot be zero");
138
+ require(address(zoraLimitOrderBook_) != address(0), "ZoraLimitOrderBook cannot be zero");
139
+ require(address(swapRouter_) != address(0), "SwapRouter cannot be zero");
140
+ require(permit2_ != address(0), "Permit2 cannot be zero");
141
+ poolManager = poolManager_;
142
+ zoraLimitOrderBook = zoraLimitOrderBook_;
143
+ swapRouter = swapRouter_;
144
+ }
145
+
146
+ /// @inheritdoc IMsgSender
147
+ function msgSender() external view returns (address) {
148
+ TransientSlot.AddressSlot slot = TransientSlot.asAddress(_MAKER_SLOT);
149
+ return TransientSlot.tload(slot);
150
+ }
151
+
152
+ /// @notice Sets the canonical limit order configuration
153
+ /// @dev Only callable by zoraLimitOrderBook.authority()
154
+ /// @param config The new limit order configuration
155
+ function setLimitOrderConfig(LimitOrderConfig memory config) external {
156
+ require(msg.sender == SimpleAccessManaged(address(zoraLimitOrderBook)).authority(), OnlyAuthority());
157
+ SwapLimitOrders.validate(config);
158
+ _limitOrderConfig = config;
159
+ emit LimitOrderConfigUpdated(config.multiples, config.percentages);
160
+ }
161
+
162
+ /// @notice Returns the current limit order configuration
163
+ /// @return The current limit order configuration
164
+ function getLimitOrderConfig() external view returns (LimitOrderConfig memory) {
165
+ return _limitOrderConfig;
166
+ }
167
+
168
+ /// @notice Executes a swap with automatic limit order placement and filling
169
+ /// @param params The swap and limit order parameters
170
+ /// @return delta The balance delta from the swap
171
+ function swapWithLimitOrders(SwapWithLimitOrdersParams calldata params) external payable returns (BalanceDelta delta) {
172
+ // Store recipient (maker) in transient storage for IMsgSender interface
173
+ TransientSlot.AddressSlot slot = TransientSlot.asAddress(_MAKER_SLOT);
174
+ TransientSlot.tstore(slot, params.recipient);
175
+
176
+ // Validate limit order parameters (signature, percentages, multiples, etc.)
177
+ SwapLimitOrders.validate(params.limitOrderConfig);
178
+
179
+ // Validate config matches canonical config
180
+ _validateConfigMatchesCurrent(params.limitOrderConfig);
181
+
182
+ // Require v4Route has at least one pool (the target)
183
+ require(params.v4Route.length > 0, EmptyV4Route());
184
+
185
+ // Validate routes
186
+ V3ToV4SwapLib.validateRoutes(params.v3Route, params.inputCurrency, params.v4Route);
187
+
188
+ // Validate and transfer input currency from msg.sender using Permit2
189
+ V3ToV4SwapLib.permit2TransferFrom(PERMIT2, params.inputCurrency, params.inputAmount, msg.sender, address(this), msg.value);
190
+
191
+ // Get target pool (last element in v4Route)
192
+ PoolKey memory targetPool = params.v4Route[params.v4Route.length - 1];
193
+
194
+ // Get tick before swap
195
+ (, int24 tickBeforeSwap, , ) = StateLibrary.getSlot0(poolManager, targetPool.toId());
196
+
197
+ // Execute V3 swap (inputCurrency -> backing currency)
198
+ (uint256 currencyAmount, address currencyReceived) = V3ToV4SwapLib.executeV3Swap(
199
+ swapRouter,
200
+ V3ToV4SwapLib.V3SwapParams({
201
+ v3Route: params.v3Route,
202
+ inputCurrency: params.inputCurrency,
203
+ inputAmount: params.inputAmount,
204
+ recipient: address(this)
205
+ })
206
+ );
207
+
208
+ // Prepare callback data for V4 swaps + limit order creation
209
+ CallbackData memory callbackData = CallbackData({
210
+ recipient: params.recipient,
211
+ v4Route: params.v4Route,
212
+ currencyAmount: currencyAmount,
213
+ currencyReceived: currencyReceived,
214
+ minAmountOut: params.minAmountOut,
215
+ limitOrderConfig: params.limitOrderConfig
216
+ });
217
+
218
+ // Execute V4 swaps + create orders via unlock callback
219
+ bytes memory result = poolManager.unlock(abi.encode(callbackData));
220
+
221
+ (CreatedOrder[] memory orders, bool isCoinCurrency0, int24 tickAfterSwap) = abi.decode(result, (CreatedOrder[], bool, int24));
222
+
223
+ // Check if hook supports limit order filling using ERC165
224
+ bool hookSupportsFill = IERC165(address(targetPool.hooks)).supportsInterface(type(ISupportsLimitOrderFill).interfaceId);
225
+
226
+ // Router-based filling for legacy hooks
227
+ if (!hookSupportsFill && orders.length > 0 && tickBeforeSwap != tickAfterSwap) {
228
+ _fillOrders(targetPool, !isCoinCurrency0, tickBeforeSwap, tickAfterSwap);
229
+ }
230
+
231
+ emit SwapWithLimitOrdersExecuted(msg.sender, params.recipient, targetPool, BalanceDelta.wrap(0), tickBeforeSwap, tickAfterSwap, orders);
232
+
233
+ // Clear maker from transient storage
234
+ TransientSlot.tstore(slot, address(0));
235
+
236
+ return BalanceDelta.wrap(0);
237
+ }
238
+
239
+ /// @notice Callback function called by the pool manager during unlock
240
+ /// @dev This function executes V4 swaps and settles coins to recipient
241
+ /// @param data Encoded CallbackData
242
+ /// @return Encoded UnlockResult containing coin amount and pool info
243
+ function unlockCallback(bytes calldata data) external returns (bytes memory) {
244
+ if (msg.sender != address(poolManager)) {
245
+ revert OnlyPoolManager();
246
+ }
247
+
248
+ CallbackData memory callbackData = abi.decode(data, (CallbackData));
249
+
250
+ // Execute V4 multi-hop swap
251
+ V3ToV4SwapLib.V4SwapResult memory swapResult = _executeV4Swaps(callbackData);
252
+
253
+ // Get target pool and coin info
254
+ (PoolKey memory targetPool, bool isCoinCurrency0, address coinAddress) = _getTargetPoolInfo(callbackData.v4Route, swapResult.outputCurrency);
255
+
256
+ // Get current pool state after swap
257
+ (uint160 sqrtPriceX96, int24 currentTick) = _getPoolState(targetPool);
258
+
259
+ // Create limit orders
260
+ (CreatedOrder[] memory createdOrders, uint128 unallocated) = _createLimitOrders(
261
+ targetPool,
262
+ isCoinCurrency0,
263
+ coinAddress,
264
+ swapResult.outputAmount,
265
+ currentTick,
266
+ sqrtPriceX96,
267
+ callbackData.limitOrderConfig,
268
+ callbackData.recipient
269
+ );
270
+
271
+ // Settle currencies with pool manager
272
+ _settleCurrencies(callbackData.currencyReceived, callbackData.currencyAmount, coinAddress, unallocated, callbackData.recipient);
273
+
274
+ return abi.encode(createdOrders, isCoinCurrency0, currentTick);
275
+ }
276
+
277
+ /// @notice Executes V4 multi-hop swaps and validates output
278
+ /// @param callbackData The callback data containing swap parameters
279
+ /// @return swapResult The result of the V4 swap containing output amount and currency
280
+ function _executeV4Swaps(CallbackData memory callbackData) internal returns (V3ToV4SwapLib.V4SwapResult memory swapResult) {
281
+ swapResult = V3ToV4SwapLib.executeV4MultiHopSwap(
282
+ poolManager,
283
+ V3ToV4SwapLib.V4SwapParams({
284
+ v4Route: callbackData.v4Route,
285
+ amountIn: callbackData.currencyAmount,
286
+ startingCurrency: Currency.wrap(callbackData.currencyReceived)
287
+ })
288
+ );
289
+
290
+ // Validate minimum output amount
291
+ require(swapResult.outputAmount >= callbackData.minAmountOut, InsufficientOutputAmount());
292
+ }
293
+
294
+ /// @notice Gets target pool and coin information
295
+ /// @param v4Route The V4 route array
296
+ /// @param outputCurrency The output currency from swaps
297
+ /// @return targetPool The target pool (last pool in route)
298
+ /// @return isCoinCurrency0 Whether the coin is currency0 in the pool
299
+ /// @return coinAddress The address of the coin
300
+ function _getTargetPoolInfo(
301
+ PoolKey[] memory v4Route,
302
+ Currency outputCurrency
303
+ ) internal pure returns (PoolKey memory targetPool, bool isCoinCurrency0, address coinAddress) {
304
+ uint256 targetPoolIndex = v4Route.length - 1;
305
+ targetPool = v4Route[targetPoolIndex];
306
+ coinAddress = Currency.unwrap(outputCurrency);
307
+ isCoinCurrency0 = Currency.unwrap(targetPool.currency0) == coinAddress;
308
+ }
309
+
310
+ /// @notice Gets current tick and price from pool
311
+ /// @param targetPool The pool to query
312
+ /// @return sqrtPriceX96 The current sqrt price
313
+ /// @return tick The current tick
314
+ function _getPoolState(PoolKey memory targetPool) internal view returns (uint160 sqrtPriceX96, int24 tick) {
315
+ (sqrtPriceX96, tick, , ) = StateLibrary.getSlot0(poolManager, targetPool.toId());
316
+ }
317
+
318
+ /// @notice Creates limit orders with metadata from swap output
319
+ /// @param targetPool The target pool for the orders
320
+ /// @param isCoinCurrency0 Whether the coin is currency0
321
+ /// @param coinAddress The address of the coin
322
+ /// @param coinAmount The amount of coins received from swap
323
+ /// @param currentTick The current tick after swap
324
+ /// @param sqrtPriceX96 The current sqrt price after swap
325
+ /// @param limitOrderConfig The limit order configuration
326
+ /// @param maker The maker address (order owner/recipient)
327
+ /// @return createdOrders Array of CreatedOrder structs with orderIds and config
328
+ /// @return unallocated The amount not allocated to orders (goes to maker)
329
+ function _createLimitOrders(
330
+ PoolKey memory targetPool,
331
+ bool isCoinCurrency0,
332
+ address coinAddress,
333
+ uint128 coinAmount,
334
+ int24 currentTick,
335
+ uint160 sqrtPriceX96,
336
+ LimitOrderConfig memory limitOrderConfig,
337
+ address maker
338
+ ) internal returns (CreatedOrder[] memory createdOrders, uint128 unallocated) {
339
+ uint128 allocated;
340
+ Orders memory orders;
341
+ // Compute limit orders
342
+ (orders, allocated, unallocated) = SwapLimitOrders.computeOrders(targetPool, isCoinCurrency0, coinAmount, currentTick, sqrtPriceX96, limitOrderConfig);
343
+
344
+ // Create orders if there are any to create
345
+ if (orders.sizes.length > 0 && allocated > 0) {
346
+ // Take allocated coins from pool manager to this contract
347
+ poolManager.take(Currency.wrap(coinAddress), address(this), allocated);
348
+
349
+ // Set value for ETH transfers (0 for ERC20, allocated for ETH)
350
+ uint256 value = coinAddress != address(0) ? 0 : allocated;
351
+
352
+ // For ERC20, approve the order book to spend the coins
353
+ if (coinAddress != address(0)) {
354
+ IERC20(coinAddress).approve(address(zoraLimitOrderBook), allocated);
355
+ }
356
+
357
+ // Create orders with prefunded path
358
+ bytes32[] memory orderIds = zoraLimitOrderBook.create{value: value}(targetPool, isCoinCurrency0, orders.sizes, orders.ticks, maker);
359
+
360
+ createdOrders = new CreatedOrder[](orderIds.length);
361
+ unchecked {
362
+ for (uint256 i; i < orderIds.length; ++i) {
363
+ createdOrders[i] = CreatedOrder({orderId: orderIds[i], multiple: orders.multiples[i], percentage: orders.percentages[i]});
364
+ }
365
+ }
366
+ } else {
367
+ createdOrders = new CreatedOrder[](0);
368
+ }
369
+ }
370
+
371
+ /// @notice Settles input currency and distributes output coins
372
+ /// @param inputCurrency The input currency address
373
+ /// @param inputAmount The input currency amount
374
+ /// @param coinAddress The coin address
375
+ /// @param unallocated The unallocated coin amount to send to maker
376
+ /// @param maker The maker address (buyer)
377
+ function _settleCurrencies(address inputCurrency, uint256 inputAmount, address coinAddress, uint128 unallocated, address maker) internal {
378
+ // Settle input currency with pool manager
379
+ _transferFundsToPoolManager(inputCurrency, inputAmount);
380
+
381
+ // Take unallocated coins to maker (buyer)
382
+ if (unallocated > 0) {
383
+ poolManager.take(Currency.wrap(coinAddress), maker, unallocated);
384
+ }
385
+ }
386
+
387
+ function _transferFundsToPoolManager(address token, uint256 amount) internal {
388
+ Currency currency = Currency.wrap(token);
389
+ // Settle input currency
390
+ // if erc20 currency, sync and transfer
391
+ if (!currency.isAddressZero()) {
392
+ poolManager.sync(currency);
393
+
394
+ // transfer with balance check
395
+ uint256 beforeBalance = currency.balanceOf(address(poolManager));
396
+ currency.transfer(address(poolManager), amount);
397
+ require(currency.balanceOf(address(poolManager)) == beforeBalance + amount, IZoraLimitOrderBook.InsufficientTransferFunds());
398
+
399
+ poolManager.settle();
400
+ } else {
401
+ poolManager.settle{value: amount}();
402
+ }
403
+ }
404
+
405
+ /// @notice Fills limit orders within the tick range crossed by the swap
406
+ /// @param poolKey The pool key
407
+ /// @param isCurrency0 Whether to fill currency0 orders
408
+ /// @param tickBeforeSwap The tick before the swap
409
+ /// @param tickAfterSwap The tick after the swap
410
+ function _fillOrders(PoolKey memory poolKey, bool isCurrency0, int24 tickBeforeSwap, int24 tickAfterSwap) internal {
411
+ // Ensure ticks are in the correct order for fill validation
412
+ // For currency0 orders: startTick <= endTick (ascending)
413
+ // For currency1 orders: startTick >= endTick (descending)
414
+ int24 startTick;
415
+ int24 endTick;
416
+ if (isCurrency0) {
417
+ // Currency0 orders need ascending tick range
418
+ startTick = tickBeforeSwap < tickAfterSwap ? tickBeforeSwap : tickAfterSwap;
419
+ endTick = tickBeforeSwap < tickAfterSwap ? tickAfterSwap : tickBeforeSwap;
420
+ } else {
421
+ // Currency1 orders need descending tick range
422
+ startTick = tickBeforeSwap > tickAfterSwap ? tickBeforeSwap : tickAfterSwap;
423
+ endTick = tickBeforeSwap > tickAfterSwap ? tickAfterSwap : tickBeforeSwap;
424
+ }
425
+
426
+ // Call fill in locked mode - will trigger unlock/callback flow in ZoraLimitOrderBook
427
+ zoraLimitOrderBook.fill(poolKey, isCurrency0, startTick, endTick, 0, address(0));
428
+ }
429
+
430
+ /// @notice Validates that the provided config matches the canonical config
431
+ /// @param config The config to validate
432
+ function _validateConfigMatchesCurrent(LimitOrderConfig memory config) internal view {
433
+ uint256 canonicalLength = _limitOrderConfig.multiples.length;
434
+
435
+ // If canonical config is uninitialized, skip validation
436
+ if (canonicalLength == 0) return;
437
+
438
+ // Check array lengths match
439
+ require(config.multiples.length == canonicalLength && config.percentages.length == canonicalLength, InvalidLimitOrderConfig());
440
+
441
+ // Validate all values in single loop
442
+ unchecked {
443
+ for (uint256 i; i < canonicalLength; ++i) {
444
+ require(
445
+ config.multiples[i] == _limitOrderConfig.multiples[i] && config.percentages[i] == _limitOrderConfig.percentages[i],
446
+ InvalidLimitOrderConfig()
447
+ );
448
+ }
449
+ }
450
+ }
451
+
452
+ /// @notice Allows contract to receive ETH
453
+ receive() external payable {}
454
+ }