@ballkidz/defifa 0.0.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 (59) hide show
  1. package/.gas-snapshot +2 -0
  2. package/CRYPTO_ECON.md +955 -0
  3. package/CRYPTO_ECON.pdf +0 -0
  4. package/CRYPTO_ECON.tex +800 -0
  5. package/README.md +119 -0
  6. package/SKILLS.md +177 -0
  7. package/deployments/defifa-v5/arbitrum_sepolia/DefifaDelegate.json +4867 -0
  8. package/deployments/defifa-v5/arbitrum_sepolia/DefifaDeployer.json +1719 -0
  9. package/deployments/defifa-v5/arbitrum_sepolia/DefifaGovernor.json +1535 -0
  10. package/deployments/defifa-v5/arbitrum_sepolia/DefifaTokenUriResolver.json +295 -0
  11. package/deployments/defifa-v5/base_sepolia/DefifaDelegate.json +4875 -0
  12. package/deployments/defifa-v5/base_sepolia/DefifaDeployer.json +1725 -0
  13. package/deployments/defifa-v5/base_sepolia/DefifaGovernor.json +1543 -0
  14. package/deployments/defifa-v5/base_sepolia/DefifaTokenUriResolver.json +301 -0
  15. package/deployments/defifa-v5/optimism_sepolia/DefifaDelegate.json +4875 -0
  16. package/deployments/defifa-v5/optimism_sepolia/DefifaDeployer.json +1725 -0
  17. package/deployments/defifa-v5/optimism_sepolia/DefifaGovernor.json +1543 -0
  18. package/deployments/defifa-v5/optimism_sepolia/DefifaTokenUriResolver.json +301 -0
  19. package/deployments/defifa-v5/sepolia/DefifaDelegate.json +4875 -0
  20. package/deployments/defifa-v5/sepolia/DefifaDeployer.json +1725 -0
  21. package/deployments/defifa-v5/sepolia/DefifaGovernor.json +1543 -0
  22. package/deployments/defifa-v5/sepolia/DefifaTokenUriResolver.json +301 -0
  23. package/foundry.lock +17 -0
  24. package/foundry.toml +35 -0
  25. package/package.json +33 -0
  26. package/remappings.txt +6 -0
  27. package/script/Deploy.s.sol +109 -0
  28. package/script/helpers/DefifaDeploymentLib.sol +83 -0
  29. package/slither-ci.config.json +10 -0
  30. package/sphinx.lock +521 -0
  31. package/src/DefifaDeployer.sol +894 -0
  32. package/src/DefifaGovernor.sol +490 -0
  33. package/src/DefifaHook.sol +1056 -0
  34. package/src/DefifaProjectOwner.sol +63 -0
  35. package/src/DefifaTokenUriResolver.sol +312 -0
  36. package/src/enums/DefifaGamePhase.sol +11 -0
  37. package/src/enums/DefifaScorecardState.sol +10 -0
  38. package/src/interfaces/IDefifaDeployer.sol +108 -0
  39. package/src/interfaces/IDefifaGamePhaseReporter.sol +8 -0
  40. package/src/interfaces/IDefifaGamePotReporter.sol +8 -0
  41. package/src/interfaces/IDefifaGovernor.sol +132 -0
  42. package/src/interfaces/IDefifaHook.sol +228 -0
  43. package/src/interfaces/IDefifaTokenUriResolver.sol +10 -0
  44. package/src/libraries/DefifaFontImporter.sol +19 -0
  45. package/src/libraries/DefifaHookLib.sol +358 -0
  46. package/src/structs/DefifaAttestations.sol +9 -0
  47. package/src/structs/DefifaDelegation.sol +9 -0
  48. package/src/structs/DefifaLaunchProjectData.sol +59 -0
  49. package/src/structs/DefifaOpsData.sol +20 -0
  50. package/src/structs/DefifaScorecard.sol +9 -0
  51. package/src/structs/DefifaTierCashOutWeight.sol +9 -0
  52. package/src/structs/DefifaTierParams.sol +16 -0
  53. package/test/DefifaFeeAccounting.t.sol +559 -0
  54. package/test/DefifaGovernor.t.sol +1333 -0
  55. package/test/DefifaMintCostInvariant.t.sol +299 -0
  56. package/test/DefifaNoContest.t.sol +922 -0
  57. package/test/DefifaSecurity.t.sol +717 -0
  58. package/test/SVG.t.sol +164 -0
  59. package/test/deployScript.t.sol +144 -0
@@ -0,0 +1,717 @@
1
+ // SPDX-License-Identifier: UNLICENSED
2
+ pragma solidity 0.8.26;
3
+
4
+ import "forge-std/Test.sol";
5
+ import "../src/DefifaGovernor.sol";
6
+ import "../src/DefifaDeployer.sol";
7
+ import "../src/DefifaHook.sol";
8
+ import "../src/DefifaTokenUriResolver.sol";
9
+ import "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
10
+
11
+ import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
12
+ import {MetadataResolverHelper} from "@bananapus/core-v6/test/helpers/MetadataResolverHelper.sol";
13
+ import "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
14
+ import {JBTest} from "@bananapus/core-v6/test/helpers/JBTest.sol";
15
+ import "@bananapus/core-v6/src/libraries/JBRulesetMetadataResolver.sol";
16
+ import "@bananapus/721-hook-v6/src/libraries/JB721TiersRulesetMetadataResolver.sol";
17
+ import "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
18
+
19
+ /// @dev Helper to read block.timestamp via an external call, bypassing the via-ir optimizer's timestamp caching.
20
+ contract TimestampReader {
21
+ function timestamp() external view returns (uint256) {
22
+ return block.timestamp;
23
+ }
24
+ }
25
+
26
+ /// @title DefifaSecurityTest
27
+ /// @notice High-volume game integrity, fund conservation, scorecard validation, and governance tests.
28
+ contract DefifaSecurityTest is JBTest, TestBaseWorkflow {
29
+ using JBRulesetMetadataResolver for JBRuleset;
30
+
31
+ TimestampReader private _tsReader = new TimestampReader();
32
+
33
+ address _protocolFeeProjectTokenAccount;
34
+ address _defifaProjectTokenAccount;
35
+ uint256 _protocolFeeProjectId;
36
+ uint256 _defifaProjectId;
37
+ uint256 _gameId = 3;
38
+
39
+ DefifaDeployer deployer;
40
+ DefifaHook hook;
41
+ DefifaGovernor governor;
42
+ address projectOwner = address(bytes20(keccak256("projectOwner")));
43
+
44
+ // Shared test state (set by _setupGame helpers)
45
+ uint256 _pid;
46
+ DefifaHook _nft;
47
+ DefifaGovernor _gov;
48
+ address[] _users;
49
+
50
+ function setUp() public virtual override {
51
+ super.setUp();
52
+
53
+ JBAccountingContext[] memory _tokens = new JBAccountingContext[](1);
54
+ _tokens[0] = JBAccountingContext({token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: JBCurrencyIds.ETH});
55
+ JBTerminalConfig[] memory tc = new JBTerminalConfig[](1);
56
+ tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: _tokens});
57
+ JBRulesetConfig[] memory rc = new JBRulesetConfig[](1);
58
+ rc[0] = JBRulesetConfig({
59
+ mustStartAtOrAfter: 0,
60
+ duration: 10 days,
61
+ weight: 1e18,
62
+ weightCutPercent: 0,
63
+ approvalHook: IJBRulesetApprovalHook(address(0)),
64
+ metadata: JBRulesetMetadata({
65
+ reservedPercent: 0,
66
+ cashOutTaxRate: 0,
67
+ baseCurrency: JBCurrencyIds.ETH,
68
+ pausePay: false,
69
+ pauseCreditTransfers: false,
70
+ allowOwnerMinting: false,
71
+ allowSetCustomToken: false,
72
+ allowTerminalMigration: false,
73
+ allowSetTerminals: false,
74
+ allowSetController: false,
75
+ allowAddAccountingContext: false,
76
+ allowAddPriceFeed: false,
77
+ ownerMustSendPayouts: false,
78
+ holdFees: false,
79
+ useTotalSurplusForCashOuts: false,
80
+ useDataHookForPay: true,
81
+ useDataHookForCashOut: true,
82
+ dataHook: address(0),
83
+ metadata: 0
84
+ }),
85
+ splitGroups: new JBSplitGroup[](0),
86
+ fundAccessLimitGroups: new JBFundAccessLimitGroup[](0)
87
+ });
88
+
89
+ _protocolFeeProjectId = jbController().launchProjectFor(projectOwner, "", rc, tc, "");
90
+ vm.prank(projectOwner);
91
+ _protocolFeeProjectTokenAccount =
92
+ address(jbController().deployERC20For(_protocolFeeProjectId, "Bananapus", "NANA", bytes32(0)));
93
+ _defifaProjectId = jbController().launchProjectFor(projectOwner, "", rc, tc, "");
94
+ vm.prank(projectOwner);
95
+ _defifaProjectTokenAccount =
96
+ address(jbController().deployERC20For(_defifaProjectId, "Defifa", "DEFIFA", bytes32(0)));
97
+
98
+ hook =
99
+ new DefifaHook(jbDirectory(), IERC20(_defifaProjectTokenAccount), IERC20(_protocolFeeProjectTokenAccount));
100
+ governor = new DefifaGovernor(jbController(), address(this));
101
+ deployer = new DefifaDeployer(
102
+ address(hook),
103
+ new DefifaTokenUriResolver(ITypeface(address(0))),
104
+ governor,
105
+ jbController(),
106
+ new JBAddressRegistry(),
107
+ _protocolFeeProjectId,
108
+ _defifaProjectId
109
+ );
110
+ hook.transferOwnership(address(deployer));
111
+ governor.transferOwnership(address(deployer));
112
+ }
113
+
114
+ // =========================================================================
115
+ // HIGH-VOLUME: 32 tiers at 100 ETH each = 3,200 ETH pot
116
+ // =========================================================================
117
+ function testHighVolume_32tiers() external {
118
+ _setupGame(32, 100 ether);
119
+ _toScoring();
120
+
121
+ // Tier 1 gets 50%, rest split 50% — must sum to exactly TOTAL_CASHOUT_WEIGHT
122
+ uint256 tw = _nft.TOTAL_CASHOUT_WEIGHT();
123
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(32);
124
+ uint256 half = tw / 2;
125
+ uint256 perTier = half / 31;
126
+ uint256 assigned;
127
+ for (uint256 i; i < 32; i++) {
128
+ if (i == 0) {
129
+ sc[i].cashOutWeight = half;
130
+ } else if (i == 31) {
131
+ // Last tier absorbs rounding remainder
132
+ sc[i].cashOutWeight = tw - assigned;
133
+ } else {
134
+ sc[i].cashOutWeight = perTier;
135
+ }
136
+ assigned += sc[i].cashOutWeight;
137
+ }
138
+
139
+ _attestAndRatify(sc);
140
+ uint256 pot = _surplus();
141
+ uint256 out = _cashOutAllUsers();
142
+
143
+ assertApproxEqAbs(out, pot, 1e15, "cashed out vs pot");
144
+ assertLe(_surplus(), 1e15, "remaining dust");
145
+ // No fee tokens left in hook
146
+ assertEq(IERC20(_protocolFeeProjectTokenAccount).balanceOf(address(_nft)), 0, "no NANA left");
147
+ assertEq(IERC20(_defifaProjectTokenAccount).balanceOf(address(_nft)), 0, "no DEFIFA left");
148
+ }
149
+
150
+ // =========================================================================
151
+ // MULTI-PLAYER: 5 users in winning tier, 1 each in losing tiers
152
+ // =========================================================================
153
+ function testMultiPlayer_winnerTakesAll() external {
154
+ _setupMultiPlayer();
155
+ _toScoring();
156
+
157
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(4);
158
+ sc[0].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT();
159
+ // tiers 2-4 get 0
160
+
161
+ _attestAndRatify(sc);
162
+
163
+ // All winners should get approximately equal shares
164
+ uint256[] memory winnerPayouts = new uint256[](5);
165
+ for (uint256 i; i < 5; i++) {
166
+ uint256 bb = _users[i].balance;
167
+ _cashOut(_users[i], 1, i + 1);
168
+ winnerPayouts[i] = _users[i].balance - bb;
169
+ assertGt(winnerPayouts[i], 0, "winner should receive ETH");
170
+ }
171
+
172
+ // All winners should get approximately equal amounts (within 0.1%)
173
+ for (uint256 i = 1; i < 5; i++) {
174
+ assertApproxEqRel(winnerPayouts[i], winnerPayouts[0], 0.001 ether, "winner payouts should be equal");
175
+ }
176
+
177
+ // Losers get 0 ETH (but still receive fee tokens, so no NOTHING_TO_CLAIM revert)
178
+ for (uint256 i; i < 3; i++) {
179
+ uint256 bb = _users[5 + i].balance;
180
+ _cashOut(_users[5 + i], i + 2, 1);
181
+ assertEq(_users[5 + i].balance, bb, "loser should receive 0 ETH");
182
+ assertEq(_nft.balanceOf(_users[5 + i]), 0, "loser NFT burned");
183
+ }
184
+ }
185
+
186
+ // =========================================================================
187
+ // REFUND: exact price returned during MINT phase
188
+ // =========================================================================
189
+ function testRefundIntegrity() external {
190
+ _setupGame(8, 50 ether);
191
+
192
+ // Refund first 4 during MINT phase
193
+ for (uint256 i; i < 4; i++) {
194
+ uint256 bb = _users[i].balance;
195
+ _refund(_users[i], i + 1);
196
+ assertEq(_users[i].balance - bb, 50 ether, "exact refund");
197
+ assertEq(_nft.balanceOf(_users[i]), 0, "NFT burned");
198
+ }
199
+ assertEq(_surplus(), 50 ether * 4, "pot = remaining mints");
200
+ }
201
+
202
+ // =========================================================================
203
+ // ROUNDING: extreme weights at 1000 ETH per tier
204
+ // =========================================================================
205
+ function testRounding_extremeWeights() external {
206
+ _setupGame(3, 1000 ether);
207
+ _toScoring();
208
+
209
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(3);
210
+ sc[0].cashOutWeight = 1;
211
+ sc[1].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT() - 2;
212
+ sc[2].cashOutWeight = 1;
213
+
214
+ _attestAndRatify(sc);
215
+ uint256 pot = _surplus();
216
+ uint256 out = _cashOutAllUsers();
217
+ assertApproxEqAbs(out + _surplus(), pot, 3, "fund conservation");
218
+ assertGt(_users[1].balance, pot * 99 / 100, "tier 2 > 99%");
219
+ }
220
+
221
+ // =========================================================================
222
+ // C-D2: overweight scorecard rejected
223
+ // =========================================================================
224
+ function testC_D2_rejectsOverweight() external {
225
+ _setupGame(4, 1 ether);
226
+ _toScoring();
227
+
228
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(4);
229
+ for (uint256 i; i < 4; i++) {
230
+ sc[i].cashOutWeight = (_nft.TOTAL_CASHOUT_WEIGHT() * 30) / 100; // 120% total
231
+ }
232
+
233
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
234
+ _attestAllFor(pid);
235
+ vm.expectRevert(DefifaHook.DefifaHook_InvalidCashoutWeights.selector);
236
+ _gov.ratifyScorecardFrom(_gameId, sc);
237
+ }
238
+
239
+ // =========================================================================
240
+ // M-D6: delegation blocked after MINT phase
241
+ // =========================================================================
242
+ function testM_D6_delegationBlocked() external {
243
+ _setupGame(4, 1 ether);
244
+
245
+ // REFUND phase
246
+ vm.warp(block.timestamp + 1 days);
247
+ vm.prank(_users[0]);
248
+ vm.expectRevert(abi.encodeWithSignature("DefifaHook_DelegateChangesUnavailableInThisPhase()"));
249
+ _nft.setTierDelegateTo(address(1), 1);
250
+
251
+ // SCORING phase
252
+ vm.warp(block.timestamp + 2 days);
253
+ vm.prank(_users[0]);
254
+ vm.expectRevert(abi.encodeWithSignature("DefifaHook_DelegateChangesUnavailableInThisPhase()"));
255
+ _nft.setTierDelegateTo(address(1), 1);
256
+ }
257
+
258
+ // =========================================================================
259
+ // QUORUM: 50% of minted tiers
260
+ // =========================================================================
261
+ function testQuorum_50pctMintedTiers() external {
262
+ // Launch game with 10 tiers but only mint 6
263
+ _setupPartial(10, 6, 1 ether);
264
+ uint256 expected = (6 * _gov.MAX_ATTESTATION_POWER_TIER()) / 2;
265
+ assertEq(_gov.quorum(_gameId), expected, "quorum = 50% of minted tiers");
266
+ }
267
+
268
+ // =========================================================================
269
+ // FUZZ: fund conservation across varying tier/player counts
270
+ // =========================================================================
271
+ function testFuzz_fundConservation(uint8 rawTiers, uint8 rawPlayers) external {
272
+ uint8 nTiers = uint8(bound(rawTiers, 2, 12));
273
+ uint8 nPPT = uint8(bound(rawPlayers, 1, 3));
274
+
275
+ _setupMultiN(nTiers, nPPT, 1 ether);
276
+ _toScoring();
277
+
278
+ uint256 tw = _nft.TOTAL_CASHOUT_WEIGHT();
279
+ uint256 wpt = tw / nTiers;
280
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(nTiers);
281
+ uint256 assigned;
282
+ for (uint256 i; i < nTiers; i++) {
283
+ if (i == nTiers - 1) {
284
+ // Last tier absorbs rounding remainder to satisfy exact weight requirement
285
+ sc[i].cashOutWeight = tw - assigned;
286
+ } else {
287
+ sc[i].cashOutWeight = wpt;
288
+ }
289
+ assigned += sc[i].cashOutWeight;
290
+ }
291
+
292
+ _attestAndRatify(sc);
293
+ uint256 pot = _surplus();
294
+
295
+ uint256 total;
296
+ for (uint256 i; i < _users.length; i++) {
297
+ uint256 bb = _users[i].balance;
298
+ uint256 tid = (i / nPPT) + 1;
299
+ uint256 tnum = (i % nPPT) + 1;
300
+ _cashOut(_users[i], tid, tnum);
301
+ total += _users[i].balance - bb;
302
+ }
303
+
304
+ assertApproxEqAbs(total + _surplus(), pot, _users.length, "fund conservation");
305
+ }
306
+
307
+ // =========================================================================
308
+ // SCORECARD: valid equal-weight scorecard passes
309
+ // =========================================================================
310
+ function testScorecard_equalWeight_passes() external {
311
+ _setupGame(4, 1 ether);
312
+ _toScoring();
313
+
314
+ uint256 tw = _nft.TOTAL_CASHOUT_WEIGHT();
315
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(4);
316
+ for (uint256 i; i < 4; i++) {
317
+ sc[i].cashOutWeight = tw / 4;
318
+ }
319
+
320
+ // Should succeed without reverting
321
+ _attestAndRatify(sc);
322
+ assertTrue(_nft.cashOutWeightIsSet(), "weights should be set");
323
+ }
324
+
325
+ // =========================================================================
326
+ // C-D3: reserved minters get proportional fee tokens ($DEFIFA/$NANA)
327
+ // =========================================================================
328
+ function testC_D3_reservedMintersGetFeeTokens() external {
329
+ // Setup: 2 tiers, reservedRate=1 (1 reserve per paid mint), reserveBeneficiary = _reserveAddr
330
+ address _reserveAddr = address(bytes20(keccak256("reserveBeneficiary")));
331
+
332
+ DefifaTierParams[] memory tp = new DefifaTierParams[](2);
333
+ for (uint256 i; i < 2; i++) {
334
+ tp[i] = DefifaTierParams({
335
+ reservedRate: 1, // 1 reserve per 1 paid mint
336
+ reservedTokenBeneficiary: _reserveAddr,
337
+ encodedIPFSUri: bytes32(0),
338
+ shouldUseReservedTokenBeneficiaryAsDefault: false,
339
+ name: "DEFIFA"
340
+ });
341
+ }
342
+ DefifaLaunchProjectData memory d = DefifaLaunchProjectData({
343
+ name: "DEFIFA",
344
+ projectUri: "",
345
+ contractUri: "",
346
+ baseUri: "",
347
+ token: JBAccountingContext({token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: JBCurrencyIds.ETH}),
348
+ mintPeriodDuration: 1 days,
349
+ start: uint48(block.timestamp + 3 days),
350
+ refundPeriodDuration: 1 days,
351
+ store: new JB721TiersHookStore(),
352
+ splits: new JBSplit[](0),
353
+ attestationStartTime: 0,
354
+ attestationGracePeriod: 100_381,
355
+ defaultAttestationDelegate: address(0),
356
+ tierPrice: uint104(1 ether),
357
+ tiers: tp,
358
+ defaultTokenUriResolver: IJB721TokenUriResolver(address(0)),
359
+ terminal: jbMultiTerminal(),
360
+ minParticipation: 0,
361
+ scorecardTimeout: 0
362
+ });
363
+ (_pid, _nft, _gov) = _launch(d);
364
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
365
+
366
+ // Paid mints: user0 mints tier 1, user1 mints tier 2
367
+ _users = new address[](2);
368
+ _users[0] = _addr(0);
369
+ _users[1] = _addr(1);
370
+ _mint(_users[0], 1, 1 ether);
371
+ _delegateSelf(_users[0], 1);
372
+ vm.warp(_tsReader.timestamp() + 1);
373
+ _mint(_users[1], 2, 1 ether);
374
+ _delegateSelf(_users[1], 2);
375
+ vm.warp(_tsReader.timestamp() + 1);
376
+
377
+ // Move to scoring phase (reserves can only be minted here)
378
+ _toScoring();
379
+
380
+ // Mint reserved tokens (1 per tier since reserveFrequency=1)
381
+ JB721TiersMintReservesConfig[] memory reserveConfigs = new JB721TiersMintReservesConfig[](2);
382
+ reserveConfigs[0] = JB721TiersMintReservesConfig({tierId: 1, count: 1});
383
+ reserveConfigs[1] = JB721TiersMintReservesConfig({tierId: 2, count: 1});
384
+ _nft.mintReservesFor(reserveConfigs);
385
+
386
+ // Reserve beneficiary should hold 2 NFTs (mintReservesFor auto-delegates to self)
387
+ assertEq(_nft.balanceOf(_reserveAddr), 2, "reserve beneficiary holds 2 NFTs");
388
+
389
+ // Seed fee tokens into the hook (simulating protocol fee distribution)
390
+ uint256 defifaAmount = 1000 ether;
391
+ uint256 nanaAmount = 500 ether;
392
+ deal(address(IERC20(_defifaProjectTokenAccount)), address(_nft), defifaAmount);
393
+ deal(address(IERC20(_protocolFeeProjectTokenAccount)), address(_nft), nanaAmount);
394
+
395
+ // Scorecard: equal weight
396
+ uint256 tw = _nft.TOTAL_CASHOUT_WEIGHT();
397
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(2);
398
+ sc[0].cashOutWeight = tw / 2;
399
+ sc[1].cashOutWeight = tw / 2;
400
+
401
+ // Need _reserveAddr to attest too
402
+ address[] memory allUsers = new address[](3);
403
+ allUsers[0] = _users[0];
404
+ allUsers[1] = _users[1];
405
+ allUsers[2] = _reserveAddr;
406
+
407
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
408
+ vm.warp(_tsReader.timestamp() + _gov.attestationStartTimeOf(_gameId) + 1);
409
+ for (uint256 i; i < allUsers.length; i++) {
410
+ vm.prank(allUsers[i]);
411
+ _gov.attestToScorecardFrom(_gameId, pid);
412
+ }
413
+ vm.warp(_tsReader.timestamp() + _gov.attestationGracePeriodOf(_gameId) + 1);
414
+ _gov.ratifyScorecardFrom(_gameId, sc);
415
+ vm.warp(_tsReader.timestamp() + 1);
416
+
417
+ // Cash out paid minters
418
+ _cashOut(_users[0], 1, 1);
419
+ _cashOut(_users[1], 2, 1);
420
+
421
+ // Check that paid minters got fee tokens
422
+ uint256 user0Defifa = IERC20(_defifaProjectTokenAccount).balanceOf(_users[0]);
423
+ uint256 user0Nana = IERC20(_protocolFeeProjectTokenAccount).balanceOf(_users[0]);
424
+ assertGt(user0Defifa, 0, "paid minter got DEFIFA tokens");
425
+ assertGt(user0Nana, 0, "paid minter got NANA tokens");
426
+
427
+ // Cash out reserved minter (tier 1, token #2 and tier 2, token #2)
428
+ // Reserved tokens are the 2nd minted in each tier
429
+ bytes memory meta1 = _cashOutMeta(1, 2);
430
+ vm.prank(_reserveAddr);
431
+ JBMultiTerminal(address(jbMultiTerminal()))
432
+ .cashOutTokensOf({
433
+ holder: _reserveAddr,
434
+ projectId: _pid,
435
+ cashOutCount: 0,
436
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
437
+ minTokensReclaimed: 0,
438
+ beneficiary: payable(_reserveAddr),
439
+ metadata: meta1
440
+ });
441
+
442
+ bytes memory meta2 = _cashOutMeta(2, 2);
443
+ vm.prank(_reserveAddr);
444
+ JBMultiTerminal(address(jbMultiTerminal()))
445
+ .cashOutTokensOf({
446
+ holder: _reserveAddr,
447
+ projectId: _pid,
448
+ cashOutCount: 0,
449
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
450
+ minTokensReclaimed: 0,
451
+ beneficiary: payable(_reserveAddr),
452
+ metadata: meta2
453
+ });
454
+
455
+ // Reserved minter should have gotten fee tokens too
456
+ uint256 reserveDefifa = IERC20(_defifaProjectTokenAccount).balanceOf(_reserveAddr);
457
+ uint256 reserveNana = IERC20(_protocolFeeProjectTokenAccount).balanceOf(_reserveAddr);
458
+ assertGt(reserveDefifa, 0, "reserved minter got DEFIFA tokens");
459
+ assertGt(reserveNana, 0, "reserved minter got NANA tokens");
460
+
461
+ // All 4 tokens had equal tier.price (1 ether), so each should get 25% of fee tokens
462
+ // (paid and reserved mints are treated equally in _totalMintCost)
463
+ assertApproxEqAbs(user0Defifa, reserveDefifa / 2, 1, "reserved gets 2x (2 tokens) vs paid (1 token)");
464
+ assertApproxEqAbs(user0Nana, reserveNana / 2, 1, "NANA distribution matches");
465
+
466
+ // All fee tokens distributed (none left in hook)
467
+ assertEq(IERC20(_defifaProjectTokenAccount).balanceOf(address(_nft)), 0, "no DEFIFA left");
468
+ assertEq(IERC20(_protocolFeeProjectTokenAccount).balanceOf(address(_nft)), 0, "no NANA left");
469
+ }
470
+
471
+ // =========================================================================
472
+ // GAME LIFECYCLE: cash-out before scorecard gives 0 ETH (weights = 0)
473
+ // =========================================================================
474
+ function testNoCashOut_beforeScorecard() external {
475
+ _setupGame(4, 1 ether);
476
+ _toScoring();
477
+
478
+ // Cash out during scoring before scorecard — weight=0 means NOTHING_TO_CLAIM revert
479
+ bytes memory meta = _cashOutMeta(1, 1);
480
+ vm.expectRevert(DefifaHook.DefifaHook_NothingToClaim.selector);
481
+ vm.prank(_users[0]);
482
+ JBMultiTerminal(address(jbMultiTerminal()))
483
+ .cashOutTokensOf({
484
+ holder: _users[0],
485
+ projectId: _pid,
486
+ cashOutCount: 0,
487
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
488
+ minTokensReclaimed: 0,
489
+ beneficiary: payable(_users[0]),
490
+ metadata: meta
491
+ });
492
+ // NFT should NOT have been burned (revert rolled it back)
493
+ assertEq(_nft.balanceOf(_users[0]), 1, "NFT not burned on revert");
494
+ }
495
+
496
+ // =========================================================================
497
+ // SETUP HELPERS
498
+ // =========================================================================
499
+
500
+ function _setupGame(uint8 nTiers, uint256 tierPrice) internal {
501
+ DefifaLaunchProjectData memory d = _launch_data(nTiers, tierPrice);
502
+ (_pid, _nft, _gov) = _launch(d);
503
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
504
+ _users = new address[](nTiers);
505
+ for (uint256 i; i < nTiers; i++) {
506
+ _users[i] = _addr(i);
507
+ _mint(_users[i], i + 1, tierPrice);
508
+ _delegateSelf(_users[i], i + 1);
509
+ vm.warp(block.timestamp + 1);
510
+ }
511
+ }
512
+
513
+ function _setupMultiPlayer() internal {
514
+ DefifaLaunchProjectData memory d = _launch_data(4, 1 ether);
515
+ (_pid, _nft, _gov) = _launch(d);
516
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
517
+ _users = new address[](8);
518
+ for (uint256 i; i < 5; i++) {
519
+ _users[i] = _addr(100 + i);
520
+ _mint(_users[i], 1, 1 ether);
521
+ _delegateSelf(_users[i], 1);
522
+ vm.warp(block.timestamp + 1);
523
+ }
524
+ for (uint256 i; i < 3; i++) {
525
+ _users[5 + i] = _addr(200 + i);
526
+ _mint(_users[5 + i], i + 2, 1 ether);
527
+ _delegateSelf(_users[5 + i], i + 2);
528
+ vm.warp(block.timestamp + 1);
529
+ }
530
+ }
531
+
532
+ function _setupMultiN(uint8 nTiers, uint8 nPPT, uint256 tierPrice) internal {
533
+ DefifaLaunchProjectData memory d = _launch_data(nTiers, tierPrice);
534
+ (_pid, _nft, _gov) = _launch(d);
535
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
536
+ uint256 total = uint256(nTiers) * uint256(nPPT);
537
+ _users = new address[](total);
538
+ uint256 idx;
539
+ for (uint256 t; t < nTiers; t++) {
540
+ for (uint256 p; p < nPPT; p++) {
541
+ _users[idx] = _addr(idx);
542
+ _mint(_users[idx], t + 1, tierPrice);
543
+ _delegateSelf(_users[idx], t + 1);
544
+ vm.warp(block.timestamp + 1);
545
+ idx++;
546
+ }
547
+ }
548
+ }
549
+
550
+ function _setupPartial(uint8 nTiers, uint256 nMint, uint256 tierPrice) internal {
551
+ DefifaLaunchProjectData memory d = _launch_data(nTiers, tierPrice);
552
+ (_pid, _nft, _gov) = _launch(d);
553
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
554
+ _users = new address[](nMint);
555
+ for (uint256 i; i < nMint; i++) {
556
+ _users[i] = _addr(i);
557
+ _mint(_users[i], i + 1, tierPrice);
558
+ }
559
+ }
560
+
561
+ function _toScoring() internal {
562
+ // Warp 3 days forward (past mint + refund) into scoring
563
+ vm.warp(_tsReader.timestamp() + 3 days + 1);
564
+ }
565
+
566
+ // =========================================================================
567
+ // PRIMITIVE HELPERS
568
+ // =========================================================================
569
+
570
+ function _launch_data(uint8 n, uint256 tierPrice) internal returns (DefifaLaunchProjectData memory) {
571
+ DefifaTierParams[] memory tp = new DefifaTierParams[](n);
572
+ for (uint256 i; i < n; i++) {
573
+ tp[i] = DefifaTierParams({
574
+ reservedRate: 1001,
575
+ reservedTokenBeneficiary: address(0),
576
+ encodedIPFSUri: bytes32(0),
577
+ shouldUseReservedTokenBeneficiaryAsDefault: false,
578
+ name: "DEFIFA"
579
+ });
580
+ }
581
+ return DefifaLaunchProjectData({
582
+ name: "DEFIFA",
583
+ projectUri: "",
584
+ contractUri: "",
585
+ baseUri: "",
586
+ token: JBAccountingContext({token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: JBCurrencyIds.ETH}),
587
+ mintPeriodDuration: 1 days,
588
+ start: uint48(block.timestamp + 3 days),
589
+ refundPeriodDuration: 1 days,
590
+ store: new JB721TiersHookStore(),
591
+ splits: new JBSplit[](0),
592
+ attestationStartTime: 0,
593
+ attestationGracePeriod: 100_381,
594
+ defaultAttestationDelegate: address(0),
595
+ tierPrice: uint104(tierPrice),
596
+ tiers: tp,
597
+ defaultTokenUriResolver: IJB721TokenUriResolver(address(0)),
598
+ terminal: jbMultiTerminal(),
599
+ minParticipation: 0,
600
+ scorecardTimeout: 0
601
+ });
602
+ }
603
+
604
+ function _launch(DefifaLaunchProjectData memory d) internal returns (uint256 p, DefifaHook n, DefifaGovernor g) {
605
+ g = governor;
606
+ p = deployer.launchGameWith(d);
607
+ JBRuleset memory fc = jbRulesets().currentOf(p);
608
+ if (fc.dataHook() == address(0)) (fc,) = jbRulesets().latestQueuedOf(p);
609
+ n = DefifaHook(fc.dataHook());
610
+ }
611
+
612
+ function _addr(uint256 i) internal pure returns (address) {
613
+ return address(bytes20(keccak256(abi.encode("su", i))));
614
+ }
615
+
616
+ function _mint(address user, uint256 tid, uint256 amt) internal {
617
+ vm.deal(user, amt);
618
+ uint16[] memory m = new uint16[](1);
619
+ m[0] = uint16(tid);
620
+ bytes[] memory data = new bytes[](1);
621
+ data[0] = abi.encode(user, m);
622
+ bytes4[] memory ids = new bytes4[](1);
623
+ ids[0] = metadataHelper().getId("pay", address(hook));
624
+ vm.prank(user);
625
+ jbMultiTerminal().pay{value: amt}(
626
+ _pid, JBConstants.NATIVE_TOKEN, amt, user, 0, "", metadataHelper().createMetadata(ids, data)
627
+ );
628
+ }
629
+
630
+ function _delegateSelf(address user, uint256 tid) internal {
631
+ DefifaDelegation[] memory dd = new DefifaDelegation[](1);
632
+ dd[0] = DefifaDelegation({delegatee: user, tierId: tid});
633
+ vm.prank(user);
634
+ _nft.setTierDelegatesTo(dd);
635
+ }
636
+
637
+ function _buildScorecard(uint256 n) internal pure returns (DefifaTierCashOutWeight[] memory sc) {
638
+ sc = new DefifaTierCashOutWeight[](n);
639
+ for (uint256 i; i < n; i++) {
640
+ sc[i].id = i + 1;
641
+ }
642
+ }
643
+
644
+ function _attestAndRatify(DefifaTierCashOutWeight[] memory sc) internal {
645
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
646
+ _attestAllFor(pid);
647
+ _gov.ratifyScorecardFrom(_gameId, sc);
648
+ vm.warp(block.timestamp + 1);
649
+ }
650
+
651
+ function _attestAllFor(uint256 pid) internal {
652
+ vm.warp(block.timestamp + _gov.attestationStartTimeOf(_gameId) + 1);
653
+ for (uint256 i; i < _users.length; i++) {
654
+ vm.prank(_users[i]);
655
+ _gov.attestToScorecardFrom(_gameId, pid);
656
+ }
657
+ vm.warp(block.timestamp + _gov.attestationGracePeriodOf(_gameId) + 1);
658
+ }
659
+
660
+ function _surplus() internal view returns (uint256) {
661
+ return
662
+ jbMultiTerminal()
663
+ .currentSurplusOf(_pid, jbMultiTerminal().accountingContextsOf(_pid), 18, JBCurrencyIds.ETH);
664
+ }
665
+
666
+ function _cashOut(address user, uint256 tid, uint256 tnum) internal {
667
+ bytes memory meta = _cashOutMeta(tid, tnum);
668
+ vm.prank(user);
669
+ JBMultiTerminal(address(jbMultiTerminal()))
670
+ .cashOutTokensOf({
671
+ holder: user,
672
+ projectId: _pid,
673
+ cashOutCount: 0,
674
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
675
+ minTokensReclaimed: 0,
676
+ beneficiary: payable(user),
677
+ metadata: meta
678
+ });
679
+ }
680
+
681
+ function _cashOutMeta(uint256 tid, uint256 tnum) internal returns (bytes memory) {
682
+ uint256[] memory cid = new uint256[](1);
683
+ cid[0] = (tid * 1_000_000_000) + tnum;
684
+ bytes[] memory data = new bytes[](1);
685
+ data[0] = abi.encode(cid);
686
+ bytes4[] memory ids = new bytes4[](1);
687
+ ids[0] = metadataHelper().getId("cashOut", address(hook));
688
+ return metadataHelper().createMetadata(ids, data);
689
+ }
690
+
691
+ function _cashOutAllUsers() internal returns (uint256 total) {
692
+ for (uint256 i; i < _users.length; i++) {
693
+ uint256 bb = _users[i].balance;
694
+ _cashOut(_users[i], i + 1, 1);
695
+ total += _users[i].balance - bb;
696
+ }
697
+ }
698
+
699
+ function _refund(address user, uint256 tid) internal {
700
+ JB721Tier memory tier = _nft.store().tierOf(address(_nft), tid, false);
701
+ uint256 nb = _nft.store().numberOfBurnedFor(address(_nft), tid);
702
+ // Tier fetched AFTER mint: remainingSupply already decremented, so no +1
703
+ uint256 tnum = tier.initialSupply - tier.remainingSupply + nb;
704
+ bytes memory meta = _cashOutMeta(tid, tnum);
705
+ vm.prank(user);
706
+ JBMultiTerminal(address(jbMultiTerminal()))
707
+ .cashOutTokensOf({
708
+ holder: user,
709
+ projectId: _pid,
710
+ cashOutCount: 0,
711
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
712
+ minTokensReclaimed: 0,
713
+ beneficiary: payable(user),
714
+ metadata: meta
715
+ });
716
+ }
717
+ }