@zoralabs/coins 2.4.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,286 @@
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 {CoinConstants} from "../src/libs/CoinConstants.sol";
8
+ import {IHasCreationInfo} from "../src/interfaces/IHasCreationInfo.sol";
9
+ import {UniV4SwapHelper} from "../src/libs/UniV4SwapHelper.sol";
10
+ import {ContentCoin} from "../src/ContentCoin.sol";
11
+ import {ICoin} from "../src/interfaces/ICoin.sol";
12
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
13
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
14
+ import {MockERC20} from "./mocks/MockERC20.sol";
15
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
16
+ import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
17
+ import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol";
18
+
19
+ /// @notice Tests for the launch fee feature (time-based dynamic fee)
20
+ /// @dev IMPORTANT: This test uses forge-config pragma to run in isolation mode, which properly
21
+ /// simulates transaction boundaries for transient storage testing.
22
+ contract LaunchFeeTest is BaseTest {
23
+ MockERC20 internal mockCurrency;
24
+ ContentCoin internal coin;
25
+
26
+ function setUp() public override {
27
+ super.setUpNonForked();
28
+
29
+ mockCurrency = new MockERC20("MockCurrency", "MCK");
30
+
31
+ // Fund the pool manager with backing currency
32
+ mockCurrency.mint(address(poolManager), 1_000_000_000 ether);
33
+ }
34
+
35
+ // ============================================
36
+ // Interface Support Tests
37
+ // ============================================
38
+
39
+ function test_coinSupportsIHasCreationInfo() public {
40
+ _deployCoin();
41
+
42
+ bool supported = IERC165(address(coin)).supportsInterface(type(IHasCreationInfo).interfaceId);
43
+ assertTrue(supported, "coin should support IHasCreationInfo");
44
+ }
45
+
46
+ /// forge-config: default.isolate = true
47
+ function test_creationInfo_returnsCorrectValues() public {
48
+ uint256 deployTime = block.timestamp;
49
+ _deployCoin();
50
+
51
+ (uint256 creationTimestamp, bool isDeploying) = IHasCreationInfo(address(coin)).creationInfo();
52
+
53
+ assertEq(creationTimestamp, deployTime, "creation timestamp should match deploy time");
54
+ assertFalse(isDeploying, "isDeploying should be false after deployment transaction");
55
+ }
56
+
57
+ // ============================================
58
+ // Pool Key Tests
59
+ // ============================================
60
+
61
+ function test_poolKey_usesDynamicFeeFlag() public {
62
+ _deployCoin();
63
+
64
+ PoolKey memory poolKey = coin.getPoolKey();
65
+
66
+ assertEq(poolKey.fee, CoinConstants.DYNAMIC_FEE_FLAG, "pool fee should use DYNAMIC_FEE_FLAG");
67
+ }
68
+
69
+ // ============================================
70
+ // Launch Fee Calculation Tests
71
+ // ============================================
72
+
73
+ /// forge-config: default.isolate = true
74
+ function test_launchFee_immediatelyAfterCreation() public {
75
+ _deployCoin();
76
+
77
+ uint128 amountIn = 1 ether;
78
+ address trader = makeAddr("trader");
79
+
80
+ // Snapshot to compare swaps from same starting state
81
+ uint256 snapshot = vm.snapshotState();
82
+
83
+ // Swap immediately (same block, but different transaction)
84
+ // The launch fee should be at maximum (99%)
85
+ mockCurrency.mint(trader, amountIn);
86
+ uint256 coinBalanceBefore = coin.balanceOf(trader);
87
+ _swapCurrencyForCoin(amountIn, trader);
88
+ uint256 coinsAtMaxFee = coin.balanceOf(trader) - coinBalanceBefore;
89
+
90
+ console.log("Coins received at t=0 (99% fee):", coinsAtMaxFee);
91
+
92
+ // Revert to same starting state
93
+ vm.revertToState(snapshot);
94
+
95
+ // Warp past launch fee duration and do another swap from same starting state
96
+ vm.warp(block.timestamp + CoinConstants.LAUNCH_FEE_DURATION + 1);
97
+
98
+ mockCurrency.mint(trader, amountIn);
99
+ coinBalanceBefore = coin.balanceOf(trader);
100
+ _swapCurrencyForCoin(amountIn, trader);
101
+ uint256 coinsAtMinFee = coin.balanceOf(trader) - coinBalanceBefore;
102
+
103
+ console.log("Coins received at t>10s (1% fee):", coinsAtMinFee);
104
+
105
+ // Coins received with 1% fee should be significantly more than with 99% fee
106
+ assertGt(coinsAtMinFee, coinsAtMaxFee, "should receive more coins after launch fee ends");
107
+ }
108
+
109
+ /// forge-config: default.isolate = true
110
+ function test_launchFee_decaysOverTime() public {
111
+ _deployCoin();
112
+
113
+ uint128 amountIn = 0.1 ether;
114
+ address trader = makeAddr("trader");
115
+
116
+ // Test at different time points
117
+ uint256[] memory timePoints = new uint256[](5);
118
+ timePoints[0] = 0; // 99% fee
119
+ timePoints[1] = 2; // ~79.2% fee
120
+ timePoints[2] = 5; // ~50% fee
121
+ timePoints[3] = 8; // ~20.8% fee
122
+ timePoints[4] = 10; // 1% fee
123
+
124
+ uint256[] memory coinsReceived = new uint256[](5);
125
+
126
+ for (uint256 i = 0; i < timePoints.length; i++) {
127
+ // Reset state for each test
128
+ uint256 snapshot = vm.snapshotState();
129
+
130
+ if (timePoints[i] > 0) {
131
+ vm.warp(block.timestamp + timePoints[i]);
132
+ }
133
+
134
+ mockCurrency.mint(trader, amountIn);
135
+ uint256 coinBalanceBefore = coin.balanceOf(trader);
136
+
137
+ _swapCurrencyForCoin(amountIn, trader);
138
+
139
+ coinsReceived[i] = coin.balanceOf(trader) - coinBalanceBefore;
140
+
141
+ console.log("Time:", timePoints[i], "s - Coins received:", coinsReceived[i]);
142
+
143
+ vm.revertToState(snapshot);
144
+ }
145
+
146
+ // Verify monotonic increase (more coins as fee decreases)
147
+ for (uint256 i = 1; i < coinsReceived.length; i++) {
148
+ assertGt(coinsReceived[i], coinsReceived[i - 1], "coins received should increase as launch fee decays");
149
+ }
150
+ }
151
+
152
+ /// forge-config: default.isolate = true
153
+ function test_launchFee_exactlyAtDuration() public {
154
+ _deployCoin();
155
+
156
+ uint128 amountIn = 0.1 ether;
157
+ address trader = makeAddr("trader");
158
+
159
+ // Test at exactly the launch fee duration
160
+ uint256 snapshot = vm.snapshotState();
161
+ vm.warp(block.timestamp + CoinConstants.LAUNCH_FEE_DURATION);
162
+
163
+ mockCurrency.mint(trader, amountIn);
164
+ uint256 coinBalanceBefore = coin.balanceOf(trader);
165
+ _swapCurrencyForCoin(amountIn, trader);
166
+ uint256 coinsAtExactDuration = coin.balanceOf(trader) - coinBalanceBefore;
167
+
168
+ vm.revertToState(snapshot);
169
+
170
+ // Test after the launch fee duration (same starting state)
171
+ vm.warp(block.timestamp + CoinConstants.LAUNCH_FEE_DURATION + 100);
172
+
173
+ mockCurrency.mint(trader, amountIn);
174
+ coinBalanceBefore = coin.balanceOf(trader);
175
+ _swapCurrencyForCoin(amountIn, trader);
176
+ uint256 coinsAfterDuration = coin.balanceOf(trader) - coinBalanceBefore;
177
+
178
+ // Should be approximately equal (both at 1% fee, same pool state)
179
+ assertApproxEqRel(coinsAtExactDuration, coinsAfterDuration, 0.01e18, "fee should be same at and after duration");
180
+ }
181
+
182
+ function test_launchFee_afterDurationEnds() public {
183
+ _deployCoin();
184
+
185
+ // Warp well past the launch fee duration
186
+ vm.warp(block.timestamp + CoinConstants.LAUNCH_FEE_DURATION + 1 days);
187
+
188
+ uint128 amountIn = 0.1 ether;
189
+ address trader = makeAddr("trader");
190
+ mockCurrency.mint(trader, amountIn);
191
+
192
+ // Should use normal 1% LP fee
193
+ _swapCurrencyForCoin(amountIn, trader);
194
+
195
+ // Just verify the swap succeeded - fee calculation is 1%
196
+ assertGt(coin.balanceOf(trader), 0, "trader should have received coins");
197
+ }
198
+
199
+ // ============================================
200
+ // Initial Supply Bypass Tests
201
+ // ============================================
202
+
203
+ function test_initialSupply_bypassesLaunchFee() public {
204
+ // The initial supply purchase during deployment should bypass launch fee
205
+ // This is verified by checking the creator receives coins during deployment
206
+
207
+ uint256 creatorBalanceBefore = 0; // Creator has no coins before deployment
208
+
209
+ _deployCoin();
210
+
211
+ uint256 creatorBalanceAfter = coin.balanceOf(users.creator);
212
+
213
+ // Creator should receive initial supply (10 million for content coins)
214
+ assertEq(creatorBalanceAfter, CoinConstants.CONTENT_COIN_INITIAL_CREATOR_SUPPLY, "creator should receive full initial supply without launch fee");
215
+ }
216
+
217
+ // ============================================
218
+ // Fee Calculation Math Tests
219
+ // ============================================
220
+
221
+ function test_feeCalculation_linearDecay() public pure {
222
+ // Test the fee calculation formula
223
+ // fee = startFee - (elapsed / duration) * (startFee - endFee)
224
+
225
+ uint256 startFee = CoinConstants.LAUNCH_FEE_START; // 990,000 (99%)
226
+ uint256 endFee = CoinConstants.LP_FEE_V4; // 10,000 (1%)
227
+ uint256 duration = CoinConstants.LAUNCH_FEE_DURATION; // 10 seconds
228
+
229
+ // At t=0: fee should be 990,000
230
+ uint256 feeAt0 = startFee - (0 * (startFee - endFee)) / duration;
231
+ assertEq(feeAt0, 990_000, "fee at t=0");
232
+
233
+ // At t=5: fee should be 500,000 (50%)
234
+ uint256 feeAt5 = startFee - (5 * (startFee - endFee)) / duration;
235
+ assertEq(feeAt5, 500_000, "fee at t=5");
236
+
237
+ // At t=10: fee should be 10,000 (1%)
238
+ uint256 feeAt10 = startFee - (10 * (startFee - endFee)) / duration;
239
+ assertEq(feeAt10, 10_000, "fee at t=10");
240
+ }
241
+
242
+ // ============================================
243
+ // Helper Functions
244
+ // ============================================
245
+
246
+ function _deployCoin() internal {
247
+ bytes32 salt = keccak256(abi.encodePacked("launchFeeTest", block.timestamp));
248
+ bytes memory poolConfig = _defaultPoolConfig(address(mockCurrency));
249
+
250
+ vm.prank(users.creator);
251
+ (address coinAddress, ) = factory.deploy(
252
+ users.creator,
253
+ _getDefaultOwners(),
254
+ "https://test.com",
255
+ "LaunchFeeCoin",
256
+ "LAUNCH",
257
+ poolConfig,
258
+ address(0), // no platform referrer
259
+ address(0), // no post deploy hook
260
+ bytes(""),
261
+ salt
262
+ );
263
+
264
+ coin = ContentCoin(payable(coinAddress));
265
+ vm.label(address(coin), "LAUNCH_FEE_COIN");
266
+ }
267
+
268
+ function _swapCurrencyForCoin(uint128 amountIn, address trader) internal {
269
+ uint128 minAmountOut = 0;
270
+
271
+ (bytes memory commands, bytes[] memory inputs) = UniV4SwapHelper.buildExactInputSingleSwapCommand(
272
+ address(mockCurrency),
273
+ amountIn,
274
+ address(coin),
275
+ minAmountOut,
276
+ coin.getPoolKey(),
277
+ bytes("")
278
+ );
279
+
280
+ vm.startPrank(trader);
281
+ UniV4SwapHelper.approveTokenWithPermit2(permit2, address(router), address(mockCurrency), amountIn, uint48(block.timestamp + 1 days));
282
+
283
+ router.execute(commands, inputs, block.timestamp + 1 days);
284
+ vm.stopPrank();
285
+ }
286
+ }