@zoralabs/coins 2.5.0 → 2.6.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 (48) hide show
  1. package/.turbo/turbo-build$colon$js.log +136 -130
  2. package/CHANGELOG.md +28 -17
  3. package/abis/BaseCoin.json +5 -0
  4. package/abis/ContentCoin.json +5 -0
  5. package/abis/ICoin.json +5 -0
  6. package/abis/ICoinV3.json +5 -0
  7. package/abis/ITrendCoin.json +140 -0
  8. package/abis/ITrendCoinErrors.json +33 -0
  9. package/abis/IUniversalRouter.json +61 -0
  10. package/abis/IZoraFactory.json +237 -0
  11. package/abis/TrendCoin.json +2053 -0
  12. package/abis/ZoraFactoryImpl.json +242 -0
  13. package/dist/index.cjs +955 -138
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.js +953 -138
  16. package/dist/index.js.map +1 -1
  17. package/dist/wagmiGenerated.d.ts +1388 -149
  18. package/dist/wagmiGenerated.d.ts.map +1 -1
  19. package/foundry.toml +1 -0
  20. package/package/wagmiGenerated.ts +962 -139
  21. package/package.json +2 -2
  22. package/src/BaseCoin.sol +12 -12
  23. package/src/ContentCoin.sol +20 -1
  24. package/src/CreatorCoin.sol +3 -0
  25. package/src/TrendCoin.sol +117 -0
  26. package/src/ZoraFactoryImpl.sol +142 -1
  27. package/src/hooks/ZoraV4CoinHook.sol +17 -7
  28. package/src/interfaces/ICoin.sol +5 -1
  29. package/src/interfaces/ICreatorCoin.sol +0 -3
  30. package/src/interfaces/IPoolManager.sol +13 -0
  31. package/src/interfaces/ITrendCoin.sol +26 -0
  32. package/src/interfaces/ITrendCoinErrors.sol +24 -0
  33. package/src/interfaces/IZoraFactory.sol +60 -1
  34. package/src/libs/CoinConstants.sol +13 -1
  35. package/src/libs/CoinRewardsV4.sol +82 -21
  36. package/src/libs/TickerUtils.sol +66 -0
  37. package/src/libs/UniV4SwapToCurrency.sol +2 -1
  38. package/src/version/ContractVersionBase.sol +1 -1
  39. package/test/CoinRewardsV4.t.sol +48 -0
  40. package/test/CreatorCoin.t.sol +2 -1
  41. package/test/Factory.t.sol +31 -5
  42. package/test/LaunchFee.t.sol +0 -2
  43. package/test/LiquidityMigration.t.sol +0 -2
  44. package/test/TrendCoin.t.sol +1128 -0
  45. package/test/Upgrades.t.sol +16 -3
  46. package/test/utils/FeeEstimatorHook.sol +36 -10
  47. package/test/utils/V4TestSetup.sol +36 -4
  48. package/wagmi.config.ts +2 -0
@@ -0,0 +1,1128 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.13;
3
+
4
+ import {BaseTest} from "./utils/BaseTest.sol";
5
+ import {console} from "forge-std/console.sol";
6
+
7
+ import {IZoraFactory} from "../src/interfaces/IZoraFactory.sol";
8
+ import {IHasRewardsRecipients} from "../src/interfaces/IHasRewardsRecipients.sol";
9
+ import {IHasCoinType} from "../src/interfaces/ICoin.sol";
10
+ import {CoinRewardsV4} from "../src/libs/CoinRewardsV4.sol";
11
+ import {UniV4SwapHelper} from "../src/libs/UniV4SwapHelper.sol";
12
+ import {FeeEstimatorHook} from "./utils/FeeEstimatorHook.sol";
13
+ import {RewardTestHelpers, RewardBalances} from "./utils/RewardTestHelpers.sol";
14
+ import {CoinConstants} from "../src/libs/CoinConstants.sol";
15
+ import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
16
+ import {TrendCoin} from "../src/TrendCoin.sol";
17
+ import {ITrendCoin} from "../src/interfaces/ITrendCoin.sol";
18
+ import {ITrendCoinErrors} from "../src/interfaces/ITrendCoinErrors.sol";
19
+ import {ICoin} from "../src/interfaces/ICoin.sol";
20
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
21
+ import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
22
+ import {CoinConfigurationVersions} from "../src/libs/CoinConfigurationVersions.sol";
23
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
24
+ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
25
+ import {ZoraFactoryImpl} from "../src/ZoraFactoryImpl.sol";
26
+ import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
27
+ import {ProxyShim} from "../src/utils/ProxyShim.sol";
28
+ import {ZoraFactory} from "../src/proxy/ZoraFactory.sol";
29
+ import {ZoraHookRegistry} from "../src/hook-registry/ZoraHookRegistry.sol";
30
+ import {PoolConfiguration} from "../src/types/PoolConfiguration.sol";
31
+
32
+ contract TrendCoinTest is BaseTest {
33
+ TrendCoin internal trendCoin;
34
+
35
+ function setUp() public override {
36
+ super.setUpNonForked();
37
+ }
38
+
39
+ // ============ Factory Deployment Tests ============
40
+
41
+ function test_deployTrendCoin_basic() public {
42
+ string memory symbol = "TESTTREND";
43
+
44
+ (address coinAddress, ) = factory.deployTrendCoin(symbol, address(0), "");
45
+
46
+ trendCoin = TrendCoin(coinAddress);
47
+
48
+ // Verify basic properties
49
+ assertEq(trendCoin.symbol(), symbol, "Symbol should match");
50
+ assertEq(trendCoin.name(), symbol, "Name should equal symbol for trend coins");
51
+ assertEq(uint8(trendCoin.coinType()), uint8(IHasCoinType.CoinType.Trend), "Should be Trend coin type");
52
+
53
+ // TrendCoins have no payout recipient or platform referrer
54
+ assertEq(trendCoin.payoutRecipient(), address(0), "Payout recipient should be zero");
55
+ assertEq(trendCoin.platformReferrer(), address(0), "Platform referrer should be zero");
56
+ }
57
+
58
+ function test_deployTrendCoin_emitsEventWithPoolConfig() public {
59
+ string memory symbol = "EVENTTEST";
60
+
61
+ // Get expected pool config
62
+ bytes memory expectedPoolConfig = CoinConfigurationVersions.defaultConfig(CoinConstants.CREATOR_COIN_CURRENCY);
63
+
64
+ vm.expectEmit(true, false, false, false);
65
+ emit IZoraFactory.TrendCoinCreated(
66
+ address(this),
67
+ symbol,
68
+ address(0),
69
+ PoolKey(Currency.wrap(address(0)), Currency.wrap(address(0)), 0, 0, hook),
70
+ bytes32(0),
71
+ expectedPoolConfig,
72
+ ""
73
+ );
74
+
75
+ (address coinAddress, ) = factory.deployTrendCoin(symbol, address(0), "");
76
+
77
+ // Verify coin was created
78
+ assertTrue(coinAddress != address(0), "Coin should be created");
79
+ }
80
+
81
+ function test_deployTrendCoin_addressCanBePredicted() public {
82
+ string memory symbol = "PREDICT";
83
+
84
+ // Get predicted address before deployment
85
+ address predictedAddress = factory.trendCoinAddress(symbol);
86
+
87
+ // Deploy the coin
88
+ (address actualAddress, ) = factory.deployTrendCoin(symbol, address(0), "");
89
+
90
+ // Verify prediction matches
91
+ assertEq(actualAddress, predictedAddress, "Predicted address should match actual");
92
+ }
93
+
94
+ function test_deployTrendCoin_tickerUniqueness() public {
95
+ string memory symbol = "UNIQUE";
96
+
97
+ // Deploy first coin
98
+ factory.deployTrendCoin(symbol, address(0), "");
99
+
100
+ // Try to deploy with same ticker - should revert
101
+ vm.expectRevert(abi.encodeWithSelector(ITrendCoinErrors.TickerAlreadyUsed.selector, symbol));
102
+ factory.deployTrendCoin(symbol, address(0), "");
103
+ }
104
+
105
+ function test_deployTrendCoin_tickerCaseInsensitive() public {
106
+ // Deploy with lowercase
107
+ factory.deployTrendCoin("test", address(0), "");
108
+
109
+ // Try to deploy with uppercase - should revert (same ticker, different case)
110
+ vm.expectRevert(abi.encodeWithSelector(ITrendCoinErrors.TickerAlreadyUsed.selector, "TEST"));
111
+ factory.deployTrendCoin("TEST", address(0), "");
112
+
113
+ // Try with mixed case - should also revert
114
+ vm.expectRevert(abi.encodeWithSelector(ITrendCoinErrors.TickerAlreadyUsed.selector, "TeSt"));
115
+ factory.deployTrendCoin("TeSt", address(0), "");
116
+ }
117
+
118
+ function test_deployTrendCoin_differentTickersAllowed() public {
119
+ // Deploy multiple coins with different tickers
120
+ (address coin1, ) = factory.deployTrendCoin("TICKER1", address(0), "");
121
+ (address coin2, ) = factory.deployTrendCoin("TICKER2", address(0), "");
122
+ (address coin3, ) = factory.deployTrendCoin("TICKER3", address(0), "");
123
+
124
+ // All should be different addresses
125
+ assertTrue(coin1 != coin2, "Coin1 and Coin2 should have different addresses");
126
+ assertTrue(coin2 != coin3, "Coin2 and Coin3 should have different addresses");
127
+ assertTrue(coin1 != coin3, "Coin1 and Coin3 should have different addresses");
128
+ }
129
+
130
+ function test_deployTrendCoin_fullSupplyToPool() public {
131
+ string memory symbol = "FULLPOOL";
132
+
133
+ (address coinAddress, ) = factory.deployTrendCoin(symbol, address(0), "");
134
+ trendCoin = TrendCoin(coinAddress);
135
+
136
+ // Total supply should be the full 1B
137
+ assertEq(trendCoin.totalSupply(), CoinConstants.TOTAL_SUPPLY, "Total supply should be 1B");
138
+
139
+ // Verify token allocation - the coin itself should have 0 balance (all sent to hook for pool)
140
+ assertEq(trendCoin.balanceOf(coinAddress), 0, "Coin should have no balance");
141
+ }
142
+
143
+ // ============ Symbol Validation Tests ============
144
+
145
+ function test_deployTrendCoin_validSymbols_lettersOnly() public {
146
+ // Test uppercase letters
147
+ (address coin1, ) = factory.deployTrendCoin("ABC", address(0), "");
148
+ assertTrue(coin1 != address(0), "Coin with uppercase letters should deploy");
149
+
150
+ // Test lowercase letters
151
+ (address coin2, ) = factory.deployTrendCoin("xyz", address(0), "");
152
+ assertTrue(coin2 != address(0), "Coin with lowercase letters should deploy");
153
+
154
+ // Test mixed case
155
+ (address coin3, ) = factory.deployTrendCoin("TestCoin", address(0), "");
156
+ assertTrue(coin3 != address(0), "Coin with mixed case letters should deploy");
157
+ }
158
+
159
+ function test_deployTrendCoin_validSymbols_numbersOnly() public {
160
+ (address coin1, ) = factory.deployTrendCoin("123", address(0), "");
161
+ assertTrue(coin1 != address(0), "Coin with numbers should deploy");
162
+
163
+ (address coin2, ) = factory.deployTrendCoin("456", address(0), "");
164
+ assertTrue(coin2 != address(0), "Coin with different numbers should deploy");
165
+ }
166
+
167
+ function test_deployTrendCoin_invalidSymbols_dashOnly() public {
168
+ vm.expectRevert(ITrendCoinErrors.TickerInvalidCharacters.selector);
169
+ factory.deployTrendCoin("--", address(0), "");
170
+
171
+ vm.expectRevert(ITrendCoinErrors.TickerInvalidCharacters.selector);
172
+ factory.deployTrendCoin("---", address(0), "");
173
+ }
174
+
175
+ function test_deployTrendCoin_invalidSymbols_spaceOnly() public {
176
+ vm.expectRevert(ITrendCoinErrors.TickerInvalidCharacters.selector);
177
+ factory.deployTrendCoin(" ", address(0), "");
178
+
179
+ vm.expectRevert(ITrendCoinErrors.TickerInvalidCharacters.selector);
180
+ factory.deployTrendCoin(" ", address(0), "");
181
+ }
182
+
183
+ function test_deployTrendCoin_validSymbols_allAllowedCharacters() public {
184
+ (address coin1, ) = factory.deployTrendCoin("ABC123xyz", address(0), "");
185
+ assertTrue(coin1 != address(0), "Coin with all character types should deploy");
186
+
187
+ (address coin2, ) = factory.deployTrendCoin("Test123Coin", address(0), "");
188
+ assertTrue(coin2 != address(0), "Coin with mixed valid characters should deploy");
189
+ }
190
+
191
+ struct InvalidSymbolTestCase {
192
+ string symbol;
193
+ }
194
+
195
+ function fixtureInvalidSymbols() public pure returns (InvalidSymbolTestCase[] memory) {
196
+ InvalidSymbolTestCase[] memory cases = new InvalidSymbolTestCase[](35);
197
+ cases[0] = InvalidSymbolTestCase("TEST!");
198
+ cases[1] = InvalidSymbolTestCase("TEST@");
199
+ cases[2] = InvalidSymbolTestCase("TEST#");
200
+ cases[3] = InvalidSymbolTestCase("TEST$");
201
+ cases[4] = InvalidSymbolTestCase("TEST%");
202
+ cases[5] = InvalidSymbolTestCase("TEST^");
203
+ cases[6] = InvalidSymbolTestCase("TEST&");
204
+ cases[7] = InvalidSymbolTestCase("TEST*");
205
+ cases[8] = InvalidSymbolTestCase("TEST(");
206
+ cases[9] = InvalidSymbolTestCase("TEST)");
207
+ cases[10] = InvalidSymbolTestCase("TEST_");
208
+ cases[11] = InvalidSymbolTestCase("TEST+");
209
+ cases[12] = InvalidSymbolTestCase("TEST=");
210
+ cases[13] = InvalidSymbolTestCase("TEST[");
211
+ cases[14] = InvalidSymbolTestCase("TEST]");
212
+ cases[15] = InvalidSymbolTestCase("TEST{");
213
+ cases[16] = InvalidSymbolTestCase("TEST}");
214
+ cases[17] = InvalidSymbolTestCase("TEST|");
215
+ cases[18] = InvalidSymbolTestCase("TEST\\");
216
+ cases[19] = InvalidSymbolTestCase("TEST:");
217
+ cases[20] = InvalidSymbolTestCase("TEST;");
218
+ cases[21] = InvalidSymbolTestCase('TEST"');
219
+ cases[22] = InvalidSymbolTestCase("TEST'");
220
+ cases[23] = InvalidSymbolTestCase("TEST<");
221
+ cases[24] = InvalidSymbolTestCase("TEST>");
222
+ cases[25] = InvalidSymbolTestCase("TEST,");
223
+ cases[26] = InvalidSymbolTestCase("TEST.");
224
+ cases[27] = InvalidSymbolTestCase("TEST?");
225
+ cases[28] = InvalidSymbolTestCase("TEST/");
226
+ cases[29] = InvalidSymbolTestCase("TEST~");
227
+ cases[30] = InvalidSymbolTestCase("TEST_COIN");
228
+ cases[31] = InvalidSymbolTestCase("TEST.COIN");
229
+ cases[32] = InvalidSymbolTestCase("TEST!@#");
230
+ cases[33] = InvalidSymbolTestCase("TEST COIN"); // space not allowed
231
+ cases[34] = InvalidSymbolTestCase("TEST-COIN"); // dash not allowed
232
+ return cases;
233
+ }
234
+
235
+ function tableInvalidSymbolsTest(InvalidSymbolTestCase memory invalidSymbols) public {
236
+ vm.expectRevert(ITrendCoinErrors.TickerInvalidCharacters.selector);
237
+ factory.deployTrendCoin(invalidSymbols.symbol, address(0), "");
238
+ }
239
+
240
+ function test_deployTrendCoin_invalidSymbols_withSpaces() public {
241
+ vm.expectRevert(ITrendCoinErrors.TickerInvalidCharacters.selector);
242
+ factory.deployTrendCoin("TEST COIN", address(0), "");
243
+
244
+ vm.expectRevert(ITrendCoinErrors.TickerInvalidCharacters.selector);
245
+ factory.deployTrendCoin(" TEST ", address(0), "");
246
+
247
+ vm.expectRevert(ITrendCoinErrors.TickerInvalidCharacters.selector);
248
+ factory.deployTrendCoin("TEST COIN 123", address(0), "");
249
+ }
250
+
251
+ function test_deployTrendCoin_validSymbols_lettersAndNumbers() public {
252
+ (address coin1, ) = factory.deployTrendCoin("TEST123", address(0), "");
253
+ assertTrue(coin1 != address(0), "Coin with letters then numbers should deploy");
254
+
255
+ (address coin2, ) = factory.deployTrendCoin("123TEST", address(0), "");
256
+ assertTrue(coin2 != address(0), "Coin with numbers then letters should deploy");
257
+
258
+ (address coin3, ) = factory.deployTrendCoin("T1E2S3T", address(0), "");
259
+ assertTrue(coin3 != address(0), "Coin with alternating letters and numbers should deploy");
260
+ }
261
+
262
+ function test_deployTrendCoin_invalidSymbols_lettersAndDash() public {
263
+ vm.expectRevert(ITrendCoinErrors.TickerInvalidCharacters.selector);
264
+ factory.deployTrendCoin("TEST-COIN", address(0), "");
265
+
266
+ vm.expectRevert(ITrendCoinErrors.TickerInvalidCharacters.selector);
267
+ factory.deployTrendCoin("-TEST-", address(0), "");
268
+
269
+ vm.expectRevert(ITrendCoinErrors.TickerInvalidCharacters.selector);
270
+ factory.deployTrendCoin("TEST--COIN", address(0), "");
271
+ }
272
+
273
+ function test_deployTrendCoin_validSymbols_alphanumericMixed() public {
274
+ (address coin1, ) = factory.deployTrendCoin("TEST123Coin", address(0), "");
275
+ assertTrue(coin1 != address(0), "Coin with all alphanumeric types should deploy");
276
+
277
+ (address coin2, ) = factory.deployTrendCoin("ABC123XYZ", address(0), "");
278
+ assertTrue(coin2 != address(0), "Coin with mixed valid characters should deploy");
279
+
280
+ (address coin3, ) = factory.deployTrendCoin("Test456Coin789", address(0), "");
281
+ assertTrue(coin3 != address(0), "Coin with complex valid pattern should deploy");
282
+ }
283
+
284
+ function test_deployTrendCoin_validSymbols_lengthBoundaries() public {
285
+ // Minimum valid length (2 characters)
286
+ (address coin1, ) = factory.deployTrendCoin("AB", address(0), "");
287
+ assertTrue(coin1 != address(0), "Coin with 2 characters should deploy");
288
+
289
+ // Maximum valid length (32 characters)
290
+ (address coin2, ) = factory.deployTrendCoin("ABCDEFGHIJKLMNOPQRSTUVWXYZ123456", address(0), "");
291
+ assertTrue(coin2 != address(0), "Coin with 32 characters should deploy");
292
+ }
293
+
294
+ function test_deployTrendCoin_invalidSymbols_tooShort() public {
295
+ vm.expectRevert(ITrendCoinErrors.TickerTooShort.selector);
296
+ factory.deployTrendCoin("A", address(0), "");
297
+
298
+ vm.expectRevert(ITrendCoinErrors.TickerTooShort.selector);
299
+ factory.deployTrendCoin("", address(0), "");
300
+ }
301
+
302
+ function test_deployTrendCoin_invalidSymbols_tooLong() public {
303
+ vm.expectRevert(ITrendCoinErrors.TickerTooLong.selector);
304
+ factory.deployTrendCoin("ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567", address(0), "");
305
+ }
306
+
307
+ // ============ Fee Distribution Tests ============
308
+
309
+ function _deployTrendCoin() internal {
310
+ string memory symbol = "FEETEST";
311
+ (address coinAddress, ) = factory.deployTrendCoin(symbol, address(0), "");
312
+ trendCoin = TrendCoin(coinAddress);
313
+ vm.label(address(trendCoin), "TEST_TREND_COIN");
314
+ }
315
+
316
+ function _estimateLpFees(bytes memory commands, bytes[] memory inputs) internal returns (FeeEstimatorHook.FeeEstimatorState memory feeState) {
317
+ uint256 snapshot = vm.snapshotState();
318
+ _deployFeeEstimatorHook(address(hook));
319
+
320
+ // Execute the swap
321
+ uint256 deadline = block.timestamp + 20;
322
+ router.execute(commands, inputs, deadline);
323
+
324
+ feeState = FeeEstimatorHook(payable(address(hook))).getFeeState();
325
+
326
+ vm.revertToState(snapshot);
327
+ }
328
+
329
+ function _recordZoraBalances() internal view returns (RewardBalances memory balances) {
330
+ // For TrendCoins: creator and platformReferrer are address(0), so those balances will be 0
331
+ balances.creator = 0; // No creator for trend coins
332
+ balances.platformReferrer = 0; // No platform referrer for trend coins
333
+ balances.tradeReferrer = 0; // We'll track this if provided
334
+ balances.protocol = zoraToken.balanceOf(trendCoin.protocolRewardRecipient());
335
+ balances.doppler = zoraToken.balanceOf(trendCoin.dopplerFeeRecipient());
336
+ }
337
+
338
+ function _buyTrendCoin(uint128 amountIn) internal returns (uint256 feeCurrency) {
339
+ deal(address(zoraToken), users.buyer, amountIn);
340
+
341
+ vm.warp(block.timestamp + 1 days);
342
+
343
+ vm.startPrank(users.buyer);
344
+ UniV4SwapHelper.approveTokenWithPermit2(permit2, address(router), address(zoraToken), uint128(amountIn), uint48(block.timestamp + 1 days));
345
+
346
+ (bytes memory commands, bytes[] memory inputs) = UniV4SwapHelper.buildExactInputSingleSwapCommand(
347
+ address(zoraToken),
348
+ uint128(amountIn),
349
+ address(trendCoin),
350
+ 0,
351
+ trendCoin.getPoolKey(),
352
+ bytes("") // No trade referrer
353
+ );
354
+
355
+ // Estimate the total fees before executing
356
+ FeeEstimatorHook.FeeEstimatorState memory feeState = _estimateLpFees(commands, inputs);
357
+
358
+ feeCurrency = feeState.afterSwapCurrencyAmount;
359
+
360
+ router.execute(commands, inputs, block.timestamp + 1 days);
361
+ vm.stopPrank();
362
+ }
363
+
364
+ /// @notice Test that TrendCoin fee distribution sends 100% to protocol with no LP remint
365
+ /// For TrendCoins, all market rewards go to protocolRewardRecipient and LP remint is skipped.
366
+ /// Note: In Foundry tests, transient storage persists for the whole test (single tx), so
367
+ /// isDeploying stays true and _calculateLaunchFee always returns LP_FEE_V4 (1%).
368
+ /// In production (separate txs), trend coins use TREND_LP_FEE_V4 (0.01%) after launch.
369
+ function test_trendCoin_feeDistribution_100PercentToProtocol() public {
370
+ _deployTrendCoin();
371
+
372
+ // Roll forward past the launch fee period (10 seconds) to avoid high launch fees
373
+ vm.warp(block.timestamp + 11 seconds);
374
+
375
+ uint128 tradeAmount = 1000 ether; // 1000 ZORA tokens
376
+
377
+ // Record initial protocol balance
378
+ address protocolRecipient = trendCoin.protocolRewardRecipient();
379
+ uint256 initialProtocolBalance = zoraToken.balanceOf(protocolRecipient);
380
+
381
+ // Perform trade (note: _buyTrendCoin also does vm.warp but this makes explicit we're past launch)
382
+ _buyTrendCoin(tradeAmount);
383
+
384
+ // Calculate final protocol balance
385
+ uint256 finalProtocolBalance = zoraToken.balanceOf(protocolRecipient);
386
+ uint256 protocolReward = finalProtocolBalance - initialProtocolBalance;
387
+
388
+ // Fee breakdown for TrendCoins in test (isDeploying=true due to transient storage):
389
+ // - Total fee: 1% (LP_FEE_V4, because isDeploying bypass is active)
390
+ // - No LP remint (skipped for trend coins)
391
+ // - Protocol gets 100% of fees
392
+ //
393
+ // Expected calculation:
394
+ // - Total fees = tradeAmount * 1% = 10 ZORA
395
+ // - Protocol receives = 10 ZORA (100% of fees, no LP remint for TrendCoins)
396
+ uint256 expectedTotalFees = (uint256(tradeAmount) * CoinConstants.LP_FEE_V4) / 1_000_000;
397
+
398
+ // Protocol should receive approximately all fees (allowing small rounding tolerance)
399
+ assertApproxEqRel(protocolReward, expectedTotalFees, 0.01e18, "Protocol should receive ~100% of total fees");
400
+
401
+ // Verify actual value is reasonable (should be ~10 ZORA for 1000 ZORA trade at 1% fee)
402
+ assertGt(protocolReward, 9.9 ether, "Protocol reward should be > 9.9 ZORA");
403
+ assertLt(protocolReward, 10.1 ether, "Protocol reward should be < 10.1 ZORA");
404
+
405
+ // Verify TrendCoin recipients are correctly configured (no creator/referrer rewards)
406
+ assertEq(trendCoin.payoutRecipient(), address(0), "Payout recipient should be zero");
407
+ assertEq(trendCoin.platformReferrer(), address(0), "Platform referrer should be zero");
408
+ }
409
+
410
+ /// @notice Test that TrendCoin has correct coin type
411
+ function test_trendCoin_coinType() public {
412
+ _deployTrendCoin();
413
+
414
+ IHasCoinType.CoinType theCoinType = CoinRewardsV4.getCoinType(IHasRewardsRecipients(address(trendCoin)));
415
+ assertEq(uint8(theCoinType), uint8(IHasCoinType.CoinType.Trend), "Should be Trend coin type");
416
+ }
417
+
418
+ /// @notice Test that TrendCoin recipients return expected values
419
+ function test_trendCoin_rewardRecipients() public {
420
+ _deployTrendCoin();
421
+
422
+ // Payout recipient and platform referrer should be address(0)
423
+ assertEq(trendCoin.payoutRecipient(), address(0), "Payout recipient should be zero");
424
+ assertEq(trendCoin.platformReferrer(), address(0), "Platform referrer should be zero");
425
+
426
+ // Protocol reward recipient should be set
427
+ assertTrue(trendCoin.protocolRewardRecipient() != address(0), "Protocol recipient should be set");
428
+
429
+ // Doppler fee recipient should be set
430
+ assertTrue(trendCoin.dopplerFeeRecipient() != address(0), "Doppler recipient should be set");
431
+ }
432
+
433
+ // ============ Zero Fee After Launch Tests ============
434
+
435
+ /// @notice TrendCoins should have 0.01% swap fee after the launch fee duration
436
+ /// forge-config: default.isolate = true
437
+ function test_trendCoin_minimalFeeAfterLaunchDuration() public {
438
+ _deployTrendCoin();
439
+
440
+ uint128 amountIn = 100 ether;
441
+ address trader = makeAddr("trader");
442
+
443
+ // Snapshot at same pool state for both swaps
444
+ uint256 snapshot = vm.snapshotState();
445
+
446
+ // Swap right at the end of launch period (0.01% fee for trend coins)
447
+ vm.warp(block.timestamp + CoinConstants.LAUNCH_FEE_DURATION);
448
+ deal(address(zoraToken), trader, amountIn);
449
+ vm.startPrank(trader);
450
+ UniV4SwapHelper.approveTokenWithPermit2(permit2, address(router), address(zoraToken), amountIn, uint48(block.timestamp + 1 days));
451
+ (bytes memory commands, bytes[] memory inputs) = UniV4SwapHelper.buildExactInputSingleSwapCommand(
452
+ address(zoraToken),
453
+ amountIn,
454
+ address(trendCoin),
455
+ 0,
456
+ trendCoin.getPoolKey(),
457
+ bytes("")
458
+ );
459
+ router.execute(commands, inputs, block.timestamp + 1 days);
460
+ vm.stopPrank();
461
+ uint256 coinsAtDuration = trendCoin.balanceOf(trader);
462
+
463
+ vm.revertToState(snapshot);
464
+
465
+ // Swap well after launch period — should yield same amount (both 0.01% fee)
466
+ vm.warp(block.timestamp + CoinConstants.LAUNCH_FEE_DURATION + 1 days);
467
+ deal(address(zoraToken), trader, amountIn);
468
+ vm.startPrank(trader);
469
+ UniV4SwapHelper.approveTokenWithPermit2(permit2, address(router), address(zoraToken), amountIn, uint48(block.timestamp + 1 days));
470
+ (commands, inputs) = UniV4SwapHelper.buildExactInputSingleSwapCommand(
471
+ address(zoraToken),
472
+ amountIn,
473
+ address(trendCoin),
474
+ 0,
475
+ trendCoin.getPoolKey(),
476
+ bytes("")
477
+ );
478
+ router.execute(commands, inputs, block.timestamp + 1 days);
479
+ vm.stopPrank();
480
+ uint256 coinsAfterDuration = trendCoin.balanceOf(trader);
481
+
482
+ // Both should be approximately equal (0.01% fee in both cases, same pool state)
483
+ assertApproxEqRel(coinsAtDuration, coinsAfterDuration, 0.01e18, "both swaps should yield same coins at 0.01% fee");
484
+ }
485
+
486
+ /// @notice TrendCoins should still have the launch fee during the launch period
487
+ /// forge-config: default.isolate = true
488
+ function test_trendCoin_launchFeeStillAppliesDuringLaunch() public {
489
+ _deployTrendCoin();
490
+
491
+ uint128 amountIn = 100 ether;
492
+ address trader = makeAddr("trader");
493
+
494
+ uint256 snapshot = vm.snapshotState();
495
+
496
+ // Swap immediately (launch fee ~99%)
497
+ deal(address(zoraToken), trader, amountIn);
498
+ vm.startPrank(trader);
499
+ UniV4SwapHelper.approveTokenWithPermit2(permit2, address(router), address(zoraToken), amountIn, uint48(block.timestamp + 1 days));
500
+ (bytes memory commands, bytes[] memory inputs) = UniV4SwapHelper.buildExactInputSingleSwapCommand(
501
+ address(zoraToken),
502
+ amountIn,
503
+ address(trendCoin),
504
+ 0,
505
+ trendCoin.getPoolKey(),
506
+ bytes("")
507
+ );
508
+ router.execute(commands, inputs, block.timestamp + 1 days);
509
+ vm.stopPrank();
510
+ uint256 coinsAtLaunch = trendCoin.balanceOf(trader);
511
+
512
+ vm.revertToState(snapshot);
513
+
514
+ // Swap after launch period (0.01% fee for trend coins)
515
+ vm.warp(block.timestamp + CoinConstants.LAUNCH_FEE_DURATION + 1);
516
+ deal(address(zoraToken), trader, amountIn);
517
+ vm.startPrank(trader);
518
+ UniV4SwapHelper.approveTokenWithPermit2(permit2, address(router), address(zoraToken), amountIn, uint48(block.timestamp + 1 days));
519
+ (commands, inputs) = UniV4SwapHelper.buildExactInputSingleSwapCommand(
520
+ address(zoraToken),
521
+ amountIn,
522
+ address(trendCoin),
523
+ 0,
524
+ trendCoin.getPoolKey(),
525
+ bytes("")
526
+ );
527
+ router.execute(commands, inputs, block.timestamp + 1 days);
528
+ vm.stopPrank();
529
+ uint256 coinsPostLaunch = trendCoin.balanceOf(trader);
530
+
531
+ // Post-launch (0.01% fee) should yield significantly more coins than during launch (~99% fee)
532
+ assertGt(coinsPostLaunch, coinsAtLaunch, "should receive more coins after launch fee ends");
533
+ }
534
+
535
+ /// @notice Test that TrendCoin uses correct production curve configuration
536
+ function test_trendCoin_curveConfiguration() public {
537
+ // Deploy a trend coin
538
+ factory.deployTrendCoin("CURVETEST", address(0), "");
539
+
540
+ // Decode the production pool config
541
+ (
542
+ uint8 version,
543
+ address currency,
544
+ int24[] memory tickLower,
545
+ int24[] memory tickUpper,
546
+ uint16[] memory numDiscoveryPositions,
547
+ uint256[] memory maxDiscoverySupplyShare
548
+ ) = CoinConfigurationVersions.decodeDopplerMultiCurveUniV4(CoinConstants.TREND_COIN_DEFAULT_POOL_CONFIG);
549
+
550
+ // Verify version is 4 (Doppler Multi-Curve Uni V4)
551
+ assertEq(version, 4, "Version should be 4");
552
+
553
+ // Verify currency is ZORA
554
+ assertEq(currency, CoinConstants.CREATOR_COIN_CURRENCY, "Currency should be ZORA");
555
+
556
+ // Verify 3 curves
557
+ assertEq(tickLower.length, 3, "Should have 3 curves");
558
+ assertEq(tickUpper.length, 3, "Should have 3 curves");
559
+ assertEq(numDiscoveryPositions.length, 3, "Should have 3 curves");
560
+ assertEq(maxDiscoverySupplyShare.length, 3, "Should have 3 curves");
561
+
562
+ // Verify Curve 1: ticks [-89200, -75200], 11 positions, 5% max supply
563
+ assertEq(tickLower[0], -89200, "Curve 1 lower tick should be -89200");
564
+ assertEq(tickUpper[0], -75200, "Curve 1 upper tick should be -75200");
565
+ assertEq(numDiscoveryPositions[0], 11, "Curve 1 should have 11 positions");
566
+ assertEq(maxDiscoverySupplyShare[0], 0.05e18, "Curve 1 max supply should be 5%");
567
+
568
+ // Verify Curve 2: ticks [-77200, -68200], 11 positions, 12.5% max supply
569
+ assertEq(tickLower[1], -77200, "Curve 2 lower tick should be -77200");
570
+ assertEq(tickUpper[1], -68200, "Curve 2 upper tick should be -68200");
571
+ assertEq(numDiscoveryPositions[1], 11, "Curve 2 should have 11 positions");
572
+ assertEq(maxDiscoverySupplyShare[1], 0.125e18, "Curve 2 max supply should be 12.5%");
573
+
574
+ // Verify Curve 3: ticks [-71200, -68200], 11 positions, 20% max supply
575
+ assertEq(tickLower[2], -71200, "Curve 3 lower tick should be -71200");
576
+ assertEq(tickUpper[2], -68200, "Curve 3 upper tick should be -68200");
577
+ assertEq(numDiscoveryPositions[2], 11, "Curve 3 should have 11 positions");
578
+ assertEq(maxDiscoverySupplyShare[2], 0.20e18, "Curve 3 max supply should be 20%");
579
+ }
580
+
581
+ // ============ Post-Deploy Hook Tests ============
582
+
583
+ function test_deployTrendCoin_withNullHook() public {
584
+ string memory symbol = "NULLHOOK";
585
+
586
+ // Deploy with null hook (backward compatible behavior)
587
+ (address coinAddress, bytes memory hookDataOut) = factory.deployTrendCoin(symbol, address(0), "");
588
+
589
+ // Verify coin was created
590
+ assertTrue(coinAddress != address(0), "Coin should be created");
591
+ assertEq(hookDataOut.length, 0, "Hook data should be empty");
592
+
593
+ // Verify TrendCoin properties
594
+ TrendCoin coin = TrendCoin(payable(coinAddress));
595
+ assertEq(coin.symbol(), symbol, "Symbol should match");
596
+ assertEq(uint8(coin.coinType()), uint8(IHasCoinType.CoinType.Trend), "Should be Trend coin type");
597
+ }
598
+
599
+ function test_revertWhen_deployTrendCoin_ethWithoutHook() public {
600
+ string memory symbol = "ETHERROR";
601
+
602
+ // Should revert when sending ETH without hook
603
+ vm.expectRevert(IZoraFactory.EthTransferInvalid.selector);
604
+ factory.deployTrendCoin{value: 1 ether}(symbol, address(0), "");
605
+ }
606
+
607
+ function test_revertWhen_deployTrendCoin_invalidHook() public {
608
+ string memory symbol = "BADHOOK";
609
+
610
+ // Create invalid hook (EOA without code doesn't implement IHasAfterCoinDeploy)
611
+ address invalidHook = makeAddr("invalidHook");
612
+
613
+ vm.expectRevert();
614
+ factory.deployTrendCoin{value: 1 ether}(symbol, invalidHook, "");
615
+ }
616
+
617
+ function test_revertWhen_deployTrendCoin_tickerAlreadyUsedWithHook() public {
618
+ string memory symbol = "DUPLICATE";
619
+
620
+ // Deploy first coin without hook
621
+ factory.deployTrendCoin(symbol, address(0), "");
622
+
623
+ // Try to deploy with same ticker - should revert even with hook address
624
+ vm.expectRevert(abi.encodeWithSelector(ITrendCoinErrors.TickerAlreadyUsed.selector, symbol));
625
+ factory.deployTrendCoin(symbol, makeAddr("someHook"), "");
626
+ }
627
+
628
+ function test_deployTrendCoin_addressPredictionWithHooks() public {
629
+ string memory symbol = "PREDICT2";
630
+
631
+ // Get predicted address (should work regardless of hook params)
632
+ address predictedAddress = factory.trendCoinAddress(symbol);
633
+
634
+ // Deploy with non-null hook address (but still null since no valid hook implementation)
635
+ (address actualAddress, ) = factory.deployTrendCoin(symbol, address(0), "");
636
+
637
+ // Prediction should still match (address based on ticker only)
638
+ assertEq(actualAddress, predictedAddress, "Predicted address should match");
639
+ }
640
+
641
+ function test_deployTrendCoin_tickerUniquenessWithHooks() public {
642
+ // Deploy with null hook
643
+ factory.deployTrendCoin("test", address(0), "");
644
+
645
+ // Try to deploy with same ticker, different case - should revert
646
+ vm.expectRevert(abi.encodeWithSelector(ITrendCoinErrors.TickerAlreadyUsed.selector, "TEST"));
647
+ factory.deployTrendCoin("TEST", address(0), "");
648
+
649
+ // Try with mixed case - should also revert
650
+ vm.expectRevert(abi.encodeWithSelector(ITrendCoinErrors.TickerAlreadyUsed.selector, "TeSt"));
651
+ factory.deployTrendCoin("TeSt", address(0), "");
652
+ }
653
+
654
+ // ============ URI Tests ============
655
+
656
+ function test_deployTrendCoin_uri_usesSymbolDirectly() public {
657
+ string memory symbol = "TESTCOIN";
658
+ (address coinAddress, ) = factory.deployTrendCoin(symbol, address(0), "");
659
+
660
+ trendCoin = TrendCoin(coinAddress);
661
+ string memory uri = trendCoin.tokenURI();
662
+
663
+ assertEq(uri, "https://trends.theme.wtf/trend/TESTCOIN", "URI should use symbol directly");
664
+ }
665
+
666
+ function test_deployTrendCoin_uri_preservesCase() public {
667
+ string memory symbol = "TestCoin";
668
+ (address coinAddress, ) = factory.deployTrendCoin(symbol, address(0), "");
669
+
670
+ trendCoin = TrendCoin(coinAddress);
671
+ string memory uri = trendCoin.tokenURI();
672
+
673
+ assertEq(uri, "https://trends.theme.wtf/trend/TestCoin", "URI should preserve original case");
674
+ }
675
+
676
+ function test_deployTrendCoin_uri_symbolAndNameMatch() public {
677
+ string memory symbol = "TESTCOIN";
678
+ (address coinAddress, ) = factory.deployTrendCoin(symbol, address(0), "");
679
+
680
+ trendCoin = TrendCoin(coinAddress);
681
+
682
+ assertEq(trendCoin.symbol(), symbol, "Symbol should match");
683
+ assertEq(trendCoin.name(), symbol, "Name should equal symbol for trend coins");
684
+ assertEq(trendCoin.tokenURI(), string.concat("https://trends.theme.wtf/trend/", symbol), "URI should be base URI + symbol");
685
+ }
686
+
687
+ // ============ Metadata Manager Tests ============
688
+
689
+ function test_setContractURI_byMetadataManager() public {
690
+ _deployTrendCoin();
691
+
692
+ string memory newURI = "https://example.com/updated-metadata";
693
+
694
+ // Metadata manager should be able to update the contract URI
695
+ vm.prank(users.metadataManager);
696
+ trendCoin.setContractURI(newURI);
697
+
698
+ assertEq(trendCoin.contractURI(), newURI, "Contract URI should be updated");
699
+ }
700
+
701
+ function test_setContractURI_multipleUpdates() public {
702
+ _deployTrendCoin();
703
+
704
+ string memory uri1 = "https://example.com/v1";
705
+ string memory uri2 = "https://example.com/v2";
706
+ string memory uri3 = "https://example.com/v3";
707
+
708
+ vm.startPrank(users.metadataManager);
709
+
710
+ trendCoin.setContractURI(uri1);
711
+ assertEq(trendCoin.contractURI(), uri1, "Contract URI should be v1");
712
+
713
+ trendCoin.setContractURI(uri2);
714
+ assertEq(trendCoin.contractURI(), uri2, "Contract URI should be v2");
715
+
716
+ trendCoin.setContractURI(uri3);
717
+ assertEq(trendCoin.contractURI(), uri3, "Contract URI should be v3");
718
+
719
+ vm.stopPrank();
720
+ }
721
+
722
+ function test_revertWhen_setContractURI_byNonMetadataManager() public {
723
+ _deployTrendCoin();
724
+
725
+ string memory newURI = "https://example.com/malicious";
726
+
727
+ // Random address should not be able to update the contract URI
728
+ address randomUser = makeAddr("randomUser");
729
+ vm.prank(randomUser);
730
+ vm.expectRevert(ITrendCoin.OnlyMetadataManager.selector);
731
+ trendCoin.setContractURI(newURI);
732
+ }
733
+
734
+ function test_revertWhen_setContractURI_byOwner() public {
735
+ _deployTrendCoin();
736
+
737
+ string memory newURI = "https://example.com/owner-attempt";
738
+
739
+ // Even the coin owner should not be able to use setContractURI (must use setContractURI)
740
+ vm.prank(users.creator);
741
+ vm.expectRevert(ITrendCoin.OnlyMetadataManager.selector);
742
+ trendCoin.setContractURI(newURI);
743
+ }
744
+
745
+ function test_revertWhen_setContractURI_byFactoryOwner() public {
746
+ _deployTrendCoin();
747
+
748
+ string memory newURI = "https://example.com/factory-owner-attempt";
749
+
750
+ // Factory owner should not be able to update the contract URI
751
+ vm.prank(users.factoryOwner);
752
+ vm.expectRevert(ITrendCoin.OnlyMetadataManager.selector);
753
+ trendCoin.setContractURI(newURI);
754
+ }
755
+
756
+ function test_setContractURI_emptyString() public {
757
+ _deployTrendCoin();
758
+
759
+ // Metadata manager should be able to set empty URI
760
+ vm.prank(users.metadataManager);
761
+ trendCoin.setContractURI("");
762
+
763
+ assertEq(trendCoin.contractURI(), "", "Contract URI should be empty");
764
+ }
765
+
766
+ // ============ setNameAndSymbol Metadata Manager Tests ============
767
+
768
+ function test_setNameAndSymbol_byMetadataManager() public {
769
+ _deployTrendCoin();
770
+
771
+ string memory newName = "UpdatedTrend";
772
+ string memory newSymbol = "UPDTREND";
773
+
774
+ vm.prank(users.metadataManager);
775
+ trendCoin.setNameAndSymbol(newName, newSymbol);
776
+
777
+ assertEq(trendCoin.name(), newName, "Name should be updated");
778
+ assertEq(trendCoin.symbol(), newSymbol, "Symbol should be updated");
779
+ }
780
+
781
+ function test_setNameAndSymbol_multipleUpdates() public {
782
+ _deployTrendCoin();
783
+
784
+ vm.startPrank(users.metadataManager);
785
+
786
+ trendCoin.setNameAndSymbol("Name1", "SYM1");
787
+ assertEq(trendCoin.name(), "Name1", "Name should be Name1");
788
+ assertEq(trendCoin.symbol(), "SYM1", "Symbol should be SYM1");
789
+
790
+ trendCoin.setNameAndSymbol("Name2", "SYM2");
791
+ assertEq(trendCoin.name(), "Name2", "Name should be Name2");
792
+ assertEq(trendCoin.symbol(), "SYM2", "Symbol should be SYM2");
793
+
794
+ vm.stopPrank();
795
+ }
796
+
797
+ function test_setNameAndSymbol_emitsEvent() public {
798
+ _deployTrendCoin();
799
+
800
+ string memory newName = "EventTrend";
801
+ string memory newSymbol = "EVTTREND";
802
+
803
+ vm.prank(users.metadataManager);
804
+ vm.expectEmit(true, true, true, true);
805
+ emit ICoin.NameAndSymbolUpdated(users.metadataManager, newName, newSymbol);
806
+ trendCoin.setNameAndSymbol(newName, newSymbol);
807
+ }
808
+
809
+ function test_revertWhen_setNameAndSymbol_byNonMetadataManager() public {
810
+ _deployTrendCoin();
811
+
812
+ address randomUser = makeAddr("randomUser");
813
+ vm.prank(randomUser);
814
+ vm.expectRevert(ITrendCoin.OnlyMetadataManager.selector);
815
+ trendCoin.setNameAndSymbol("Malicious", "MAL");
816
+ }
817
+
818
+ function test_revertWhen_setNameAndSymbol_byOwner() public {
819
+ _deployTrendCoin();
820
+
821
+ vm.prank(users.creator);
822
+ vm.expectRevert(ITrendCoin.OnlyMetadataManager.selector);
823
+ trendCoin.setNameAndSymbol("OwnerAttempt", "OWN");
824
+ }
825
+
826
+ function test_revertWhen_setNameAndSymbol_byFactoryOwner() public {
827
+ _deployTrendCoin();
828
+
829
+ vm.prank(users.factoryOwner);
830
+ vm.expectRevert(ITrendCoin.OnlyMetadataManager.selector);
831
+ trendCoin.setNameAndSymbol("FactoryAttempt", "FAC");
832
+ }
833
+
834
+ function test_revertWhen_setNameAndSymbol_emptyName() public {
835
+ _deployTrendCoin();
836
+
837
+ vm.prank(users.metadataManager);
838
+ vm.expectRevert(abi.encodeWithSelector(ICoin.NameIsRequired.selector));
839
+ trendCoin.setNameAndSymbol("", "SYM");
840
+ }
841
+
842
+ // ============ Pool Config Admin Tests ============
843
+
844
+ function _getDefaultPoolConfigParams()
845
+ internal
846
+ pure
847
+ returns (
848
+ address currency,
849
+ int24[] memory tickLower,
850
+ int24[] memory tickUpper,
851
+ uint16[] memory numDiscoveryPositions,
852
+ uint256[] memory maxDiscoverySupplyShare
853
+ )
854
+ {
855
+ (, currency, tickLower, tickUpper, numDiscoveryPositions, maxDiscoverySupplyShare) = CoinConfigurationVersions.decodeDopplerMultiCurveUniV4(
856
+ CoinConstants.TREND_COIN_DEFAULT_POOL_CONFIG
857
+ );
858
+ }
859
+
860
+ function test_setTrendCoinPoolConfig_ownerCanSet() public {
861
+ bytes memory expectedPoolConfig = CoinConstants.TREND_COIN_DEFAULT_POOL_CONFIG;
862
+
863
+ // Get the factory owner
864
+ address factoryOwner = ZoraFactoryImpl(address(factory)).owner();
865
+
866
+ (
867
+ address currency,
868
+ int24[] memory tickLower,
869
+ int24[] memory tickUpper,
870
+ uint16[] memory numDiscoveryPositions,
871
+ uint256[] memory maxDiscoverySupplyShare
872
+ ) = _getDefaultPoolConfigParams();
873
+
874
+ vm.prank(factoryOwner);
875
+ factory.setTrendCoinPoolConfig(currency, tickLower, tickUpper, numDiscoveryPositions, maxDiscoverySupplyShare);
876
+
877
+ // Verify it was set
878
+ bytes memory storedConfig = factory.trendCoinPoolConfig();
879
+ assertEq(keccak256(storedConfig), keccak256(expectedPoolConfig), "Pool config should be stored");
880
+ }
881
+
882
+ function test_setTrendCoinPoolConfig_emitsEvent() public {
883
+ bytes memory expectedPoolConfig = CoinConstants.TREND_COIN_DEFAULT_POOL_CONFIG;
884
+ address factoryOwner = ZoraFactoryImpl(address(factory)).owner();
885
+
886
+ (
887
+ address currency,
888
+ int24[] memory tickLower,
889
+ int24[] memory tickUpper,
890
+ uint16[] memory numDiscoveryPositions,
891
+ uint256[] memory maxDiscoverySupplyShare
892
+ ) = _getDefaultPoolConfigParams();
893
+
894
+ vm.expectEmit(false, false, false, true);
895
+ emit IZoraFactory.TrendCoinPoolConfigUpdated(expectedPoolConfig);
896
+
897
+ vm.prank(factoryOwner);
898
+ factory.setTrendCoinPoolConfig(currency, tickLower, tickUpper, numDiscoveryPositions, maxDiscoverySupplyShare);
899
+ }
900
+
901
+ function test_revertWhen_setTrendCoinPoolConfig_nonOwner() public {
902
+ address nonOwner = makeAddr("nonOwner");
903
+
904
+ (
905
+ address currency,
906
+ int24[] memory tickLower,
907
+ int24[] memory tickUpper,
908
+ uint16[] memory numDiscoveryPositions,
909
+ uint256[] memory maxDiscoverySupplyShare
910
+ ) = _getDefaultPoolConfigParams();
911
+
912
+ vm.prank(nonOwner);
913
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, nonOwner));
914
+ factory.setTrendCoinPoolConfig(currency, tickLower, tickUpper, numDiscoveryPositions, maxDiscoverySupplyShare);
915
+ }
916
+
917
+ function test_revertWhen_setTrendCoinPoolConfig_emptyArrays() public {
918
+ address factoryOwner = ZoraFactoryImpl(address(factory)).owner();
919
+ address currency = CoinConstants.CREATOR_COIN_CURRENCY;
920
+
921
+ int24[] memory tickLower = new int24[](0);
922
+ int24[] memory tickUpper = new int24[](0);
923
+ uint16[] memory numDiscoveryPositions = new uint16[](0);
924
+ uint256[] memory maxDiscoverySupplyShare = new uint256[](0);
925
+
926
+ vm.prank(factoryOwner);
927
+ vm.expectRevert(IZoraFactory.InvalidConfig.selector);
928
+ factory.setTrendCoinPoolConfig(currency, tickLower, tickUpper, numDiscoveryPositions, maxDiscoverySupplyShare);
929
+ }
930
+
931
+ function test_revertWhen_setTrendCoinPoolConfig_mismatchedArrayLengths() public {
932
+ address factoryOwner = ZoraFactoryImpl(address(factory)).owner();
933
+ address currency = CoinConstants.CREATOR_COIN_CURRENCY;
934
+
935
+ int24[] memory tickLower = new int24[](2);
936
+ int24[] memory tickUpper = new int24[](3); // Mismatched length
937
+ uint16[] memory numDiscoveryPositions = new uint16[](2);
938
+ uint256[] memory maxDiscoverySupplyShare = new uint256[](2);
939
+
940
+ vm.prank(factoryOwner);
941
+ vm.expectRevert(IZoraFactory.InvalidConfig.selector);
942
+ factory.setTrendCoinPoolConfig(currency, tickLower, tickUpper, numDiscoveryPositions, maxDiscoverySupplyShare);
943
+ }
944
+
945
+ function test_revertWhen_deployTrendCoin_configNotSet() public {
946
+ // Deploy a fresh factory without pool config set
947
+ // We need to test this with a fresh factory that hasn't had the config set
948
+ // Since our test setup sets the config, we need to use a different approach
949
+
950
+ // Deploy a new factory proxy
951
+ address proxyShim = address(new ProxyShim());
952
+ ZoraFactory newFactoryProxy = new ZoraFactory(proxyShim);
953
+
954
+ // Create a new hook registry that includes the new factory as an owner
955
+ ZoraHookRegistry newHookRegistry = new ZoraHookRegistry();
956
+ address[] memory initialOwners = new address[](2);
957
+ initialOwners[0] = address(this);
958
+ initialOwners[1] = address(newFactoryProxy);
959
+ newHookRegistry.initialize(initialOwners);
960
+
961
+ // Create a new factory impl with the new hook registry
962
+ ZoraFactoryImpl newFactoryImpl = new ZoraFactoryImpl(
963
+ address(coinV4Impl),
964
+ address(creatorCoinImpl),
965
+ address(trendCoinImpl),
966
+ address(hook),
967
+ address(newHookRegistry)
968
+ );
969
+
970
+ // Upgrade to real impl and initialize
971
+ UUPSUpgradeable(address(newFactoryProxy)).upgradeToAndCall(
972
+ address(newFactoryImpl),
973
+ abi.encodeWithSelector(ZoraFactoryImpl.initialize.selector, address(this))
974
+ );
975
+
976
+ IZoraFactory newFactory = IZoraFactory(address(newFactoryProxy));
977
+
978
+ // Try to deploy trend coin without setting config first
979
+ vm.expectRevert(IZoraFactory.TrendCoinPoolConfigNotSet.selector);
980
+ newFactory.deployTrendCoin("NOCONFIG", address(0), "");
981
+ }
982
+
983
+ function test_deployTrendCoin_usesStoredConfig() public {
984
+ // The factory should already have config set from setUp
985
+ // Deploy a trend coin and verify it works
986
+ string memory symbol = "CONFIGTEST";
987
+
988
+ (address coinAddress, ) = factory.deployTrendCoin(symbol, address(0), "");
989
+
990
+ // Verify the coin was created successfully
991
+ trendCoin = TrendCoin(coinAddress);
992
+ assertEq(trendCoin.symbol(), symbol, "Symbol should match");
993
+ assertEq(uint8(trendCoin.coinType()), uint8(IHasCoinType.CoinType.Trend), "Should be Trend coin type");
994
+ }
995
+
996
+ function test_trendCoinPoolConfig_returnsStoredConfig() public {
997
+ bytes memory expectedConfig = CoinConstants.TREND_COIN_DEFAULT_POOL_CONFIG;
998
+
999
+ // Get stored config (should be set from setUp)
1000
+ bytes memory storedConfig = factory.trendCoinPoolConfig();
1001
+
1002
+ assertEq(keccak256(storedConfig), keccak256(expectedConfig), "Should return the stored config");
1003
+ }
1004
+ // ============ Reinitialization Protection Tests ============
1005
+
1006
+ function test_revertWhen_reinitializeTrendCoin() public {
1007
+ _deployTrendCoin();
1008
+
1009
+ // Get pool key and configuration from the deployed coin
1010
+ ICoin coin = ICoin(address(trendCoin));
1011
+ PoolKey memory poolKey_ = coin.getPoolKey();
1012
+ PoolConfiguration memory poolConfig_ = coin.getPoolConfiguration();
1013
+
1014
+ // Try to reinitialize via initializeTrendCoin - should fail
1015
+ address[] memory owners = new address[](1);
1016
+ owners[0] = users.creator;
1017
+
1018
+ vm.expectRevert(Initializable.InvalidInitialization.selector);
1019
+ trendCoin.initializeTrendCoin(owners, "REINIT", poolKey_, uint160(1 << 96), poolConfig_);
1020
+ }
1021
+
1022
+ function test_revertWhen_legacyInitialize() public {
1023
+ _deployTrendCoin();
1024
+
1025
+ // Get pool key and configuration from the deployed coin
1026
+ ICoin coin = ICoin(address(trendCoin));
1027
+ PoolKey memory poolKey_ = coin.getPoolKey();
1028
+ PoolConfiguration memory poolConfig_ = coin.getPoolConfiguration();
1029
+
1030
+ // Try to call legacy initialize - should always revert with UseSpecificTrendCoinInitialize
1031
+ address[] memory owners = new address[](1);
1032
+ owners[0] = users.creator;
1033
+
1034
+ vm.expectRevert(ITrendCoinErrors.UseSpecificTrendCoinInitialize.selector);
1035
+ trendCoin.initialize(
1036
+ address(0),
1037
+ owners,
1038
+ "https://example.com/reinit",
1039
+ "REINIT",
1040
+ "REINIT",
1041
+ address(0),
1042
+ CoinConstants.CREATOR_COIN_CURRENCY,
1043
+ poolKey_,
1044
+ uint160(1 << 96),
1045
+ poolConfig_
1046
+ );
1047
+ }
1048
+ }
1049
+
1050
+ /// @notice Tests that exercise the real post-launch 0.01% fee path for trend coins.
1051
+ /// By deploying the coin in setUp(), the deployment transaction completes and transient
1052
+ /// storage (isDeploying) is cleared before any test function runs. This lets
1053
+ /// _calculateLaunchFee reach the TREND_LP_FEE_V4 branch instead of the isDeploying bypass.
1054
+ contract TrendCoinFeeTest is BaseTest {
1055
+ TrendCoin internal trendCoin;
1056
+
1057
+ function setUp() public override {
1058
+ super.setUpNonForked();
1059
+
1060
+ // Deploy in setUp so transient storage is cleared before tests run
1061
+ (address coinAddress, ) = factory.deployTrendCoin("FEETEST", address(0), "");
1062
+ trendCoin = TrendCoin(coinAddress);
1063
+ vm.label(address(trendCoin), "TEST_TREND_COIN");
1064
+ }
1065
+
1066
+ /// @notice Verify that trend coins charge 0.01% after the launch fee window
1067
+ function test_trendCoin_postLaunchFeeIs1Bps() public {
1068
+ // Warp past the launch fee period
1069
+ vm.warp(block.timestamp + CoinConstants.LAUNCH_FEE_DURATION + 1);
1070
+
1071
+ uint128 tradeAmount = 1_000_000 ether;
1072
+
1073
+ address protocolRecipient = trendCoin.protocolRewardRecipient();
1074
+ uint256 protocolBefore = zoraToken.balanceOf(protocolRecipient);
1075
+
1076
+ deal(address(zoraToken), users.buyer, tradeAmount);
1077
+ vm.startPrank(users.buyer);
1078
+ UniV4SwapHelper.approveTokenWithPermit2(permit2, address(router), address(zoraToken), tradeAmount, uint48(block.timestamp + 1 days));
1079
+ (bytes memory commands, bytes[] memory inputs) = UniV4SwapHelper.buildExactInputSingleSwapCommand(
1080
+ address(zoraToken),
1081
+ tradeAmount,
1082
+ address(trendCoin),
1083
+ 0,
1084
+ trendCoin.getPoolKey(),
1085
+ bytes("")
1086
+ );
1087
+ router.execute(commands, inputs, block.timestamp + 1 days);
1088
+ vm.stopPrank();
1089
+
1090
+ uint256 protocolAfter = zoraToken.balanceOf(protocolRecipient);
1091
+ uint256 protocolReward = protocolAfter - protocolBefore;
1092
+
1093
+ // 0.01% of 1M = 100 ZORA. Allow 10% tolerance for swap path conversion.
1094
+ uint256 expectedFee = (uint256(tradeAmount) * CoinConstants.TREND_LP_FEE_V4) / 1_000_000;
1095
+ assertApproxEqRel(protocolReward, expectedFee, 0.10e18, "Protocol reward should be ~0.01% of trade");
1096
+
1097
+ // Sanity: must be far below what 1% would yield (10,000 ZORA)
1098
+ uint256 onePercentFee = (uint256(tradeAmount) * CoinConstants.LP_FEE_V4) / 1_000_000;
1099
+ assertLt(protocolReward, onePercentFee / 5, "Fee must be well below the 1% deployment-bypass level");
1100
+ }
1101
+
1102
+ /// @notice Smoke test that a post-launch trend coin swap still succeeds on the 1 bp fee path
1103
+ function test_trendCoin_postLaunchSwapSucceeds() public {
1104
+ vm.warp(block.timestamp + CoinConstants.LAUNCH_FEE_DURATION + 1);
1105
+
1106
+ uint128 amountIn = 100 ether;
1107
+ address trader = makeAddr("trader");
1108
+
1109
+ // Execute a trend coin swap
1110
+ deal(address(zoraToken), trader, amountIn);
1111
+ vm.startPrank(trader);
1112
+ UniV4SwapHelper.approveTokenWithPermit2(permit2, address(router), address(zoraToken), amountIn, uint48(block.timestamp + 1 days));
1113
+ (bytes memory commands, bytes[] memory inputs) = UniV4SwapHelper.buildExactInputSingleSwapCommand(
1114
+ address(zoraToken),
1115
+ amountIn,
1116
+ address(trendCoin),
1117
+ 0,
1118
+ trendCoin.getPoolKey(),
1119
+ bytes("")
1120
+ );
1121
+ router.execute(commands, inputs, block.timestamp + 1 days);
1122
+ vm.stopPrank();
1123
+
1124
+ uint256 trendCoinsReceived = trendCoin.balanceOf(trader);
1125
+
1126
+ assertGt(trendCoinsReceived, 0, "Post-launch trend swap should return coins");
1127
+ }
1128
+ }