@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
@@ -17,7 +17,13 @@ import {ICoin} from "../src/interfaces/ICoin.sol";
17
17
  import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
18
18
  import {CoinCommon} from "../src/libs/CoinCommon.sol";
19
19
  import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
20
+ import {MultiOwnable} from "../src/utils/MultiOwnable.sol";
20
21
  import {IHooksUpgradeGate} from "../src/interfaces/IHooksUpgradeGate.sol";
22
+ import {BaseCoin} from "../src/BaseCoin.sol";
23
+ import {CoinConstants} from "../src/libs/CoinConstants.sol";
24
+ import {SwapParams} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
25
+ import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
26
+ import {BalanceDeltaLibrary} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
21
27
 
22
28
  contract LiquidityMigrationReceiver is IUpgradeableDestinationV4Hook, IERC165 {
23
29
  function initializeFromMigration(
@@ -45,6 +51,8 @@ contract InvalidLiquidityMigrationReceiver is IERC165 {
45
51
  contract LiquidityMigrationTest is BaseTest {
46
52
  MockERC20 internal mockERC20A;
47
53
 
54
+ address constant coinVersionLookup = 0x777777751622c0d3258f214F9DF38E35BF45baF3;
55
+
48
56
  function setUp() public override {
49
57
  super.setUpWithBlockNumber(30267794);
50
58
 
@@ -125,31 +133,59 @@ contract LiquidityMigrationTest is BaseTest {
125
133
  assertEq(newPoolKey.tickSpacing, poolKey.tickSpacing, "poolkey tickSpacing");
126
134
  }
127
135
 
128
- function test_migrateLiquidity_enablesSwapsOnOldPoolKey() public {
136
+ function test_migrateLiquidity_revertsSwapsOnOldPoolKey() public {
129
137
  address currency = address(mockERC20A);
130
138
  mockERC20A.mint(address(poolManager), 1_000_000_000 ether);
131
139
  _deployV4Coin(currency);
132
140
 
133
141
  address trader = makeAddr("trader");
134
-
135
142
  mockERC20A.mint(trader, 10 ether);
136
143
 
137
- // do some swaps
144
+ // do some swaps before migration
138
145
  _swapSomeCurrencyForCoin(coinV4, currency, 1 ether, trader);
139
146
  _swapSomeCoinForCurrency(coinV4, currency, uint128(coinV4.balanceOf(trader)), trader);
140
147
 
141
- address newHook = address(new LiquidityMigrationReceiver());
142
-
143
148
  PoolKey memory poolKey = coinV4.getPoolKey();
149
+ bytes32 poolKeyHash = CoinCommon.hashPoolKey(poolKey);
150
+
151
+ // Verify the mapping exists before migration
152
+ IZoraV4CoinHook.PoolCoin memory poolCoinBefore = hook.getPoolCoin(poolKey);
153
+ assertEq(poolCoinBefore.coin, address(coinV4), "pool coin should exist before migration");
154
+
155
+ IZoraV4CoinHook.PoolCoin memory poolCoinByHashBefore = hook.getPoolCoinByHash(poolKeyHash);
156
+ assertEq(poolCoinByHashBefore.coin, address(coinV4), "pool coin by hash should exist before migration");
144
157
 
158
+ address newHook = address(new LiquidityMigrationReceiver());
145
159
  registerUpgradePath(address(poolKey.hooks), address(newHook));
146
160
 
147
161
  // migrate the liquidity
148
162
  vm.prank(users.creator);
149
163
  coinV4.migrateLiquidity(address(newHook), "");
150
164
 
151
- // now swap using the existing pool key, it should succeed
152
- _swapSomeCurrencyForCoin(poolKey, coinV4, currency, uint128(mockERC20A.balanceOf(trader)), trader);
165
+ // Verify that the old pool key mapping has been deleted
166
+ IZoraV4CoinHook.PoolCoin memory poolCoinAfter = hook.getPoolCoin(poolKey);
167
+ assertEq(poolCoinAfter.coin, address(0), "old pool key should have no associated coin after migration");
168
+ assertEq(poolCoinAfter.positions.length, 0, "old pool coin positions should be empty after migration");
169
+
170
+ IZoraV4CoinHook.PoolCoin memory poolCoinByHashAfter = hook.getPoolCoinByHash(poolKeyHash);
171
+ assertEq(poolCoinByHashAfter.coin, address(0), "old pool coin by hash should have no associated coin after migration");
172
+ assertEq(poolCoinByHashAfter.positions.length, 0, "old pool coin by hash positions should be empty after migration");
173
+
174
+ // Verify that hook operations revert with NoCoinForHook on the old poolkey
175
+ vm.expectRevert(abi.encodeWithSelector(IZoraV4CoinHook.NoCoinForHook.selector, poolKey));
176
+
177
+ SwapParams memory mockSwapParams = SwapParams({zeroForOne: true, amountSpecified: 1 ether, sqrtPriceLimitX96: 0});
178
+
179
+ BalanceDelta mockDelta = BalanceDeltaLibrary.ZERO_DELTA;
180
+
181
+ // Call afterSwap directly - this should revert with NoCoinForHook
182
+ vm.prank(address(poolManager));
183
+ IHooks(address(hook)).afterSwap(trader, poolKey, mockSwapParams, mockDelta, "");
184
+
185
+ // Verify the new pool key still works
186
+ PoolKey memory newPoolKey = coinV4.getPoolKey();
187
+ assertEq(address(newPoolKey.hooks), address(newHook), "coin should have updated to new hook");
188
+ assertTrue(address(poolKey.hooks) != address(newPoolKey.hooks), "old and new pool keys should be different");
153
189
  }
154
190
 
155
191
  function test_migrateLiquidity_emitsLiquidityMigrated() public {
@@ -383,4 +419,106 @@ contract LiquidityMigrationTest is BaseTest {
383
419
  // Should match isRegisteredUpgradePath
384
420
  assertEq(hookUpgradeGate.isAllowedHookUpgrade(baseImpl, upgradeImpl), hookUpgradeGate.isRegisteredUpgradePath(baseImpl, upgradeImpl));
385
421
  }
422
+
423
+ function test_migrateLiquidity_failsWithEmptyPositionBug() public {
424
+ // Reproduce the bug discovered in hook version 1.1.2 where migration
425
+ // tries to modify liquidity positions that have zero liquidity
426
+ vm.createSelectFork("base", 35671635);
427
+
428
+ address contentCoin = 0x81f5F30217dA777a5d6441606AFa57E093833d7C;
429
+ address oldHook = 0x9ea932730A7787000042e34390B8E435dD839040; // v1.1.2 hook
430
+ address newHook = 0xff74Be9D3596eA7a33BB4983DD7906fB34135040; // current hook
431
+ address upgradeGate = 0xD88f6BdD765313CaFA5888C177c325E2C3AbF2D2; // deployed upgrade gate
432
+
433
+ BaseCoin coin = BaseCoin(contentCoin);
434
+
435
+ uint24 oldFee = coin.getPoolKey().fee;
436
+
437
+ // Register upgrade path
438
+ address[] memory baseImpls = new address[](1);
439
+ baseImpls[0] = oldHook;
440
+
441
+ vm.prank(Ownable(upgradeGate).owner());
442
+ IHooksUpgradeGate(upgradeGate).registerUpgradePath(baseImpls, newHook);
443
+
444
+ // Get coin owner
445
+ address coinOwner = MultiOwnable(contentCoin).owners()[0];
446
+
447
+ // First, demonstrate the bug exists - this should fail
448
+ vm.prank(coinOwner);
449
+ vm.expectRevert();
450
+ coin.migrateLiquidity(newHook, "");
451
+
452
+ // Now fix the bug by etching fixed hook code onto the old hook address
453
+ bytes memory creationCode = HooksDeployment.makeHookCreationCode(address(poolManager), coinVersionLookup, new address[](0), upgradeGate);
454
+
455
+ (IHooks fixedHook, ) = HooksDeployment.deployHookWithExistingOrNewSalt(address(this), creationCode, bytes32(0));
456
+
457
+ // Etch the fixed hook code onto the old hook address
458
+ vm.etch(oldHook, address(fixedHook).code);
459
+
460
+ // Now migration should work
461
+ vm.prank(coinOwner);
462
+ coin.migrateLiquidity(newHook, "");
463
+ }
464
+
465
+ function test_migrateLiquidity_canUseNewFee() public {
466
+ // Reproduce the bug discovered in hook version 1.1.2 where migration
467
+ // tries to modify liquidity positions that have zero liquidity
468
+ vm.createSelectFork("base", 35754730);
469
+
470
+ // jacob creator coin
471
+ BaseCoin coin = BaseCoin(0x9B13358E3a023507E7046c18f508A958cDA75f54);
472
+
473
+ address upgradeGate = 0xD88f6BdD765313CaFA5888C177c325E2C3AbF2D2; // live upgrade gate
474
+
475
+ uint24 oldFee = coin.getPoolKey().fee;
476
+
477
+ assertEq(oldFee, 30000);
478
+
479
+ // Now fix the bug by etching fixed hook code onto the old hook address
480
+ bytes memory creationCode = HooksDeployment.makeHookCreationCode(address(poolManager), coinVersionLookup, new address[](0), upgradeGate);
481
+
482
+ (IHooks newHook, ) = HooksDeployment.deployHookWithExistingOrNewSalt(address(this), creationCode, bytes32(0));
483
+
484
+ // Register upgrade path
485
+ address[] memory baseImpls = new address[](1);
486
+ baseImpls[0] = address(coin.hooks());
487
+
488
+ vm.prank(Ownable(upgradeGate).owner());
489
+ IHooksUpgradeGate(upgradeGate).registerUpgradePath(baseImpls, address(newHook));
490
+
491
+ // Get coin owner
492
+ address coinOwner = MultiOwnable(address(coin)).owners()[0];
493
+
494
+ vm.prank(coinOwner);
495
+ coin.migrateLiquidity(address(newHook), "");
496
+
497
+ // fee should still be the same as before, because we didnt have the logic to update the fee in the old coin's hook.
498
+ assertEq(coin.getPoolKey().fee, oldFee);
499
+
500
+ address currencyAddress = address(coin.currency());
501
+
502
+ // now test swapping the migrated liquidity
503
+ address trader = makeAddr("trader");
504
+ deal(currencyAddress, trader, 10 ether);
505
+ _swapSomeCurrencyForCoin(coin, coin.currency(), 1 ether, trader);
506
+
507
+ // now migrate liquidity again, but this time to the same new hook as before
508
+ // since the bug has been fixed in the new hook, we should now be able to get the new fee
509
+ // register the upgrade path for the new hook to itself
510
+ baseImpls[0] = address(newHook);
511
+ vm.prank(Ownable(upgradeGate).owner());
512
+ IHooksUpgradeGate(upgradeGate).registerUpgradePath(baseImpls, address(newHook));
513
+
514
+ // migrate liquidity again to the same new hook as before
515
+ vm.prank(coinOwner);
516
+ coin.migrateLiquidity(address(newHook), "");
517
+
518
+ // the new fee should be the correct current fee
519
+ assertEq(coin.getPoolKey().fee, CoinConstants.LP_FEE_V4);
520
+
521
+ // now test swapping the migrated liquidity - it should work
522
+ _swapSomeCurrencyForCoin(coin, coin.currency(), 1 ether, trader);
523
+ }
386
524
  }
@@ -5,7 +5,7 @@ import "./utils/BaseTest.sol";
5
5
 
6
6
  contract MultiOwnableTest is BaseTest {
7
7
  function setUp() public override {
8
- super.setUp();
8
+ super.setUpNonForked();
9
9
 
10
10
  _deployV4Coin();
11
11
  }
@@ -135,7 +135,7 @@ contract MultiOwnableTest is BaseTest {
135
135
 
136
136
  function test_revert_init_with_zero_owners() public {
137
137
  address[] memory emptyOwners = new address[](0);
138
- bytes memory poolConfig_ = _generatePoolConfig(address(weth));
138
+ bytes memory poolConfig_ = _generatePoolConfig(address(0));
139
139
  vm.expectRevert(MultiOwnable.OneOwnerRequired.selector);
140
140
  factory.deploy(users.creator, emptyOwners, "https://test.com", "Test Token", "TEST", poolConfig_, users.platformReferrer, 0);
141
141
  }
@@ -143,7 +143,7 @@ contract MultiOwnableTest is BaseTest {
143
143
  function test_revert_init_with_zero_address() public {
144
144
  address[] memory owners = new address[](1);
145
145
  owners[0] = address(0);
146
- bytes memory poolConfig_ = _generatePoolConfig(address(weth));
146
+ bytes memory poolConfig_ = _generatePoolConfig(address(0));
147
147
  vm.expectRevert(MultiOwnable.OwnerCannotBeAddressZero.selector);
148
148
  factory.deploy(users.creator, owners, "https://test.com", "Test Token", "TEST", poolConfig_, users.platformReferrer, 0);
149
149
  }
@@ -152,7 +152,7 @@ contract MultiOwnableTest is BaseTest {
152
152
  address[] memory owners = new address[](2);
153
153
  owners[0] = users.creator;
154
154
  owners[1] = users.creator;
155
- bytes memory poolConfig_ = _generatePoolConfig(address(weth));
155
+ bytes memory poolConfig_ = _generatePoolConfig(address(0));
156
156
  vm.expectRevert(MultiOwnable.AlreadyOwner.selector);
157
157
  factory.deploy(users.creator, owners, "https://test.com", "Test Token", "TEST", poolConfig_, users.platformReferrer, 0);
158
158
  }
@@ -14,6 +14,7 @@ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
14
14
  import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
15
15
  import {BuySupplyWithSwapRouterHook} from "../src/hooks/deployment/BuySupplyWithSwapRouterHook.sol";
16
16
  import {ZoraV4CoinHook} from "../src/hooks/ZoraV4CoinHook.sol";
17
+ import {BuySupplyWithV4SwapHook} from "../src/hooks/deployment/BuySupplyWithV4SwapHook.sol";
17
18
  import {console} from "forge-std/console.sol";
18
19
  import {IDeployedCoinVersionLookup} from "../src/interfaces/IDeployedCoinVersionLookup.sol";
19
20
  import {IHooksUpgradeGate} from "../src/interfaces/IHooksUpgradeGate.sol";
@@ -106,26 +107,34 @@ contract UpgradesTest is BaseTest, CoinsDeployerBase {
106
107
 
107
108
  uint128 amountIn = 1 ether;
108
109
 
109
- address buySupplyWithSwapRouterHook = deployment.buySupplyWithSwapRouterHook;
110
-
111
- // build weth to usdc swap
112
- bytes memory call = abi.encodeWithSelector(
113
- ISwapRouter.exactInputSingle.selector,
114
- ISwapRouter.ExactInputSingleParams({
115
- tokenIn: WETH_ADDRESS,
116
- tokenOut: ZORA,
117
- fee: 3000,
118
- recipient: buySupplyWithSwapRouterHook,
119
- amountIn: amountIn,
120
- amountOutMinimum: 0,
121
- sqrtPriceLimitX96: 0
122
- })
123
- );
110
+ address buySupplyWithV4SwapHook = deployment.buySupplyWithSwapRouterHook;
124
111
 
125
112
  address buyRecipient = makeAddr("buyRecipient");
126
113
 
127
114
  address trader = 0xC077e4cC02fa01A5b7fAca1acE9BBe9f5ac5Af9F;
128
115
 
116
+ // Create V3 path: ETH -> ZORA (using exact path from test)
117
+ bytes memory v3Route = abi.encodePacked(
118
+ WETH_ADDRESS, // WETH
119
+ uint24(3000), // fee
120
+ ZORA // ZORA
121
+ );
122
+
123
+ // No V4 route needed since coin is backed by ZORA
124
+ PoolKey[] memory v4Route = new PoolKey[](0);
125
+
126
+ // Encode proper BuySupplyWithV4SwapHook data
127
+ BuySupplyWithV4SwapHook.InitialSupplyParams memory params = BuySupplyWithV4SwapHook.InitialSupplyParams({
128
+ buyRecipient: buyRecipient,
129
+ v3Route: v3Route,
130
+ v4Route: v4Route,
131
+ inputCurrency: address(0), // ETH
132
+ inputAmount: amountIn,
133
+ minAmountOut: 0
134
+ });
135
+
136
+ bytes memory hookData = abi.encode(params);
137
+
129
138
  vm.startPrank(trader);
130
139
  vm.deal(trader, amountIn);
131
140
 
@@ -137,8 +146,8 @@ contract UpgradesTest is BaseTest, CoinsDeployerBase {
137
146
  "TEST",
138
147
  poolConfig,
139
148
  users.platformReferrer,
140
- buySupplyWithSwapRouterHook,
141
- abi.encode(buyRecipient, call),
149
+ buySupplyWithV4SwapHook,
150
+ hookData,
142
151
  keccak256("test")
143
152
  );
144
153
 
@@ -0,0 +1,178 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.13;
3
+
4
+ import "./utils/BaseTest.sol";
5
+ import {CoinConfigurationVersions} from "../src/libs/CoinConfigurationVersions.sol";
6
+ import {CoinDopplerMultiCurve} from "../src/libs/CoinDopplerMultiCurve.sol";
7
+ import {V4Liquidity} from "../src/libs/V4Liquidity.sol";
8
+ import {LpPosition} from "../src/types/LpPosition.sol";
9
+ import {PoolConfiguration} from "../src/interfaces/ICoin.sol";
10
+ import {CoinConstants} from "../src/libs/CoinConstants.sol";
11
+ import {MockERC20} from "./mocks/MockERC20.sol";
12
+ import {ContentCoin} from "../src/ContentCoin.sol";
13
+ import {ZoraV4CoinHook} from "../src/hooks/ZoraV4CoinHook.sol";
14
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
15
+
16
+ contract V4LiquidityTest is BaseTest {
17
+ MockERC20 internal mockERC20A;
18
+
19
+ function setUp() public override {
20
+ super.setUp();
21
+ mockERC20A = new MockERC20("MockERC20A", "MCKA");
22
+ }
23
+
24
+ function _poolConfigWithDuplicatePositions(address currency) private pure returns (bytes memory poolConfig) {
25
+ // Create configuration that will produce duplicate positions
26
+ int24[] memory tickLower_ = new int24[](2);
27
+ tickLower_[0] = -54000;
28
+ tickLower_[1] = -54000; // Same as first curve
29
+
30
+ int24[] memory tickUpper_ = new int24[](2);
31
+ tickUpper_[0] = 7000;
32
+ tickUpper_[1] = 7000; // Same as first curve
33
+
34
+ uint16[] memory numDiscoveryPositions_ = new uint16[](2);
35
+ numDiscoveryPositions_[0] = 5;
36
+ numDiscoveryPositions_[1] = 5;
37
+
38
+ uint256[] memory maxDiscoverySupplyShare_ = new uint256[](2);
39
+ maxDiscoverySupplyShare_[0] = 100000000000000000; // 0.1e18
40
+ maxDiscoverySupplyShare_[1] = 100000000000000000; // 0.1e18
41
+
42
+ poolConfig = CoinConfigurationVersions.encodeDopplerMultiCurveUniV4(currency, tickLower_, tickUpper_, numDiscoveryPositions_, maxDiscoverySupplyShare_);
43
+ }
44
+
45
+ function _countDuplicatePositions(LpPosition[] memory positions) private pure returns (uint256 duplicateCount) {
46
+ for (uint256 i = 0; i < positions.length; i++) {
47
+ for (uint256 j = i + 1; j < positions.length; j++) {
48
+ if (positions[i].tickLower == positions[j].tickLower && positions[i].tickUpper == positions[j].tickUpper) {
49
+ duplicateCount++;
50
+ break; // Only count each unique duplicate once
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ function test_calculatePositionsWithDuplicateConfigCreatesDuplicates() public view {
57
+ address currency = address(mockERC20A);
58
+ bytes memory poolConfig = _poolConfigWithDuplicatePositions(currency);
59
+
60
+ (, PoolConfiguration memory poolConfiguration) = CoinDopplerMultiCurve.setupPool(true, poolConfig);
61
+
62
+ LpPosition[] memory positions = CoinDopplerMultiCurve.calculatePositions(
63
+ true, // isCoinToken0
64
+ poolConfiguration,
65
+ CoinConstants.CONTENT_COIN_MARKET_SUPPLY
66
+ );
67
+
68
+ uint256 duplicateCount = _countDuplicatePositions(positions);
69
+ assertGt(duplicateCount, 0, "Should have duplicate positions");
70
+ }
71
+
72
+ function test_dedupePositionsMergesDuplicates() public view {
73
+ address currency = address(mockERC20A);
74
+ bytes memory poolConfig = _poolConfigWithDuplicatePositions(currency);
75
+
76
+ (, PoolConfiguration memory poolConfiguration) = CoinDopplerMultiCurve.setupPool(true, poolConfig);
77
+
78
+ LpPosition[] memory originalPositions = CoinDopplerMultiCurve.calculatePositions(
79
+ true, // isCoinToken0
80
+ poolConfiguration,
81
+ CoinConstants.CONTENT_COIN_MARKET_SUPPLY
82
+ );
83
+
84
+ uint256 originalDuplicateCount = _countDuplicatePositions(originalPositions);
85
+ assertGt(originalDuplicateCount, 0, "Should have duplicate positions to test deduplication");
86
+
87
+ // Deduplicate the positions
88
+ LpPosition[] memory dedupedPositions = V4Liquidity.dedupePositions(originalPositions);
89
+
90
+ // Verify no duplicates exist in deduped array
91
+ uint256 dedupedDuplicateCount = _countDuplicatePositions(dedupedPositions);
92
+ assertEq(dedupedDuplicateCount, 0, "Should have no duplicates after deduplication");
93
+
94
+ // Verify that array is smaller after deduplication
95
+ assertLt(dedupedPositions.length, originalPositions.length, "Deduped array should be smaller");
96
+
97
+ // Calculate total liquidity before and after deduplication
98
+ uint256 totalOriginalLiquidity = 0;
99
+ uint256 totalDedupedLiquidity = 0;
100
+
101
+ for (uint256 i = 0; i < originalPositions.length; i++) {
102
+ totalOriginalLiquidity += originalPositions[i].liquidity;
103
+ }
104
+
105
+ for (uint256 i = 0; i < dedupedPositions.length; i++) {
106
+ totalDedupedLiquidity += dedupedPositions[i].liquidity;
107
+ }
108
+
109
+ assertEq(totalOriginalLiquidity, totalDedupedLiquidity, "Total liquidity should be preserved");
110
+ }
111
+
112
+ function test_memoryStructModification() public pure {
113
+ // this test shows that we can modify a struct in memory and it will be reflected in the array
114
+ LpPosition[] memory positions = new LpPosition[](2);
115
+ positions[0] = LpPosition({tickLower: -100, tickUpper: 100, liquidity: 1000});
116
+ positions[1] = LpPosition({tickLower: -200, tickUpper: 200, liquidity: 2000});
117
+
118
+ LpPosition memory pos = positions[0];
119
+ pos.liquidity += 500;
120
+ pos = positions[1];
121
+ pos.liquidity = 3000;
122
+
123
+ // The array element should be modified
124
+ assertEq(positions[0].liquidity, 1500, "Array element should change when modifying copy");
125
+ assertEq(positions[1].liquidity, 3000, "Array element should change when modifying copy");
126
+ }
127
+
128
+ function test_mstoreArrayLength() public pure {
129
+ LpPosition[] memory positions = new LpPosition[](5);
130
+ positions[0] = LpPosition({tickLower: -100, tickUpper: 100, liquidity: 1000});
131
+ positions[1] = LpPosition({tickLower: -200, tickUpper: 200, liquidity: 2000});
132
+ positions[2] = LpPosition({tickLower: -300, tickUpper: 300, liquidity: 3000});
133
+ positions[3] = LpPosition({tickLower: -400, tickUpper: 400, liquidity: 4000});
134
+ positions[4] = LpPosition({tickLower: -500, tickUpper: 500, liquidity: 5000});
135
+
136
+ assertEq(positions.length, 5, "Initial length should be 5");
137
+
138
+ assembly {
139
+ mstore(positions, 2)
140
+ }
141
+
142
+ assertEq(positions.length, 2, "Length should be 2 after mstore");
143
+ assertEq(positions[0].liquidity, 1000, "First element should be preserved");
144
+ assertEq(positions[1].liquidity, 2000, "Second element should be preserved");
145
+ }
146
+
147
+ function test_deployedCoinWithDuplicateConfigHasNoDuplicatePositions() public {
148
+ address currency = address(mockERC20A);
149
+
150
+ address[] memory owners = new address[](1);
151
+ owners[0] = users.creator;
152
+
153
+ bytes memory poolConfig = _poolConfigWithDuplicatePositions(currency);
154
+
155
+ (address coinAddress, ) = factory.deploy(
156
+ users.creator,
157
+ owners,
158
+ "https://test.com",
159
+ DEFAULT_NAME,
160
+ DEFAULT_SYMBOL,
161
+ poolConfig,
162
+ address(0),
163
+ address(0),
164
+ bytes(""),
165
+ bytes32(0)
166
+ );
167
+
168
+ ContentCoin coinV4 = ContentCoin(payable(coinAddress));
169
+
170
+ // get hooks
171
+ PoolKey memory poolKey = coinV4.getPoolKey();
172
+ LpPosition[] memory positions = ZoraV4CoinHook(payable(address(coinV4.hooks()))).getPoolCoin(poolKey).positions;
173
+
174
+ // Verify no duplicate positions exist in the deployed coin (deduplication worked during deployment)
175
+ uint256 duplicateCount = _countDuplicatePositions(positions);
176
+ assertEq(duplicateCount, 0, "Should have no duplicates after deployment");
177
+ }
178
+ }
@@ -7,20 +7,28 @@ import {IZoraHookRegistry} from "../src/interfaces/IZoraHookRegistry.sol";
7
7
  import {ZoraHookRegistry} from "../src/hook-registry/ZoraHookRegistry.sol";
8
8
 
9
9
  contract MockHook {
10
- function contractVersion() public pure returns (string memory) {
11
- return "0.0.0";
10
+ string private _version;
11
+
12
+ constructor(string memory version_) {
13
+ _version = version_;
14
+ }
15
+
16
+ function contractVersion() public view returns (string memory) {
17
+ return _version;
12
18
  }
13
19
  }
14
20
 
21
+ contract MockHookNoVersion {
22
+ // No contractVersion function
23
+ }
24
+
15
25
  contract ZoraHookRegistryTest is Test {
16
- uint256 internal forkId;
17
26
  address internal owner;
18
27
 
19
28
  ZoraHookRegistry internal zoraHookRegistry;
20
29
  MockHook internal mockHook;
21
30
 
22
31
  function setUp() public {
23
- forkId = vm.createSelectFork("base", 34509280);
24
32
  owner = makeAddr("owner");
25
33
 
26
34
  address[] memory initialOwners = new address[](1);
@@ -29,7 +37,7 @@ contract ZoraHookRegistryTest is Test {
29
37
  zoraHookRegistry = new ZoraHookRegistry();
30
38
  zoraHookRegistry.initialize(initialOwners);
31
39
 
32
- mockHook = new MockHook();
40
+ mockHook = new MockHook("0.0.0");
33
41
  }
34
42
 
35
43
  function test_register_hooks() public {
@@ -179,7 +187,8 @@ contract ZoraHookRegistryTest is Test {
179
187
  address[] memory hooks = new address[](1);
180
188
  string[] memory tags = new string[](1);
181
189
 
182
- hooks[0] = 0x81542dC43Aff247eff4a0eceFC286A2973aE1040;
190
+ MockHook hookWithVersion = new MockHook("1.1.1");
191
+ hooks[0] = address(hookWithVersion);
183
192
  tags[0] = "CONTENT";
184
193
 
185
194
  vm.prank(owner);
@@ -192,7 +201,8 @@ contract ZoraHookRegistryTest is Test {
192
201
  address[] memory hooks = new address[](1);
193
202
  string[] memory tags = new string[](1);
194
203
 
195
- hooks[0] = 0xA1eBdD5cA6470Bbd67114331387f2dDa7bfad040;
204
+ MockHookNoVersion hookNoVersion = new MockHookNoVersion();
205
+ hooks[0] = address(hookNoVersion);
196
206
  tags[0] = "CONTENT";
197
207
 
198
208
  vm.prank(owner);
@@ -207,8 +217,8 @@ contract ZoraHookRegistryTest is Test {
207
217
 
208
218
  function test_get_hook_addresses_multiple_and_remove_middle() public {
209
219
  address a = address(mockHook);
210
- address b = address(new MockHook());
211
- address c = address(new MockHook());
220
+ address b = address(new MockHook("0.0.0"));
221
+ address c = address(new MockHook("0.0.0"));
212
222
 
213
223
  address[] memory hooks = new address[](3);
214
224
  string[] memory tags = new string[](3);
@@ -0,0 +1,22 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.13;
3
+
4
+ import {IAirlock} from "../../src/interfaces/IAirlock.sol";
5
+
6
+ /// @title MockAirlock
7
+ /// @notice Mock implementation of IAirlock for testing purposes
8
+ contract MockAirlock is IAirlock {
9
+ address private _owner;
10
+
11
+ constructor(address owner_) {
12
+ _owner = owner_;
13
+ }
14
+
15
+ function owner() external view override returns (address) {
16
+ return _owner;
17
+ }
18
+
19
+ function setOwner(address newOwner) external {
20
+ _owner = newOwner;
21
+ }
22
+ }
@@ -0,0 +1,8 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.23;
3
+
4
+ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5
+
6
+ contract SimpleERC20 is ERC20 {
7
+ constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
8
+ }