@zoralabs/coins 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.turbo/turbo-build$colon$js.log +125 -106
  2. package/CHANGELOG.md +50 -5
  3. package/README.md +5 -0
  4. package/abis/AddressConstants.json +7 -0
  5. package/abis/BaseCoin.json +0 -5
  6. package/abis/BaseTest.json +62 -0
  7. package/abis/BuySupplyWithV4SwapHook.json +429 -0
  8. package/abis/ContentCoin.json +0 -5
  9. package/abis/CreatorCoin.json +0 -5
  10. package/abis/FeeEstimatorHook.json +94 -1
  11. package/abis/IUniswapV4Router04.json +484 -0
  12. package/abis/IUpgradeableDestinationV4HookWithUpdateableFee.json +95 -0
  13. package/abis/IZoraFactory.json +69 -0
  14. package/abis/MockAirlock.json +39 -0
  15. package/abis/SimpleERC20.json +326 -0
  16. package/abis/ZoraFactoryImpl.json +69 -0
  17. package/abis/ZoraV4CoinHook.json +94 -1
  18. package/addresses/8453.json +8 -10
  19. package/audits/report-cantinacode-zora-0827.pdf +3498 -4
  20. package/audits/report-cantinacode-zora-1021.pdf +0 -0
  21. package/dist/index.cjs +161 -22
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.js +160 -21
  24. package/dist/index.js.map +1 -1
  25. package/dist/wagmiGenerated.d.ts +259 -40
  26. package/dist/wagmiGenerated.d.ts.map +1 -1
  27. package/foundry.toml +3 -3
  28. package/package/wagmiGenerated.ts +160 -21
  29. package/package.json +1 -1
  30. package/script/DeployPostDeploymentHooks.s.sol +1 -3
  31. package/script/TestBackingCoinSwap.s.sol +0 -2
  32. package/script/TestV4Swap.s.sol +0 -2
  33. package/src/BaseCoin.sol +4 -12
  34. package/src/ContentCoin.sol +3 -4
  35. package/src/CreatorCoin.sol +8 -10
  36. package/src/ZoraFactoryImpl.sol +115 -83
  37. package/src/deployment/CoinsDeployerBase.sol +9 -8
  38. package/src/hook-registry/ZoraHookRegistry.sol +4 -0
  39. package/src/hooks/ZoraV4CoinHook.sol +66 -9
  40. package/src/hooks/deployment/BuySupplyWithV4SwapHook.sol +310 -0
  41. package/src/interfaces/IUpgradeableV4Hook.sol +18 -0
  42. package/src/interfaces/IZoraFactory.sol +21 -2
  43. package/src/libs/CoinConstants.sol +51 -8
  44. package/src/libs/CoinDopplerMultiCurve.sol +11 -11
  45. package/src/libs/CoinRewardsV4.sol +26 -33
  46. package/src/libs/CoinSetup.sol +2 -9
  47. package/src/libs/DopplerMath.sol +2 -2
  48. package/src/libs/V4Liquidity.sol +79 -15
  49. package/src/utils/AutoSwapper.sol +1 -1
  50. package/src/version/ContractVersionBase.sol +1 -1
  51. package/test/BuySupplyWithV4SwapHook.t.sol +509 -0
  52. package/test/Coin.t.sol +26 -14
  53. package/test/CoinRewardsV4.t.sol +33 -0
  54. package/test/CoinUniV4.t.sol +3 -5
  55. package/test/ContentCoinRewards.t.sol +44 -3
  56. package/test/CreatorCoin.t.sol +54 -33
  57. package/test/CreatorCoinRewards.t.sol +1 -3
  58. package/test/DeploymentHooks.t.sol +54 -2
  59. package/test/Factory.t.sol +3 -3
  60. package/test/LiquidityMigration.t.sol +145 -7
  61. package/test/MultiOwnable.t.sol +4 -4
  62. package/test/Upgrades.t.sol +26 -17
  63. package/test/V4Liquidity.t.sol +178 -0
  64. package/test/ZoraHookRegistry.t.sol +19 -9
  65. package/test/mocks/MockAirlock.sol +22 -0
  66. package/test/mocks/SimpleERC20.sol +8 -0
  67. package/test/utils/BaseTest.sol +155 -3
  68. package/test/utils/RewardTestHelpers.sol +4 -4
  69. package/test/utils/hookmate/README.md +50 -0
  70. package/test/utils/hookmate/artifacts/DeployHelper.sol +20 -0
  71. package/test/utils/hookmate/artifacts/Permit2.sol +16 -0
  72. package/test/utils/hookmate/artifacts/UniversalRouter.sol +29 -0
  73. package/test/utils/hookmate/artifacts/V4PoolManager.sol +17 -0
  74. package/test/utils/hookmate/artifacts/V4PositionManager.sol +23 -0
  75. package/test/utils/hookmate/artifacts/V4Quoter.sol +17 -0
  76. package/test/utils/hookmate/artifacts/V4Router.sol +18 -0
  77. package/test/utils/hookmate/constants/AddressConstants.sol +193 -0
  78. package/test/utils/hookmate/interfaces/router/IUniswapV4Router04.sol +173 -0
  79. package/test/utils/hookmate/interfaces/router/PathKey.sol +34 -0
  80. package/test/utils/hookmate/test/utils/SwapFeeEventAsserter.sol +24 -0
  81. package/wagmi.config.ts +1 -1
  82. package/abis/CoinConstants.json +0 -54
  83. package/abis/CoinRewardsV4.json +0 -67
  84. package/src/libs/CreatorCoinConstants.sol +0 -15
  85. package/src/libs/MarketConstants.sol +0 -23
  86. package/src/utils/uniswap/BytesLib.sol +0 -35
  87. package/src/utils/uniswap/Path.sol +0 -31
  88. /package/abis/{VmContractHelper227.json → VmContractHelper239.json} +0 -0
@@ -13,7 +13,7 @@ import {FullMath} from "../utils/uniswap/FullMath.sol";
13
13
  import {SqrtPriceMath} from "../utils/uniswap/SqrtPriceMath.sol";
14
14
  import {LiquidityAmounts} from "../utils/uniswap/LiquidityAmounts.sol";
15
15
  import {LpPosition} from "../types/LpPosition.sol";
16
- import {MarketConstants} from "./MarketConstants.sol";
16
+ import {CoinConstants} from "./CoinConstants.sol";
17
17
 
18
18
  /// @author Whetstone Research
19
19
  /// @notice Calculates liquidity provisioning with Uniswap v3
@@ -54,7 +54,7 @@ library DopplerMath {
54
54
  int24 spread = tickUpper - tickLower;
55
55
 
56
56
  uint160 farSqrtPriceX96 = TickMath.getSqrtPriceAtTick(farTick);
57
- uint256 amountPerPosition = FullMath.mulDiv(discoverySupply, MarketConstants.WAD, totalPositions * MarketConstants.WAD);
57
+ uint256 amountPerPosition = FullMath.mulDiv(discoverySupply, CoinConstants.WAD, totalPositions * CoinConstants.WAD);
58
58
  uint256 totalAssetsSold;
59
59
 
60
60
  for (uint256 i; i < totalPositions; i++) {
@@ -28,8 +28,9 @@ import {PathKey} from "@uniswap/v4-periphery/src/libraries/PathKey.sol";
28
28
  import {Position} from "@uniswap/v4-core/src/libraries/Position.sol";
29
29
  import {BurnedPosition, Delta, MigratedLiquidityResult, IUpgradeableV4Hook} from "../interfaces/IUpgradeableV4Hook.sol";
30
30
  import {PoolStateReader} from "../libs/PoolStateReader.sol";
31
- import {IUpgradeableDestinationV4Hook} from "../interfaces/IUpgradeableV4Hook.sol";
31
+ import {IUpgradeableDestinationV4Hook, IUpgradeableDestinationV4HookWithUpdateableFee} from "../interfaces/IUpgradeableV4Hook.sol";
32
32
  import {LiquidityAmounts} from "../utils/uniswap/LiquidityAmounts.sol";
33
+ import {IZoraV4CoinHook} from "../interfaces/IZoraV4CoinHook.sol";
33
34
 
34
35
  // command = 1; mint
35
36
  struct MintCallbackData {
@@ -71,7 +72,7 @@ library V4Liquidity {
71
72
  address coin,
72
73
  address newHook,
73
74
  bytes calldata additionalData
74
- ) internal returns (PoolKey memory) {
75
+ ) internal returns (PoolKey memory newPoolKey) {
75
76
  bytes memory data = abi.encode(
76
77
  BURN_ALL_POSITIONS_CALLBACK_ID,
77
78
  abi.encode(BurnAllPositionsCallbackData({poolKey: poolKey, positions: positions, coin: coin, newHook: newHook}))
@@ -82,19 +83,36 @@ library V4Liquidity {
82
83
 
83
84
  MigratedLiquidityResult memory migratedLiquidityResult = abi.decode(result, (MigratedLiquidityResult));
84
85
 
85
- // Check if new hook supports the upgradeable destination interface
86
- require(IERC165(newHook).supportsInterface(type(IUpgradeableDestinationV4Hook).interfaceId), IUpgradeableV4Hook.InvalidNewHook(newHook));
87
- // Initialize new hook with migration data
88
- IUpgradeableDestinationV4Hook(address(newHook)).initializeFromMigration(
89
- poolKey,
90
- coin,
91
- migratedLiquidityResult.sqrtPriceX96,
92
- migratedLiquidityResult.burnedPositions,
93
- additionalData
94
- );
95
-
96
- return
97
- PoolKey({currency0: poolKey.currency0, currency1: poolKey.currency1, fee: poolKey.fee, tickSpacing: poolKey.tickSpacing, hooks: IHooks(newHook)});
86
+ newPoolKey.currency0 = poolKey.currency0;
87
+ newPoolKey.currency1 = poolKey.currency1;
88
+ newPoolKey.hooks = IHooks(newHook);
89
+
90
+ // Check if new hook supports the new interface first, then fall back to old interface
91
+ if (IERC165(newHook).supportsInterface(type(IUpgradeableDestinationV4HookWithUpdateableFee).interfaceId)) {
92
+ // Use new interface with fee updates
93
+ (uint24 fee, int24 tickSpacing) = IUpgradeableDestinationV4HookWithUpdateableFee(address(newHook)).initializeFromMigrationWithUpdateableFee(
94
+ poolKey,
95
+ coin,
96
+ migratedLiquidityResult.sqrtPriceX96,
97
+ migratedLiquidityResult.burnedPositions,
98
+ additionalData
99
+ );
100
+ newPoolKey.fee = fee;
101
+ newPoolKey.tickSpacing = tickSpacing;
102
+ } else {
103
+ // Fall back to old interface for backward compatibility
104
+ require(IERC165(newHook).supportsInterface(type(IUpgradeableDestinationV4Hook).interfaceId), IUpgradeableV4Hook.InvalidNewHook(newHook));
105
+ IUpgradeableDestinationV4Hook(address(newHook)).initializeFromMigration(
106
+ poolKey,
107
+ coin,
108
+ migratedLiquidityResult.sqrtPriceX96,
109
+ migratedLiquidityResult.burnedPositions,
110
+ additionalData
111
+ );
112
+ // Keep existing fee and tick spacing when using old interface
113
+ newPoolKey.fee = poolKey.fee;
114
+ newPoolKey.tickSpacing = poolKey.tickSpacing;
115
+ }
98
116
  }
99
117
 
100
118
  /// @notice Handles the callback from the pool manager. Called by the hook upon unlock.
@@ -138,6 +156,41 @@ library V4Liquidity {
138
156
  return abi.encode(result);
139
157
  }
140
158
 
159
+ function dedupePositions(LpPosition[] memory positions) internal pure returns (LpPosition[] memory dedupedPositions) {
160
+ // Upper bound: no more than input length
161
+ dedupedPositions = new LpPosition[](positions.length);
162
+ uint outLen = 0;
163
+
164
+ // O(n²) approach: for each position, check if it already exists in output
165
+ // This is acceptable since position arrays are typically small (< 100 positions)
166
+
167
+ for (uint i = 0; i < positions.length; i++) {
168
+ int24 t0 = positions[i].tickLower;
169
+ int24 t1 = positions[i].tickUpper;
170
+ uint128 v = positions[i].liquidity;
171
+
172
+ bool duplicate = false;
173
+ for (uint j = 0; j < outLen; j++) {
174
+ LpPosition memory dedupedPosition = dedupedPositions[j];
175
+ if (dedupedPosition.tickLower == t0 && dedupedPosition.tickUpper == t1) {
176
+ dedupedPosition.liquidity += v;
177
+ duplicate = true;
178
+ break;
179
+ }
180
+ }
181
+
182
+ if (!duplicate) {
183
+ dedupedPositions[outLen] = LpPosition({tickLower: t0, tickUpper: t1, liquidity: v});
184
+ outLen++;
185
+ }
186
+ }
187
+
188
+ // Shrink to exact size by overwriting length field on the array
189
+ assembly {
190
+ mstore(dedupedPositions, outLen)
191
+ }
192
+ }
193
+
141
194
  function generatePositionsFromMigratedLiquidity(
142
195
  uint160 sqrtPriceX96,
143
196
  BurnedPosition[] calldata migratedLiquidity
@@ -198,6 +251,17 @@ library V4Liquidity {
198
251
  for (uint256 i; i < positions.length; i++) {
199
252
  uint128 liquidity = getLiquidity(poolManager, address(this), poolKey, positions[i].tickLower, positions[i].tickUpper);
200
253
 
254
+ // Skip positions that have no liquidity to avoid CannotUpdateEmptyPosition error
255
+ if (liquidity == 0) {
256
+ burnedPositions[i] = BurnedPosition({
257
+ tickLower: positions[i].tickLower,
258
+ tickUpper: positions[i].tickUpper,
259
+ amount0Received: 0,
260
+ amount1Received: 0
261
+ });
262
+ continue;
263
+ }
264
+
201
265
  ModifyLiquidityParams memory params = ModifyLiquidityParams({
202
266
  tickLower: positions[i].tickLower,
203
267
  tickUpper: positions[i].tickUpper,
@@ -9,7 +9,7 @@ pragma solidity ^0.8.28;
9
9
 
10
10
  import {ISwapRouter} from "@zoralabs/shared-contracts/interfaces/uniswap/ISwapRouter.sol";
11
11
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
12
- import {Path} from "./uniswap/Path.sol";
12
+ import {Path} from "@zoralabs/shared-contracts/libs/UniswapV3/Path.sol";
13
13
 
14
14
  /// @title AutoSwapper
15
15
  /// @notice A contract that allows for swapping of tokens via a uniswap v3 swap router. Only works with Uniswap V3 swaps.
@@ -9,6 +9,6 @@ import {IVersionedContract} from "@zoralabs/shared-contracts/interfaces/IVersion
9
9
  contract ContractVersionBase is IVersionedContract {
10
10
  /// @notice The version of the contract
11
11
  function contractVersion() external pure override returns (string memory) {
12
- return "2.2.1";
12
+ return "2.3.1";
13
13
  }
14
14
  }
@@ -0,0 +1,509 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.13;
3
+
4
+ import {BaseTest} from "./utils/BaseTest.sol";
5
+ import {BuySupplyWithV4SwapHook} from "../src/hooks/deployment/BuySupplyWithV4SwapHook.sol";
6
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
7
+ import {CoinConfigurationVersions} from "../src/libs/CoinConfigurationVersions.sol";
8
+ import {ICoin} from "../src/interfaces/ICoin.sol";
9
+ import {ISwapRouter} from "../src/interfaces/ISwapRouter.sol";
10
+ import {CoinConstants} from "../src/libs/CoinConstants.sol";
11
+ import {ContentCoin} from "../src/ContentCoin.sol";
12
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
13
+ import {console} from "forge-std/console.sol";
14
+
15
+ contract BuySupplyWithV4SwapHookTest is BaseTest {
16
+ address constant ZORA = 0x1111111111166b7FE7bd91427724B487980aFc69;
17
+ BuySupplyWithV4SwapHook postDeployHook;
18
+
19
+ // TODO: Add tests to verify swap path always goes from input currency to backing currency
20
+ // 1. Test V3-only swap paths (e.g., USDC -> WETH -> Creator Coin)
21
+ // 2. Test V4-only swap paths (e.g., WETH -> Creator Coin via V4)
22
+ // 3. Test mixed V3->V4 swap paths (e.g., USDC -> WETH via V3, then WETH -> Creator Coin via V4)
23
+ // 4. Test different input currencies (USDC, WETH, other ERC20s) all properly route to backing currency
24
+ // 5. Verify the final currency received always matches the Content Coin's backing currency
25
+ // 6. Clean up debug logging in BuySupplyWithV4SwapHook.sol
26
+
27
+ function setUp() public override {
28
+ super.setUpWithBlockNumber(33646532);
29
+
30
+ postDeployHook = new BuySupplyWithV4SwapHook(factory, address(swapRouter), address(V4_POOL_MANAGER));
31
+ }
32
+
33
+ function _encodeV4HookData(
34
+ address buyRecipient,
35
+ bytes memory v3Route,
36
+ PoolKey[] memory v4Route,
37
+ address inputCurrency,
38
+ uint256 inputAmount,
39
+ uint256 minAmountOut
40
+ ) internal pure returns (bytes memory) {
41
+ BuySupplyWithV4SwapHook.InitialSupplyParams memory params = BuySupplyWithV4SwapHook.InitialSupplyParams({
42
+ buyRecipient: buyRecipient,
43
+ v3Route: v3Route,
44
+ v4Route: v4Route,
45
+ inputCurrency: inputCurrency,
46
+ inputAmount: inputAmount,
47
+ minAmountOut: minAmountOut
48
+ });
49
+ return abi.encode(params);
50
+ }
51
+
52
+ function _encodeV3Path(address tokenA, uint24 feeA, address tokenB, uint24 feeB, address tokenC) internal pure returns (bytes memory) {
53
+ return abi.encodePacked(tokenA, feeA, tokenB, feeB, tokenC);
54
+ }
55
+
56
+ function _encodeV3PathSingle(address tokenA, uint24 fee, address tokenB) internal pure returns (bytes memory) {
57
+ return abi.encodePacked(tokenA, fee, tokenB);
58
+ }
59
+
60
+ function _deployCreatorCoin(address payoutRecipient) internal returns (address creatorCoinAddress) {
61
+ bytes memory creatorPoolConfig = CoinConfigurationVersions.defaultDopplerMultiCurveUniV4(ZORA);
62
+
63
+ vm.prank(payoutRecipient);
64
+ creatorCoinAddress = factory.deployCreatorCoin(
65
+ payoutRecipient, // payoutRecipient
66
+ _getDefaultOwners(), // owners
67
+ "https://creator.com", // uri
68
+ "Creator Coin", // name
69
+ "CREATOR", // symbol
70
+ creatorPoolConfig, // poolConfig (ZORA-backed)
71
+ users.platformReferrer, // platformReferrer
72
+ bytes32(0) // coinSalt
73
+ );
74
+ }
75
+
76
+ function _deployContentCoinWithHook(
77
+ address backingCurrency,
78
+ uint256 payableAmount,
79
+ address caller,
80
+ bytes memory hookData
81
+ ) internal returns (address coinAddress, uint256 amountCurrency, uint256 coinsPurchased) {
82
+ bytes memory poolConfig = CoinConfigurationVersions.defaultDopplerMultiCurveUniV4(backingCurrency);
83
+
84
+ vm.prank(caller);
85
+ bytes memory hookDataOut;
86
+ (coinAddress, hookDataOut) = factory.deployWithHook{value: payableAmount}(
87
+ caller, // payoutRecipient
88
+ _getDefaultOwners(), // owners
89
+ "https://test.com", // uri
90
+ "Content Coin", // name
91
+ "CONTENT", // symbol
92
+ poolConfig, // poolConfig
93
+ users.platformReferrer, // platformReferrer
94
+ address(postDeployHook), // postDeployHook
95
+ hookData // postDeployHookData
96
+ );
97
+
98
+ (amountCurrency, coinsPurchased) = abi.decode(hookDataOut, (uint256, uint256));
99
+ }
100
+
101
+ /// @dev Test buying initial supply of a Content Coin backed by ZORA
102
+ /// This only requires V3 swap (ETH -> ZORA) since the coin is already backed by ZORA
103
+ function test_buyContentCoinSupply_V3SwapOnly() public {
104
+ uint256 initialOrderSize = 0.1 ether;
105
+ vm.deal(users.creator, initialOrderSize);
106
+
107
+ // Create V3 path: ETH -> USDC -> ZORA
108
+ bytes memory v3Route = _encodeV3Path(
109
+ address(weth),
110
+ 3000, // WETH/USDC 0.3%
111
+ USDC_ADDRESS,
112
+ 3000, // USDC/ZORA 0.3%
113
+ ZORA
114
+ );
115
+
116
+ console.logBytes(v3Route);
117
+
118
+ // No V4 route needed since coin is backed by ZORA
119
+ PoolKey[] memory v4Route = new PoolKey[](0);
120
+
121
+ bytes memory hookData = _encodeV4HookData(users.creator, v3Route, v4Route, address(0), initialOrderSize, 0);
122
+
123
+ // Deploy Content Coin backed by ZORA
124
+ (address coinAddress, uint256 amountCurrency, uint256 coinsPurchased) = _deployContentCoinWithHook(ZORA, initialOrderSize, users.creator, hookData);
125
+
126
+ ContentCoin coin = ContentCoin(payable(coinAddress));
127
+
128
+ // Verify the coin is properly configured
129
+ assertEq(coin.currency(), ZORA, "Coin should be backed by ZORA");
130
+ assertGt(amountCurrency, 0, "Should have received ZORA from V3 swap");
131
+ assertGt(coinsPurchased, 0, "Should have purchased coins");
132
+
133
+ // Creator should have their launch supply + purchased coins
134
+ assertEq(
135
+ coin.balanceOf(users.creator),
136
+ CoinConstants.CONTENT_COIN_INITIAL_CREATOR_SUPPLY + coinsPurchased,
137
+ "Creator should have launch supply + purchased coins"
138
+ );
139
+
140
+ // Verify V3 swap worked correctly (mock implementation returns positive values)
141
+ // Note: In real implementation this would check actual pool liquidity
142
+ }
143
+
144
+ /// @dev Test that BuyInitialSupply event is emitted with accurate data using snapshot pattern
145
+ function test_BuyInitialSupplyEvent() public {
146
+ uint256 initialOrderSize = 0.1 ether;
147
+ vm.deal(users.creator, initialOrderSize * 2); // Double to account for both runs
148
+
149
+ // Create V3 path: ETH -> USDC -> ZORA
150
+ bytes memory v3Route = _encodeV3Path(
151
+ address(weth),
152
+ 3000, // WETH/USDC 0.3%
153
+ USDC_ADDRESS,
154
+ 3000, // USDC/ZORA 0.3%
155
+ ZORA
156
+ );
157
+
158
+ // No V4 route needed since coin is backed by ZORA
159
+ PoolKey[] memory v4Route = new PoolKey[](0);
160
+
161
+ bytes memory hookData = _encodeV4HookData(users.creator, v3Route, v4Route, address(0), initialOrderSize, 0);
162
+
163
+ PoolKey[] memory expectedV4Route = new PoolKey[](1);
164
+
165
+ // FIRST RUN: Use snapshot pattern to capture expected values
166
+ uint256 snapshot = vm.snapshot();
167
+
168
+ // Execute deployment to get actual values
169
+ (address coinAddress, uint256 expectedAmountCurrency, uint256 expectedCoinsPurchased) = _deployContentCoinWithHook(
170
+ ZORA,
171
+ initialOrderSize,
172
+ users.creator,
173
+ hookData
174
+ );
175
+
176
+ expectedV4Route[0] = ICoin(payable(coinAddress)).getPoolKey();
177
+
178
+ // Revert to snapshot to restore state
179
+ vm.revertToState(snapshot);
180
+
181
+ // SECOND RUN: Execute with event verification using captured values
182
+ // Note: We skip checking coin address (first indexed param) since it will be different after snapshot revert
183
+ vm.expectEmit(false, true, true, true); // Skip coin address, check recipient, coinsPurchased, and all data
184
+ emit BuySupplyWithV4SwapHook.BuyInitialSupply(
185
+ address(0), // coin (indexed) - skip checking since address will differ after revert
186
+ users.creator, // recipient (indexed)
187
+ expectedCoinsPurchased, // coinsPurchased (indexed)
188
+ v3Route, // v3Route (data)
189
+ expectedV4Route, // v4Route (data)
190
+ address(0), // inputCurrency (data) - ETH represented as address(0)
191
+ initialOrderSize, // inputAmount (data)
192
+ expectedAmountCurrency // v4SwapInput (data) - amount received from V3 swap
193
+ );
194
+
195
+ // Deploy Content Coin backed by ZORA - this should emit event with matching parameters
196
+ _deployContentCoinWithHook(ZORA, initialOrderSize, users.creator, hookData);
197
+ }
198
+
199
+ /// @dev Test buying initial supply of a Content Coin paired with ETH
200
+ /// This requires no V3 or V4 routing - just direct V4 swap with ETH
201
+ function test_buyContentCoinSupply_ETHPaired() public {
202
+ uint256 initialOrderSize = 0.05 ether;
203
+ vm.deal(users.creator, initialOrderSize);
204
+
205
+ // No V3 route needed - direct ETH to coin swap
206
+ bytes memory v3Route = "";
207
+
208
+ // No V4 route needed - direct swap
209
+ PoolKey[] memory v4Route = new PoolKey[](0);
210
+
211
+ bytes memory hookData = _encodeV4HookData(users.creator, v3Route, v4Route, address(0), initialOrderSize, 0);
212
+
213
+ // Deploy Content Coin paired with ETH (address(0))
214
+ (address coinAddress, uint256 amountCurrency, uint256 coinsPurchased) = _deployContentCoinWithHook(
215
+ address(0),
216
+ initialOrderSize,
217
+ users.creator,
218
+ hookData
219
+ );
220
+
221
+ ContentCoin coin = ContentCoin(payable(coinAddress));
222
+
223
+ // Verify the coin is properly configured as ETH-paired
224
+ assertEq(coin.currency(), address(0), "Coin should be paired with ETH");
225
+ assertEq(amountCurrency, initialOrderSize, "Should have used all ETH directly");
226
+ assertGt(coinsPurchased, 0, "Should have purchased coins");
227
+
228
+ // Creator should have their launch reward + purchased coins
229
+ assertEq(
230
+ coin.balanceOf(users.creator),
231
+ CoinConstants.CONTENT_COIN_INITIAL_CREATOR_SUPPLY + coinsPurchased,
232
+ "Creator should have launch reward + purchased coins"
233
+ );
234
+ }
235
+
236
+ /// @dev Test deploying Content Coin with owned Creator Coin tokens (no ETH, no V3 swap)
237
+ /// This demonstrates using existing ERC20 tokens to purchase initial supply during deployment
238
+ function test_buyContentCoinSupply_WithOwnedCreatorCoins() public {
239
+ // STEP 1: Deploy Creator Coin backed by ZORA
240
+ address creatorCoinAddress = _deployCreatorCoin(users.creator);
241
+
242
+ // STEP 2: Give another user ZORA tokens and have them swap for Creator Coins
243
+ address anotherCreator = makeAddr("anotherCreator");
244
+ uint256 zoraAmount = 10e18; // 10 ZORA tokens
245
+ deal(ZORA, anotherCreator, zoraAmount);
246
+ assertEq(IERC20(ZORA).balanceOf(anotherCreator), zoraAmount, "anotherCreator should have ZORA tokens");
247
+
248
+ // Swap ZORA tokens for Creator Coins using proper V4 swap mechanism
249
+ uint128 swapAmountIn = uint128(zoraAmount);
250
+ _swapSomeCurrencyForCoin(ICoin(payable(creatorCoinAddress)), ZORA, swapAmountIn, anotherCreator);
251
+
252
+ uint256 creatorCoinAmount = IERC20(creatorCoinAddress).balanceOf(anotherCreator);
253
+
254
+ // STEP 3: Have anotherCreator approve the hook to spend their Creator Coins
255
+ vm.prank(anotherCreator);
256
+ IERC20(creatorCoinAddress).approve(address(postDeployHook), creatorCoinAmount);
257
+
258
+ // STEP 4: Deploy Content Coin backed by Creator Coin using owned tokens
259
+
260
+ // No V3 route needed - anotherCreator already has Creator Coins
261
+ bytes memory v3Route = "";
262
+
263
+ // No V4 route needed - direct Creator Coin to Content Coin swap
264
+ PoolKey[] memory v4Route = new PoolKey[](0);
265
+
266
+ bytes memory hookData = _encodeV4HookData(anotherCreator, v3Route, v4Route, creatorCoinAddress, creatorCoinAmount, 0);
267
+
268
+ // Deploy with amount = 0 (no ETH needed since using owned tokens)
269
+ (address contentCoinAddress, uint256 amountCurrency, uint256 coinsPurchased) = _deployContentCoinWithHook(
270
+ creatorCoinAddress,
271
+ 0, // No ETH needed
272
+ anotherCreator,
273
+ hookData
274
+ );
275
+
276
+ ContentCoin contentCoin = ContentCoin(payable(contentCoinAddress));
277
+
278
+ // Verify the content coin is properly configured
279
+ assertEq(contentCoin.currency(), creatorCoinAddress, "Content coin should be backed by Creator coin");
280
+ assertGt(amountCurrency, 0, "Should have used some Creator Coins");
281
+ assertGt(coinsPurchased, 0, "Should have purchased content coins");
282
+
283
+ // anotherCreator should have their launch reward + purchased content coins
284
+ assertEq(
285
+ contentCoin.balanceOf(anotherCreator),
286
+ CoinConstants.CONTENT_COIN_INITIAL_CREATOR_SUPPLY + coinsPurchased,
287
+ "anotherCreator should have launch reward + purchased content coins"
288
+ );
289
+
290
+ // Verify Creator Coin balance decreased
291
+ uint256 remainingCreatorCoins = IERC20(creatorCoinAddress).balanceOf(anotherCreator);
292
+ assertLt(remainingCreatorCoins, creatorCoinAmount, "anotherCreator should have spent some Creator Coins");
293
+ assertEq(remainingCreatorCoins, creatorCoinAmount - amountCurrency, "Creator Coin balance should decrease by amount used");
294
+ }
295
+
296
+ /// @dev Test buying initial supply of a Content Coin backed by a Creator Coin
297
+ /// This requires V3 swap (ETH -> ZORA) then V4 swap (ZORA -> Creator Coin -> Content Coin)
298
+ function test_buyContentCoinSupply_CreatorCoinBacked() public {
299
+ uint256 initialOrderSize = 0.08 ether;
300
+ vm.deal(users.creator, initialOrderSize);
301
+
302
+ // STEP 1: Deploy Creator Coin backed by ZORA
303
+ address creatorCoinAddress = _deployCreatorCoin(users.creator);
304
+
305
+ // STEP 2: Deploy Content Coin backed by Creator Coin
306
+
307
+ // Create V3 path: ETH -> USDC -> ZORA (to get the creator coin's backing currency)
308
+ bytes memory v3Route = _encodeV3Path(
309
+ address(weth),
310
+ 3000, // WETH/USDC 0.3%
311
+ USDC_ADDRESS,
312
+ 3000, // USDC/ZORA 0.3%
313
+ ZORA
314
+ );
315
+
316
+ // V4 route: ZORA -> Creator Coin (then Creator Coin -> Content Coin will be added automatically)
317
+ PoolKey[] memory v4Route = new PoolKey[](1);
318
+ v4Route[0] = ICoin(payable(creatorCoinAddress)).getPoolKey();
319
+
320
+ bytes memory hookData = _encodeV4HookData(users.creator, v3Route, v4Route, address(0), initialOrderSize, 0);
321
+
322
+ (address contentCoinAddress, uint256 amountCurrency, uint256 coinsPurchased) = _deployContentCoinWithHook(
323
+ creatorCoinAddress,
324
+ initialOrderSize,
325
+ users.creator,
326
+ hookData
327
+ );
328
+
329
+ ContentCoin contentCoin = ContentCoin(payable(contentCoinAddress));
330
+
331
+ // Verify the content coin is properly configured
332
+ assertEq(contentCoin.currency(), creatorCoinAddress, "Content coin should be backed by Creator coin");
333
+ assertGt(amountCurrency, 0, "Should have received ZORA from V3 swap");
334
+ assertGt(coinsPurchased, 0, "Should have purchased content coins");
335
+
336
+ // Creator should have their launch reward + purchased content coins
337
+ assertEq(
338
+ contentCoin.balanceOf(users.creator),
339
+ CoinConstants.CONTENT_COIN_INITIAL_CREATOR_SUPPLY + coinsPurchased,
340
+ "Creator should have launch reward + purchased content coins"
341
+ );
342
+ }
343
+
344
+ // ============ ERROR HANDLING TESTS ============
345
+
346
+ function test_RevertWhen_InsufficientInputCurrencyETH() public {
347
+ uint256 inputAmount = 1 ether;
348
+ uint256 insufficientAmount = 0.5 ether;
349
+
350
+ // Create V3 path: ETH -> USDC -> ZORA
351
+ bytes memory v3Route = _encodeV3Path(
352
+ address(weth),
353
+ 3000, // WETH/USDC 0.3%
354
+ USDC_ADDRESS,
355
+ 3000, // USDC/ZORA 0.3%
356
+ ZORA
357
+ );
358
+
359
+ PoolKey[] memory v4Route = new PoolKey[](0);
360
+
361
+ bytes memory hookData = _encodeV4HookData(users.creator, v3Route, v4Route, address(0), inputAmount, 0);
362
+
363
+ // Should revert with InsufficientInputCurrency
364
+ vm.deal(users.creator, insufficientAmount);
365
+ bytes memory poolConfig = CoinConfigurationVersions.defaultDopplerMultiCurveUniV4(ZORA);
366
+ vm.expectRevert(abi.encodeWithSelector(BuySupplyWithV4SwapHook.InsufficientInputCurrency.selector, inputAmount, insufficientAmount));
367
+
368
+ vm.prank(users.creator);
369
+ factory.deployWithHook{value: insufficientAmount}(
370
+ users.creator, // payoutRecipient
371
+ _getDefaultOwners(), // owners
372
+ "https://test.com", // uri
373
+ "Content Coin", // name
374
+ "CONTENT", // symbol
375
+ poolConfig, // poolConfig
376
+ users.platformReferrer, // platformReferrer
377
+ address(postDeployHook), // postDeployHook
378
+ hookData // postDeployHookData
379
+ );
380
+ }
381
+
382
+ function test_RevertWhen_InsufficientInputCurrencyERC20() public {
383
+ // Deploy Creator Coin first
384
+ address creatorCoinAddress = _deployCreatorCoin(users.creator);
385
+
386
+ uint256 userBalance = 500e18;
387
+
388
+ // Give user some Creator Coins but less than required
389
+ deal(creatorCoinAddress, users.creator, userBalance);
390
+
391
+ // Approve a small amount to spend for Creator Coins
392
+ vm.prank(users.creator);
393
+ IERC20(creatorCoinAddress).approve(address(postDeployHook), 1);
394
+
395
+ // No V3 route needed - user already has Creator Coins (but insufficient amount)
396
+ bytes memory v3Route = "";
397
+ PoolKey[] memory v4Route = new PoolKey[](0);
398
+
399
+ uint256 zoraAmount = 10e18; // 10 ZORA tokens
400
+ deal(ZORA, users.creator, zoraAmount);
401
+
402
+ // Swap ZORA tokens for Creator Coins using proper V4 swap mechanism
403
+ uint128 swapAmountIn = uint128(zoraAmount);
404
+ _swapSomeCurrencyForCoin(ICoin(payable(creatorCoinAddress)), ZORA, swapAmountIn, users.creator);
405
+
406
+ uint256 inputAmount = IERC20(creatorCoinAddress).balanceOf(users.creator);
407
+ uint256 amountToApprove = inputAmount / 2;
408
+
409
+ // only approve half of the input amount - it should revert
410
+ vm.prank(users.creator);
411
+ IERC20(creatorCoinAddress).approve(address(postDeployHook), amountToApprove);
412
+
413
+ bytes memory hookData = _encodeV4HookData(users.creator, v3Route, v4Route, creatorCoinAddress, inputAmount, 0);
414
+
415
+ bytes memory poolConfig = CoinConfigurationVersions.defaultDopplerMultiCurveUniV4(creatorCoinAddress);
416
+ // Should revert with InsufficientInputCurrency
417
+ vm.expectRevert(abi.encodeWithSelector(BuySupplyWithV4SwapHook.InsufficientInputCurrency.selector, inputAmount, amountToApprove));
418
+
419
+ vm.prank(users.creator);
420
+ factory.deployWithHook(
421
+ users.creator, // payoutRecipient
422
+ _getDefaultOwners(), // owners
423
+ "https://test.com", // uri
424
+ "Content Coin", // name
425
+ "CONTENT", // symbol
426
+ poolConfig, // poolConfig
427
+ users.platformReferrer, // platformReferrer
428
+ address(postDeployHook), // postDeployHook
429
+ hookData // postDeployHookData
430
+ );
431
+ }
432
+
433
+ function test_RevertWhen_V3RouteDoesNotConnectToV4RouteStart() public {
434
+ // Deploy Creator Coin backed by ZORA
435
+ address creatorCoinAddress = _deployCreatorCoin(users.creator);
436
+
437
+ vm.deal(users.creator, 1 ether);
438
+
439
+ // Create V3 path that ends with USDC
440
+ bytes memory v3Route = _encodeV3PathSingle(
441
+ address(weth),
442
+ 3000, // WETH/USDC 0.3%
443
+ USDC_ADDRESS
444
+ );
445
+
446
+ // Create V4 route that starts with ZORA (not USDC - mismatch!)
447
+ PoolKey[] memory v4Route = new PoolKey[](1);
448
+ v4Route[0] = ICoin(payable(creatorCoinAddress)).getPoolKey();
449
+
450
+ bytes memory hookData = _encodeV4HookData(users.creator, v3Route, v4Route, address(0), 1 ether, 0);
451
+
452
+ // Should revert with V3RouteDoesNotConnectToV4RouteStart
453
+
454
+ bytes memory poolConfig = CoinConfigurationVersions.defaultDopplerMultiCurveUniV4(creatorCoinAddress);
455
+
456
+ vm.prank(users.creator);
457
+ vm.expectRevert(abi.encodeWithSelector(BuySupplyWithV4SwapHook.V3RouteDoesNotConnectToV4RouteStart.selector));
458
+ factory.deployWithHook{value: 1 ether}(
459
+ users.creator, // payoutRecipient
460
+ _getDefaultOwners(), // owners
461
+ "https://test.com", // uri
462
+ "Content Coin", // name
463
+ "CONTENT", // symbol
464
+ poolConfig, // poolConfig
465
+ users.platformReferrer, // platformReferrer
466
+ address(postDeployHook), // postDeployHook
467
+ hookData // postDeployHookData
468
+ );
469
+ }
470
+
471
+ function test_RevertWhen_InsufficientOutputAmount() public {
472
+ uint256 initialOrderSize = 0.1 ether;
473
+ vm.deal(users.creator, initialOrderSize);
474
+
475
+ // Create V3 path: ETH -> USDC -> ZORA
476
+ bytes memory v3Route = _encodeV3Path(
477
+ address(weth),
478
+ 3000, // WETH/USDC 0.3%
479
+ USDC_ADDRESS,
480
+ 3000, // USDC/ZORA 0.3%
481
+ ZORA
482
+ );
483
+
484
+ // No V4 route needed since coin is backed by ZORA
485
+ PoolKey[] memory v4Route = new PoolKey[](0);
486
+
487
+ // Set impossibly high minimum amount out (1 million coins)
488
+ uint256 impossibleMinAmountOut = type(uint256).max;
489
+
490
+ bytes memory hookData = _encodeV4HookData(users.creator, v3Route, v4Route, address(0), initialOrderSize, impossibleMinAmountOut);
491
+
492
+ // Should revert with InsufficientOutputAmount
493
+ bytes memory poolConfig = CoinConfigurationVersions.defaultDopplerMultiCurveUniV4(ZORA);
494
+ vm.expectRevert(abi.encodeWithSelector(BuySupplyWithV4SwapHook.InsufficientOutputAmount.selector));
495
+
496
+ vm.prank(users.creator);
497
+ factory.deployWithHook{value: initialOrderSize}(
498
+ users.creator, // payoutRecipient
499
+ _getDefaultOwners(), // owners
500
+ "https://test.com", // uri
501
+ "Content Coin", // name
502
+ "CONTENT", // symbol
503
+ poolConfig, // poolConfig
504
+ users.platformReferrer, // platformReferrer
505
+ address(postDeployHook), // postDeployHook
506
+ hookData // postDeployHookData
507
+ );
508
+ }
509
+ }