@ballkidz/defifa 0.0.2 → 0.0.4

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,2400 @@
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
+ import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
19
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
20
+ import {ITypeface} from "lib/typeface/contracts/interfaces/ITypeface.sol";
21
+ import {IJB721TokenUriResolver} from "@bananapus/721-hook-v6/src/interfaces/IJB721TokenUriResolver.sol";
22
+ import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
23
+ import {DefifaDelegation} from "../src/structs/DefifaDelegation.sol";
24
+ import {DefifaLaunchProjectData} from "../src/structs/DefifaLaunchProjectData.sol";
25
+ import {DefifaTierParams} from "../src/structs/DefifaTierParams.sol";
26
+ import {DefifaTierCashOutWeight} from "../src/structs/DefifaTierCashOutWeight.sol";
27
+ import {DefifaGamePhase} from "../src/enums/DefifaGamePhase.sol";
28
+ import {DefifaScorecardState} from "../src/enums/DefifaScorecardState.sol";
29
+
30
+ /// @dev Helper to read block.timestamp via an external call, bypassing the via-ir optimizer's timestamp caching.
31
+ contract TimestampReader {
32
+ function timestamp() external view returns (uint256) {
33
+ return block.timestamp;
34
+ }
35
+ }
36
+
37
+ /// @title DefifaForkTest
38
+ /// @notice Comprehensive fork tests covering full game lifecycle, edge cases, adversarial conditions, and fund
39
+ /// conservation. Forks Ethereum mainnet to test in realistic conditions.
40
+ contract DefifaForkTest is JBTest, TestBaseWorkflow {
41
+ using JBRulesetMetadataResolver for JBRuleset;
42
+
43
+ TimestampReader private _tsReader;
44
+
45
+ address _protocolFeeProjectTokenAccount;
46
+ address _defifaProjectTokenAccount;
47
+ uint256 _protocolFeeProjectId;
48
+ uint256 _defifaProjectId;
49
+ uint256 _gameId = 3;
50
+
51
+ DefifaDeployer deployer;
52
+ DefifaHook hook;
53
+ DefifaGovernor governor;
54
+ address projectOwner = address(bytes20(keccak256("projectOwner")));
55
+
56
+ // Shared test state
57
+ uint256 _pid;
58
+ DefifaHook _nft;
59
+ DefifaGovernor _gov;
60
+ address[] _users;
61
+
62
+ modifier onlyFork() {
63
+ string memory rpcUrl = vm.envOr("RPC_ETHEREUM_MAINNET", string(""));
64
+ if (bytes(rpcUrl).length == 0) {
65
+ vm.skip(true);
66
+ return;
67
+ }
68
+ _;
69
+ }
70
+
71
+ function setUp() public virtual override {
72
+ string memory rpcUrl = vm.envOr("RPC_ETHEREUM_MAINNET", string(""));
73
+ if (bytes(rpcUrl).length == 0) return;
74
+ vm.createSelectFork(rpcUrl);
75
+
76
+ // Deploy JB core fresh on fork.
77
+ super.setUp();
78
+
79
+ _tsReader = new TimestampReader();
80
+
81
+ JBAccountingContext[] memory _tokens = new JBAccountingContext[](1);
82
+ _tokens[0] = JBAccountingContext({token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: JBCurrencyIds.ETH});
83
+ JBTerminalConfig[] memory tc = new JBTerminalConfig[](1);
84
+ tc[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: _tokens});
85
+ JBRulesetConfig[] memory rc = new JBRulesetConfig[](1);
86
+ rc[0] = JBRulesetConfig({
87
+ mustStartAtOrAfter: 0,
88
+ duration: 10 days,
89
+ weight: 1e18,
90
+ weightCutPercent: 0,
91
+ approvalHook: IJBRulesetApprovalHook(address(0)),
92
+ metadata: JBRulesetMetadata({
93
+ reservedPercent: 0,
94
+ cashOutTaxRate: 0,
95
+ baseCurrency: JBCurrencyIds.ETH,
96
+ pausePay: false,
97
+ pauseCreditTransfers: false,
98
+ allowOwnerMinting: false,
99
+ allowSetCustomToken: false,
100
+ allowTerminalMigration: false,
101
+ allowSetTerminals: false,
102
+ allowSetController: false,
103
+ allowAddAccountingContext: false,
104
+ allowAddPriceFeed: false,
105
+ ownerMustSendPayouts: false,
106
+ holdFees: false,
107
+ useTotalSurplusForCashOuts: false,
108
+ useDataHookForPay: true,
109
+ useDataHookForCashOut: true,
110
+ dataHook: address(0),
111
+ metadata: 0
112
+ }),
113
+ splitGroups: new JBSplitGroup[](0),
114
+ fundAccessLimitGroups: new JBFundAccessLimitGroup[](0)
115
+ });
116
+
117
+ _protocolFeeProjectId = jbController().launchProjectFor(projectOwner, "", rc, tc, "");
118
+ vm.prank(projectOwner);
119
+ _protocolFeeProjectTokenAccount =
120
+ address(jbController().deployERC20For(_protocolFeeProjectId, "Bananapus", "NANA", bytes32(0)));
121
+ _defifaProjectId = jbController().launchProjectFor(projectOwner, "", rc, tc, "");
122
+ vm.prank(projectOwner);
123
+ _defifaProjectTokenAccount =
124
+ address(jbController().deployERC20For(_defifaProjectId, "Defifa", "DEFIFA", bytes32(0)));
125
+
126
+ hook =
127
+ new DefifaHook(jbDirectory(), IERC20(_defifaProjectTokenAccount), IERC20(_protocolFeeProjectTokenAccount));
128
+ governor = new DefifaGovernor(jbController(), address(this));
129
+ deployer = new DefifaDeployer(
130
+ address(hook),
131
+ new DefifaTokenUriResolver(ITypeface(address(0))),
132
+ governor,
133
+ jbController(),
134
+ new JBAddressRegistry(),
135
+ _defifaProjectId,
136
+ _protocolFeeProjectId
137
+ );
138
+
139
+ // Grant deployer SET_SPLIT_GROUPS permission on the defifa fee project.
140
+ uint8[] memory permissionIds = new uint8[](1);
141
+ permissionIds[0] = JBPermissionIds.SET_SPLIT_GROUPS;
142
+ vm.prank(projectOwner);
143
+ jbPermissions()
144
+ .setPermissionsFor(
145
+ projectOwner,
146
+ JBPermissionsData({
147
+ operator: address(deployer), projectId: uint64(_defifaProjectId), permissionIds: permissionIds
148
+ })
149
+ );
150
+
151
+ hook.transferOwnership(address(deployer));
152
+ governor.transferOwnership(address(deployer));
153
+ }
154
+
155
+ // =========================================================================
156
+ // FULL LIFECYCLE: Mint → Refund → Score → Ratify → Cash Out
157
+ // =========================================================================
158
+
159
+ function test_fork_fullLifecycle_4tiers() external onlyFork {
160
+ _setupGame(4, 1 ether);
161
+
162
+ // Verify MINT phase.
163
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.MINT));
164
+
165
+ // Verify all users hold NFTs.
166
+ for (uint256 i; i < 4; i++) {
167
+ assertEq(_nft.balanceOf(_users[i]), 1, "each user holds 1 NFT");
168
+ }
169
+
170
+ // Record pot before fees.
171
+ uint256 potBefore = _balance();
172
+ assertEq(potBefore, 4 ether, "pot = 4 ETH from 4 mints");
173
+
174
+ // Advance to SCORING.
175
+ _toScoring();
176
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.SCORING));
177
+
178
+ // Set winner-take-all scorecard: tier 1 gets everything.
179
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(4);
180
+ sc[0].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT();
181
+
182
+ _attestAndRatify(sc);
183
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.COMPLETE));
184
+
185
+ // Winner cashes out.
186
+ uint256 winnerBefore = _users[0].balance;
187
+ _cashOut(_users[0], 1, 1);
188
+ uint256 winnerReceived = _users[0].balance - winnerBefore;
189
+ assertGt(winnerReceived, 0, "winner received ETH");
190
+
191
+ // Losers get only fee tokens (no ETH).
192
+ for (uint256 i = 1; i < 4; i++) {
193
+ uint256 bb = _users[i].balance;
194
+ _cashOut(_users[i], i + 1, 1);
195
+ assertEq(_users[i].balance, bb, "loser gets 0 ETH");
196
+ // But they should have received fee tokens.
197
+ uint256 defifa = IERC20(_defifaProjectTokenAccount).balanceOf(_users[i]);
198
+ uint256 nana = IERC20(_protocolFeeProjectTokenAccount).balanceOf(_users[i]);
199
+ assertTrue(defifa > 0 || nana > 0, "loser got fee tokens");
200
+ }
201
+
202
+ // All fee tokens distributed.
203
+ assertEq(IERC20(_defifaProjectTokenAccount).balanceOf(address(_nft)), 0, "no DEFIFA left in hook");
204
+ assertEq(IERC20(_protocolFeeProjectTokenAccount).balanceOf(address(_nft)), 0, "no NANA left in hook");
205
+ }
206
+
207
+ // =========================================================================
208
+ // REFUND PHASE: Full refund during MINT, partial refund patterns
209
+ // =========================================================================
210
+
211
+ function test_fork_refundDuringMint_exactPrice() external onlyFork {
212
+ _setupGame(8, 2 ether);
213
+
214
+ // Refund first 4 users during MINT.
215
+ for (uint256 i; i < 4; i++) {
216
+ uint256 bb = _users[i].balance;
217
+ _refund(_users[i], i + 1);
218
+ assertEq(_users[i].balance - bb, 2 ether, "exact refund of mint price");
219
+ assertEq(_nft.balanceOf(_users[i]), 0, "NFT burned on refund");
220
+ }
221
+
222
+ // Remaining pot = 4 users * 2 ETH = 8 ETH.
223
+ assertEq(_balance(), 8 ether, "pot = remaining mints");
224
+ }
225
+
226
+ function test_fork_refundDuringRefundPhase() external onlyFork {
227
+ _setupGame(4, 1 ether);
228
+
229
+ // Advance past MINT into REFUND phase.
230
+ vm.warp(_tsReader.timestamp() + 1 days + 1);
231
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.REFUND));
232
+
233
+ // Refund during REFUND phase.
234
+ uint256 bb = _users[0].balance;
235
+ _refund(_users[0], 1);
236
+ assertEq(_users[0].balance - bb, 1 ether, "refund works in REFUND phase");
237
+ }
238
+
239
+ // =========================================================================
240
+ // HIGH VOLUME: 32 tiers × 100 ETH each = 3,200 ETH pot
241
+ // =========================================================================
242
+
243
+ function test_fork_highVolume_32tiers_100eth() external onlyFork {
244
+ _setupGame(32, 100 ether);
245
+ _toScoring();
246
+
247
+ // Tier 1 = 50%, rest split evenly.
248
+ uint256 tw = _nft.TOTAL_CASHOUT_WEIGHT();
249
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(32);
250
+ uint256 half = tw / 2;
251
+ uint256 perTier = half / 31;
252
+ uint256 assigned;
253
+ for (uint256 i; i < 32; i++) {
254
+ if (i == 0) {
255
+ sc[i].cashOutWeight = half;
256
+ } else if (i == 31) {
257
+ sc[i].cashOutWeight = tw - assigned;
258
+ } else {
259
+ sc[i].cashOutWeight = perTier;
260
+ }
261
+ assigned += sc[i].cashOutWeight;
262
+ }
263
+
264
+ _attestAndRatify(sc);
265
+ uint256 pot = _surplus();
266
+ uint256 out = _cashOutAllUsers();
267
+
268
+ assertApproxEqAbs(out, pot, 1e15, "total cashed out ~ pot");
269
+ assertLe(_surplus(), 1e15, "negligible dust remains");
270
+ }
271
+
272
+ // =========================================================================
273
+ // EXTREME ROUNDING: 1 wei weights, 1000 ETH per tier
274
+ // =========================================================================
275
+
276
+ function test_fork_extremeWeights_1weiAnd999999() external onlyFork {
277
+ _setupGame(3, 1000 ether);
278
+ _toScoring();
279
+
280
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(3);
281
+ sc[0].cashOutWeight = 1;
282
+ sc[1].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT() - 2;
283
+ sc[2].cashOutWeight = 1;
284
+
285
+ _attestAndRatify(sc);
286
+ uint256 pot = _surplus();
287
+ uint256 out = _cashOutAllUsers();
288
+ assertApproxEqAbs(out + _surplus(), pot, 3, "fund conservation with extreme weights");
289
+ assertGt(_users[1].balance, pot * 99 / 100, "tier 2 > 99% of pot");
290
+ }
291
+
292
+ // =========================================================================
293
+ // MULTI-PLAYER PER TIER: 5 winners, 3 losers
294
+ // =========================================================================
295
+
296
+ function test_fork_multiPlayerPerTier_winnerTakeAll() external onlyFork {
297
+ _setupMultiPlayer();
298
+ _toScoring();
299
+
300
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(4);
301
+ sc[0].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT();
302
+
303
+ _attestAndRatify(sc);
304
+
305
+ // All 5 winners should get approximately equal shares.
306
+ uint256[] memory payouts = new uint256[](5);
307
+ for (uint256 i; i < 5; i++) {
308
+ uint256 bb = _users[i].balance;
309
+ _cashOut(_users[i], 1, i + 1);
310
+ payouts[i] = _users[i].balance - bb;
311
+ assertGt(payouts[i], 0, "winner receives ETH");
312
+ }
313
+ for (uint256 i = 1; i < 5; i++) {
314
+ assertApproxEqRel(payouts[i], payouts[0], 0.001 ether, "payouts approx equal");
315
+ }
316
+
317
+ // Losers get 0 ETH.
318
+ for (uint256 i; i < 3; i++) {
319
+ uint256 bb = _users[5 + i].balance;
320
+ _cashOut(_users[5 + i], i + 2, 1);
321
+ assertEq(_users[5 + i].balance, bb, "loser gets 0 ETH");
322
+ }
323
+ }
324
+
325
+ // =========================================================================
326
+ // ADVERSARIAL: Overweight scorecard (120%) rejected
327
+ // =========================================================================
328
+
329
+ function test_fork_rejectsOverweightScorecard() external onlyFork {
330
+ _setupGame(4, 1 ether);
331
+ _toScoring();
332
+
333
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(4);
334
+ for (uint256 i; i < 4; i++) {
335
+ sc[i].cashOutWeight = (_nft.TOTAL_CASHOUT_WEIGHT() * 30) / 100; // 120% total
336
+ }
337
+
338
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
339
+ _attestAllFor(pid);
340
+ vm.expectRevert(DefifaHook.DefifaHook_InvalidCashoutWeights.selector);
341
+ _gov.ratifyScorecardFrom(_gameId, sc);
342
+ }
343
+
344
+ // =========================================================================
345
+ // ADVERSARIAL: Underweight scorecard (80%) rejected
346
+ // =========================================================================
347
+
348
+ function test_fork_rejectsUnderweightScorecard() external onlyFork {
349
+ _setupGame(4, 1 ether);
350
+ _toScoring();
351
+
352
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(4);
353
+ for (uint256 i; i < 4; i++) {
354
+ sc[i].cashOutWeight = (_nft.TOTAL_CASHOUT_WEIGHT() * 20) / 100; // 80% total
355
+ }
356
+
357
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
358
+ _attestAllFor(pid);
359
+ vm.expectRevert(DefifaHook.DefifaHook_InvalidCashoutWeights.selector);
360
+ _gov.ratifyScorecardFrom(_gameId, sc);
361
+ }
362
+
363
+ // =========================================================================
364
+ // ADVERSARIAL: Double attestation attempt
365
+ // =========================================================================
366
+
367
+ function test_fork_doubleAttestationReverts() external onlyFork {
368
+ _setupGame(4, 1 ether);
369
+ _toScoring();
370
+
371
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
372
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
373
+
374
+ vm.warp(_tsReader.timestamp() + _gov.attestationStartTimeOf(_gameId) + 1);
375
+
376
+ vm.prank(_users[0]);
377
+ _gov.attestToScorecardFrom(_gameId, pid);
378
+
379
+ // Second attestation should revert.
380
+ vm.prank(_users[0]);
381
+ vm.expectRevert(DefifaGovernor.DefifaGovernor_AlreadyAttested.selector);
382
+ _gov.attestToScorecardFrom(_gameId, pid);
383
+ }
384
+
385
+ // =========================================================================
386
+ // ADVERSARIAL: Duplicate scorecard submission reverts
387
+ // =========================================================================
388
+
389
+ function test_fork_duplicateScorecardReverts() external onlyFork {
390
+ _setupGame(4, 1 ether);
391
+ _toScoring();
392
+
393
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
394
+ _gov.submitScorecardFor(_gameId, sc);
395
+
396
+ vm.expectRevert(DefifaGovernor.DefifaGovernor_DuplicateScorecard.selector);
397
+ _gov.submitScorecardFor(_gameId, sc);
398
+ }
399
+
400
+ // =========================================================================
401
+ // ADVERSARIAL: Double ratification reverts
402
+ // =========================================================================
403
+
404
+ function test_fork_doubleRatificationReverts() external onlyFork {
405
+ _setupGame(4, 1 ether);
406
+ _toScoring();
407
+
408
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
409
+ _attestAndRatify(sc);
410
+
411
+ vm.expectRevert(DefifaGovernor.DefifaGovernor_AlreadyRatified.selector);
412
+ _gov.ratifyScorecardFrom(_gameId, sc);
413
+ }
414
+
415
+ // =========================================================================
416
+ // ADVERSARIAL: Cash out weights set twice reverts
417
+ // =========================================================================
418
+
419
+ function test_fork_cashOutWeightsAlreadySetReverts() external onlyFork {
420
+ _setupGame(4, 1 ether);
421
+ _toScoring();
422
+
423
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
424
+ _attestAndRatify(sc);
425
+ assertTrue(_nft.cashOutWeightIsSet(), "weights set");
426
+
427
+ // Trying to set weights again via governor owner (which is the governor itself) should revert
428
+ // because cashOutWeightIsSet is true. But since governor already ratified, the hook owner is the governor.
429
+ // The governor can't call setTierCashOutWeightsTo directly without going through ratification again.
430
+ // The ratification path will revert with AlreadyRatified.
431
+ vm.expectRevert(DefifaGovernor.DefifaGovernor_AlreadyRatified.selector);
432
+ _gov.ratifyScorecardFrom(_gameId, sc);
433
+ }
434
+
435
+ // =========================================================================
436
+ // ADVERSARIAL: Delegation blocked after MINT phase
437
+ // =========================================================================
438
+
439
+ function test_fork_delegationBlockedAfterMint() external onlyFork {
440
+ _setupGame(4, 1 ether);
441
+
442
+ // Advance to REFUND phase.
443
+ vm.warp(_tsReader.timestamp() + 1 days);
444
+
445
+ vm.prank(_users[0]);
446
+ vm.expectRevert(abi.encodeWithSignature("DefifaHook_DelegateChangesUnavailableInThisPhase()"));
447
+ _nft.setTierDelegateTo(address(1), 1);
448
+
449
+ // SCORING phase.
450
+ _toScoring();
451
+
452
+ vm.prank(_users[0]);
453
+ vm.expectRevert(abi.encodeWithSignature("DefifaHook_DelegateChangesUnavailableInThisPhase()"));
454
+ _nft.setTierDelegateTo(address(1), 1);
455
+ }
456
+
457
+ // =========================================================================
458
+ // ADVERSARIAL: Cash out before scorecard — reverts (nothing to claim)
459
+ // =========================================================================
460
+
461
+ function test_fork_cashOutBeforeScorecard_reverts() external onlyFork {
462
+ _setupGame(4, 1 ether);
463
+ _toScoring();
464
+
465
+ bytes memory meta = _cashOutMeta(1, 1);
466
+ vm.prank(_users[0]);
467
+ vm.expectRevert(DefifaHook.DefifaHook_NothingToClaim.selector);
468
+ JBMultiTerminal(address(jbMultiTerminal()))
469
+ .cashOutTokensOf({
470
+ holder: _users[0],
471
+ projectId: _pid,
472
+ cashOutCount: 0,
473
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
474
+ minTokensReclaimed: 0,
475
+ beneficiary: payable(_users[0]),
476
+ metadata: meta
477
+ });
478
+
479
+ // NFT not burned (revert rolled it back).
480
+ assertEq(_nft.balanceOf(_users[0]), 1, "NFT intact after revert");
481
+ }
482
+
483
+ // =========================================================================
484
+ // ADVERSARIAL: Non-holder tries to cash out someone else's NFT
485
+ // =========================================================================
486
+
487
+ function test_fork_nonHolderCashOutReverts() external onlyFork {
488
+ _setupGame(4, 1 ether);
489
+ _toScoring();
490
+
491
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
492
+ _attestAndRatify(sc);
493
+
494
+ // Attacker tries to cash out user[0]'s token.
495
+ address attacker = address(bytes20(keccak256("attacker")));
496
+ bytes memory meta = _cashOutMeta(1, 1);
497
+
498
+ vm.prank(attacker);
499
+ vm.expectRevert();
500
+ JBMultiTerminal(address(jbMultiTerminal()))
501
+ .cashOutTokensOf({
502
+ holder: attacker,
503
+ projectId: _pid,
504
+ cashOutCount: 0,
505
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
506
+ minTokensReclaimed: 0,
507
+ beneficiary: payable(attacker),
508
+ metadata: meta
509
+ });
510
+ }
511
+
512
+ // =========================================================================
513
+ // ADVERSARIAL: Scorecard with weight on unminted tier reverts
514
+ // =========================================================================
515
+
516
+ function test_fork_weightOnUnmintedTierReverts() external onlyFork {
517
+ // Launch 8-tier game but only mint 4 tiers.
518
+ _setupPartial(8, 4, 1 ether);
519
+ _toScoring();
520
+
521
+ // Try to give weight to tier 5 (unminted).
522
+ DefifaTierCashOutWeight[] memory sc = new DefifaTierCashOutWeight[](8);
523
+ for (uint256 i; i < 8; i++) {
524
+ sc[i].id = i + 1;
525
+ sc[i].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT() / 8;
526
+ }
527
+ // Fix rounding for last tier.
528
+ sc[7].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT() - ((_nft.TOTAL_CASHOUT_WEIGHT() / 8) * 7);
529
+
530
+ vm.expectRevert(DefifaGovernor.DefifaGovernor_UnownedProposedCashoutValue.selector);
531
+ _gov.submitScorecardFor(_gameId, sc);
532
+ }
533
+
534
+ // =========================================================================
535
+ // ADVERSARIAL: Scorecard submission outside SCORING phase
536
+ // =========================================================================
537
+
538
+ function test_fork_scorecardSubmitOutsideScoring_reverts() external onlyFork {
539
+ _setupGame(4, 1 ether);
540
+
541
+ // Still in MINT phase.
542
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.MINT));
543
+
544
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
545
+
546
+ vm.expectRevert(DefifaGovernor.DefifaGovernor_NotAllowed.selector);
547
+ _gov.submitScorecardFor(_gameId, sc);
548
+ }
549
+
550
+ // =========================================================================
551
+ // ADVERSARIAL: Attestation outside SCORING phase (during COMPLETE)
552
+ // =========================================================================
553
+
554
+ function test_fork_attestationAfterRatification_reverts() external onlyFork {
555
+ _setupGame(4, 1 ether);
556
+ _toScoring();
557
+
558
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
559
+ uint256 scorecardId = _gov.submitScorecardFor(_gameId, sc);
560
+ _attestAllFor(scorecardId);
561
+ _gov.ratifyScorecardFrom(_gameId, sc);
562
+
563
+ // Now in COMPLETE phase. Try to attest to another scorecard.
564
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.COMPLETE));
565
+
566
+ // Submit a different scorecard? Can't — already ratified.
567
+ DefifaTierCashOutWeight[] memory sc2 = _buildScorecard(4);
568
+ sc2[0].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT();
569
+ vm.expectRevert(DefifaGovernor.DefifaGovernor_AlreadyRatified.selector);
570
+ _gov.submitScorecardFor(_gameId, sc2);
571
+ }
572
+
573
+ // =========================================================================
574
+ // ADVERSARIAL: NFT transfer then try to double-vote
575
+ // =========================================================================
576
+
577
+ function test_fork_nftTransferDoesNotDoubleVote() external onlyFork {
578
+ _setupGame(4, 1 ether);
579
+
580
+ // user[0] transfers their NFT to user[1] (who already has tier 2)
581
+ uint256 tokenId = _generateTokenId(1, 1);
582
+ vm.prank(_users[0]);
583
+ _nft.transferFrom(_users[0], _users[1], tokenId);
584
+
585
+ _toScoring();
586
+
587
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
588
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
589
+
590
+ vm.warp(_tsReader.timestamp() + _gov.attestationStartTimeOf(_gameId) + 1);
591
+
592
+ // user[0] has no tokens now — attestation weight should be 0.
593
+ _gov.getAttestationWeight(_gameId, _users[0], uint48(_gov.attestationStartTimeOf(_gameId)));
594
+ // user[0]'s delegation was set at mint time. The checkpoint was recorded. But they transferred.
595
+ // Since attestation uses snapshot at submission time, user[0]'s weight depends on when they delegated.
596
+
597
+ // user[1] attests (has tokens from both tiers now).
598
+ vm.prank(_users[1]);
599
+ uint256 weight1 = _gov.attestToScorecardFrom(_gameId, pid);
600
+ assertGt(weight1, 0, "user1 has attestation weight");
601
+
602
+ // Other users attest.
603
+ for (uint256 i = 2; i < 4; i++) {
604
+ vm.prank(_users[i]);
605
+ _gov.attestToScorecardFrom(_gameId, pid);
606
+ }
607
+
608
+ // Advance past grace period.
609
+ vm.warp(_tsReader.timestamp() + _gov.attestationGracePeriodOf(_gameId) + 1);
610
+
611
+ // Should be able to ratify if quorum is met.
612
+ DefifaScorecardState state = _gov.stateOf(_gameId, pid);
613
+ assertTrue(
614
+ state == DefifaScorecardState.SUCCEEDED || state == DefifaScorecardState.ACTIVE,
615
+ "state should be SUCCEEDED or ACTIVE"
616
+ );
617
+ }
618
+
619
+ // =========================================================================
620
+ // ADVERSARIAL: Competing scorecards — only one can be ratified
621
+ // =========================================================================
622
+
623
+ function test_fork_competingScorecards_onlyOneRatified() external onlyFork {
624
+ _setupGame(4, 1 ether);
625
+ _toScoring();
626
+
627
+ // Scorecard A: even distribution.
628
+ DefifaTierCashOutWeight[] memory scA = _evenScorecard(4);
629
+ uint256 pidA = _gov.submitScorecardFor(_gameId, scA);
630
+
631
+ // Scorecard B: winner-take-all (different from A).
632
+ DefifaTierCashOutWeight[] memory scB = _buildScorecard(4);
633
+ scB[0].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT();
634
+ uint256 pidB = _gov.submitScorecardFor(_gameId, scB);
635
+
636
+ assertTrue(pidA != pidB, "different scorecards have different IDs");
637
+
638
+ // Attest and ratify scorecard A.
639
+ _attestAllFor(pidA);
640
+ _gov.ratifyScorecardFrom(_gameId, scA);
641
+
642
+ // Scorecard B should now be DEFEATED.
643
+ assertEq(uint256(_gov.stateOf(_gameId, pidB)), uint256(DefifaScorecardState.DEFEATED));
644
+ }
645
+
646
+ // =========================================================================
647
+ // ADVERSARIAL: fulfillCommitmentsOf double-call (idempotent)
648
+ // =========================================================================
649
+
650
+ function test_fork_doubleFulfillment_idempotent() external onlyFork {
651
+ _setupGame(4, 1 ether);
652
+ _toScoring();
653
+
654
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
655
+ _attestAndRatify(sc);
656
+
657
+ uint256 fulfilled = deployer.fulfilledCommitmentsOf(_pid);
658
+ assertGt(fulfilled, 0, "commitments fulfilled");
659
+
660
+ // Second call should be a no-op.
661
+ deployer.fulfillCommitmentsOf(_pid);
662
+ assertEq(deployer.fulfilledCommitmentsOf(_pid), fulfilled, "no change on second call");
663
+ }
664
+
665
+ // =========================================================================
666
+ // ADVERSARIAL: fulfillCommitmentsOf before ratification reverts
667
+ // =========================================================================
668
+
669
+ function test_fork_fulfillBeforeRatification_reverts() external onlyFork {
670
+ _setupGame(4, 1 ether);
671
+ _toScoring();
672
+
673
+ vm.expectRevert(DefifaDeployer.DefifaDeployer_CantFulfillYet.selector);
674
+ deployer.fulfillCommitmentsOf(_pid);
675
+ }
676
+
677
+ // =========================================================================
678
+ // GOVERNANCE: Quorum calculation with partial minting
679
+ // =========================================================================
680
+
681
+ function test_fork_quorum_partialMinting() external onlyFork {
682
+ _setupPartial(10, 6, 1 ether);
683
+ uint256 expected = (6 * _gov.MAX_ATTESTATION_POWER_TIER()) / 2;
684
+ assertEq(_gov.quorum(_gameId), expected, "quorum = 50% of minted tiers");
685
+ }
686
+
687
+ // =========================================================================
688
+ // GOVERNANCE: Single-tier game (minimum viable game)
689
+ // =========================================================================
690
+
691
+ function test_fork_singleTierGame() external onlyFork {
692
+ DefifaLaunchProjectData memory d = _launchData(1, 1 ether);
693
+ (_pid, _nft, _gov) = _launch(d);
694
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
695
+
696
+ _users = new address[](1);
697
+ _users[0] = _addr(0);
698
+ _mint(_users[0], 1, 1 ether);
699
+ _delegateSelf(_users[0], 1);
700
+ vm.warp(_tsReader.timestamp() + 1);
701
+
702
+ _toScoring();
703
+
704
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(1);
705
+ sc[0].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT();
706
+
707
+ _attestAndRatify(sc);
708
+
709
+ // Cash out the single player.
710
+ uint256 bb = _users[0].balance;
711
+ _cashOut(_users[0], 1, 1);
712
+ uint256 received = _users[0].balance - bb;
713
+ assertGt(received, 0, "single player receives ETH");
714
+ }
715
+
716
+ // =========================================================================
717
+ // NO CONTEST: minParticipation threshold triggers NO_CONTEST
718
+ // =========================================================================
719
+
720
+ function test_fork_noContest_minParticipation() external onlyFork {
721
+ DefifaLaunchProjectData memory d = _launchDataWith(4, 1 ether, 5 ether, 0);
722
+ (_pid, _nft, _gov) = _launch(d);
723
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
724
+
725
+ // Mint only 1 ETH < 5 ETH threshold.
726
+ _users = new address[](1);
727
+ _users[0] = _addr(0);
728
+ _mint(_users[0], 1, 1 ether);
729
+
730
+ _toScoring();
731
+
732
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.NO_CONTEST));
733
+ }
734
+
735
+ // =========================================================================
736
+ // NO CONTEST: scorecardTimeout triggers NO_CONTEST
737
+ // =========================================================================
738
+
739
+ function test_fork_noContest_scorecardTimeout() external onlyFork {
740
+ DefifaLaunchProjectData memory d = _launchDataWith(4, 1 ether, 0, 7 days);
741
+ (_pid, _nft, _gov) = _launch(d);
742
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
743
+
744
+ _users = new address[](4);
745
+ for (uint256 i; i < 4; i++) {
746
+ _users[i] = _addr(i);
747
+ _mint(_users[i], i + 1, 1 ether);
748
+ _delegateSelf(_users[i], i + 1);
749
+ vm.warp(_tsReader.timestamp() + 1);
750
+ }
751
+
752
+ _toScoring();
753
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.SCORING));
754
+
755
+ // Warp past timeout.
756
+ vm.warp(d.start + 7 days + 1);
757
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.NO_CONTEST));
758
+ }
759
+
760
+ // =========================================================================
761
+ // NO CONTEST: triggerNoContestFor + full refund
762
+ // =========================================================================
763
+
764
+ function test_fork_noContest_triggerAndRefund() external onlyFork {
765
+ DefifaLaunchProjectData memory d = _launchDataWith(4, 2 ether, 0, 7 days);
766
+ (_pid, _nft, _gov) = _launch(d);
767
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
768
+
769
+ _users = new address[](4);
770
+ for (uint256 i; i < 4; i++) {
771
+ _users[i] = _addr(i);
772
+ _mint(_users[i], i + 1, 2 ether);
773
+ vm.warp(_tsReader.timestamp() + 1);
774
+ }
775
+
776
+ vm.warp(d.start + 7 days + 1);
777
+ deployer.triggerNoContestFor(_pid);
778
+
779
+ // All users refund.
780
+ uint256 totalRefunded;
781
+ for (uint256 i; i < 4; i++) {
782
+ uint256 bb = _users[i].balance;
783
+ _refund(_users[i], i + 1);
784
+ uint256 received = _users[i].balance - bb;
785
+ assertEq(received, 2 ether, "exact refund");
786
+ totalRefunded += received;
787
+ }
788
+ assertEq(totalRefunded, 8 ether, "total refunded = total minted");
789
+ }
790
+
791
+ // =========================================================================
792
+ // NO CONTEST: Double trigger reverts
793
+ // =========================================================================
794
+
795
+ function test_fork_noContest_doubleTriggerReverts() external onlyFork {
796
+ DefifaLaunchProjectData memory d = _launchDataWith(4, 1 ether, 0, 7 days);
797
+ (_pid, _nft, _gov) = _launch(d);
798
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
799
+
800
+ _users = new address[](1);
801
+ _users[0] = _addr(0);
802
+ _mint(_users[0], 1, 1 ether);
803
+
804
+ vm.warp(d.start + 7 days + 1);
805
+
806
+ deployer.triggerNoContestFor(_pid);
807
+
808
+ vm.expectRevert(DefifaDeployer.DefifaDeployer_NoContestAlreadyTriggered.selector);
809
+ deployer.triggerNoContestFor(_pid);
810
+ }
811
+
812
+ // =========================================================================
813
+ // NO CONTEST: triggerNoContest outside NO_CONTEST phase reverts
814
+ // =========================================================================
815
+
816
+ function test_fork_noContest_triggerWhenScoring_reverts() external onlyFork {
817
+ _setupGame(4, 1 ether);
818
+ _toScoring();
819
+
820
+ vm.expectRevert(DefifaDeployer.DefifaDeployer_NotNoContest.selector);
821
+ deployer.triggerNoContestFor(_pid);
822
+ }
823
+
824
+ // =========================================================================
825
+ // NO CONTEST: Ratified scorecard prevents NO_CONTEST forever
826
+ // =========================================================================
827
+
828
+ function test_fork_ratifiedPreventsNoContest() external onlyFork {
829
+ DefifaLaunchProjectData memory d = _launchDataWith(4, 1 ether, 0, 7 days);
830
+ (_pid, _nft, _gov) = _launch(d);
831
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
832
+
833
+ _users = new address[](4);
834
+ for (uint256 i; i < 4; i++) {
835
+ _users[i] = _addr(i);
836
+ _mint(_users[i], i + 1, 1 ether);
837
+ _delegateSelf(_users[i], i + 1);
838
+ vm.warp(_tsReader.timestamp() + 1);
839
+ }
840
+
841
+ _toScoring();
842
+ _attestAndRatify(_evenScorecard(4));
843
+
844
+ // Even after timeout, should remain COMPLETE.
845
+ vm.warp(d.start + 7 days + 1);
846
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.COMPLETE));
847
+
848
+ vm.expectRevert(DefifaDeployer.DefifaDeployer_NotNoContest.selector);
849
+ deployer.triggerNoContestFor(_pid);
850
+ }
851
+
852
+ // =========================================================================
853
+ // FEE ACCOUNTING: Default splits (no user splits)
854
+ // =========================================================================
855
+
856
+ function test_fork_feeAccounting_defaultSplits() external onlyFork {
857
+ _setupGame(4, 1 ether);
858
+
859
+ uint256 potBefore = _balance();
860
+ assertEq(potBefore, 4 ether);
861
+
862
+ // Expected fee: 7.5% (2.5% NANA + 5% DEFIFA).
863
+ uint256 expectedFee = (potBefore * 75_000_000) / JBConstants.SPLITS_TOTAL_PERCENT;
864
+ uint256 expectedSurplus = potBefore - expectedFee;
865
+
866
+ _toScoring();
867
+ _attestAndRatify(_evenScorecard(4));
868
+
869
+ uint256 potAfter = _balance();
870
+ assertEq(potAfter, expectedSurplus, "surplus after fees");
871
+ assertEq(deployer.fulfilledCommitmentsOf(_pid), expectedFee, "fulfilled = fee");
872
+ }
873
+
874
+ // =========================================================================
875
+ // FEE ACCOUNTING: fee + surplus = original pot (zero rounding loss)
876
+ // =========================================================================
877
+
878
+ function test_fork_feeAccounting_noRoundingLoss() external onlyFork {
879
+ _setupGame(4, 1 ether);
880
+
881
+ uint256 potBefore = _balance();
882
+
883
+ _toScoring();
884
+ _attestAndRatify(_evenScorecard(4));
885
+
886
+ uint256 potAfter = _balance();
887
+ uint256 fee = deployer.fulfilledCommitmentsOf(_pid);
888
+ assertEq(fee + potAfter, potBefore, "fee + surplus = pot exactly");
889
+ }
890
+
891
+ // =========================================================================
892
+ // FEE ACCOUNTING: With user-provided custom splits
893
+ // =========================================================================
894
+
895
+ function test_fork_feeAccounting_withUserSplits() external onlyFork {
896
+ JBSplit[] memory customSplits = new JBSplit[](1);
897
+ address charity = address(bytes20(keccak256("charity")));
898
+ customSplits[0] = JBSplit({
899
+ preferAddToBalance: false,
900
+ percent: JBConstants.SPLITS_TOTAL_PERCENT / 10, // 10%
901
+ projectId: 0,
902
+ beneficiary: payable(charity),
903
+ lockedUntil: 0,
904
+ hook: IJBSplitHook(address(0))
905
+ });
906
+
907
+ DefifaLaunchProjectData memory d = _launchDataWithSplits(4, 1 ether, customSplits);
908
+ (_pid, _nft, _gov) = _launch(d);
909
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
910
+
911
+ _users = new address[](4);
912
+ for (uint256 i; i < 4; i++) {
913
+ _users[i] = _addr(i);
914
+ _mint(_users[i], i + 1, 1 ether);
915
+ _delegateSelf(_users[i], i + 1);
916
+ vm.warp(_tsReader.timestamp() + 1);
917
+ }
918
+
919
+ uint256 potBefore = _balance();
920
+ // totalAbsolutePercent = 25M + 50M + 100M = 175M (17.5%).
921
+ // expectedFee = (potBefore * 175_000_000) / JBConstants.SPLITS_TOTAL_PERCENT;
922
+
923
+ _toScoring();
924
+ _attestAndRatify(_evenScorecard(4));
925
+
926
+ uint256 fee = deployer.fulfilledCommitmentsOf(_pid);
927
+ assertEq(fee + _balance(), potBefore, "no rounding loss with user splits");
928
+ assertTrue(charity.balance > 0, "charity received funds");
929
+ }
930
+
931
+ // =========================================================================
932
+ // FEE TOKENS: Reserved minters get proportional $DEFIFA/$NANA
933
+ // =========================================================================
934
+
935
+ function test_fork_reservedMintersGetFeeTokens() external onlyFork {
936
+ address reserveAddr = address(bytes20(keccak256("reserveBeneficiary")));
937
+
938
+ DefifaTierParams[] memory tp = new DefifaTierParams[](2);
939
+ for (uint256 i; i < 2; i++) {
940
+ tp[i] = DefifaTierParams({
941
+ reservedRate: 1,
942
+ reservedTokenBeneficiary: reserveAddr,
943
+ encodedIPFSUri: bytes32(0),
944
+ shouldUseReservedTokenBeneficiaryAsDefault: false,
945
+ name: "DEFIFA"
946
+ });
947
+ }
948
+ DefifaLaunchProjectData memory d = DefifaLaunchProjectData({
949
+ name: "DEFIFA",
950
+ projectUri: "",
951
+ contractUri: "",
952
+ baseUri: "",
953
+ token: JBAccountingContext({token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: JBCurrencyIds.ETH}),
954
+ mintPeriodDuration: 1 days,
955
+ start: uint48(block.timestamp + 3 days),
956
+ refundPeriodDuration: 1 days,
957
+ store: new JB721TiersHookStore(),
958
+ splits: new JBSplit[](0),
959
+ attestationStartTime: 0,
960
+ attestationGracePeriod: 100_381,
961
+ defaultAttestationDelegate: address(0),
962
+ tierPrice: uint104(1 ether),
963
+ tiers: tp,
964
+ defaultTokenUriResolver: IJB721TokenUriResolver(address(0)),
965
+ terminal: jbMultiTerminal(),
966
+ minParticipation: 0,
967
+ scorecardTimeout: 0
968
+ });
969
+ (_pid, _nft, _gov) = _launch(d);
970
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
971
+
972
+ _users = new address[](2);
973
+ _users[0] = _addr(0);
974
+ _users[1] = _addr(1);
975
+ _mint(_users[0], 1, 1 ether);
976
+ _delegateSelf(_users[0], 1);
977
+ vm.warp(_tsReader.timestamp() + 1);
978
+ _mint(_users[1], 2, 1 ether);
979
+ _delegateSelf(_users[1], 2);
980
+ vm.warp(_tsReader.timestamp() + 1);
981
+
982
+ _toScoring();
983
+
984
+ // Mint reserved tokens.
985
+ JB721TiersMintReservesConfig[] memory reserveConfigs = new JB721TiersMintReservesConfig[](2);
986
+ reserveConfigs[0] = JB721TiersMintReservesConfig({tierId: 1, count: 1});
987
+ reserveConfigs[1] = JB721TiersMintReservesConfig({tierId: 2, count: 1});
988
+ _nft.mintReservesFor(reserveConfigs);
989
+
990
+ assertEq(_nft.balanceOf(reserveAddr), 2, "reserve beneficiary holds 2 NFTs");
991
+
992
+ // Seed fee tokens into the hook.
993
+ deal(address(IERC20(_defifaProjectTokenAccount)), address(_nft), 1000 ether);
994
+ deal(address(IERC20(_protocolFeeProjectTokenAccount)), address(_nft), 500 ether);
995
+
996
+ // Scorecard: equal weight.
997
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(2);
998
+ sc[0].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT() / 2;
999
+ sc[1].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT() / 2;
1000
+
1001
+ address[] memory allUsers = new address[](3);
1002
+ allUsers[0] = _users[0];
1003
+ allUsers[1] = _users[1];
1004
+ allUsers[2] = reserveAddr;
1005
+
1006
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
1007
+ vm.warp(_tsReader.timestamp() + _gov.attestationStartTimeOf(_gameId) + 1);
1008
+ for (uint256 i; i < allUsers.length; i++) {
1009
+ vm.prank(allUsers[i]);
1010
+ _gov.attestToScorecardFrom(_gameId, pid);
1011
+ }
1012
+ vm.warp(_tsReader.timestamp() + _gov.attestationGracePeriodOf(_gameId) + 1);
1013
+ _gov.ratifyScorecardFrom(_gameId, sc);
1014
+ vm.warp(_tsReader.timestamp() + 1);
1015
+
1016
+ // Cash out paid minters.
1017
+ _cashOut(_users[0], 1, 1);
1018
+ _cashOut(_users[1], 2, 1);
1019
+
1020
+ uint256 user0Defifa = IERC20(_defifaProjectTokenAccount).balanceOf(_users[0]);
1021
+ assertGt(user0Defifa, 0, "paid minter got DEFIFA tokens");
1022
+
1023
+ // Cash out reserved minter's tokens.
1024
+ bytes memory meta1 = _cashOutMeta(1, 2);
1025
+ vm.prank(reserveAddr);
1026
+ JBMultiTerminal(address(jbMultiTerminal()))
1027
+ .cashOutTokensOf({
1028
+ holder: reserveAddr,
1029
+ projectId: _pid,
1030
+ cashOutCount: 0,
1031
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
1032
+ minTokensReclaimed: 0,
1033
+ beneficiary: payable(reserveAddr),
1034
+ metadata: meta1
1035
+ });
1036
+
1037
+ bytes memory meta2 = _cashOutMeta(2, 2);
1038
+ vm.prank(reserveAddr);
1039
+ JBMultiTerminal(address(jbMultiTerminal()))
1040
+ .cashOutTokensOf({
1041
+ holder: reserveAddr,
1042
+ projectId: _pid,
1043
+ cashOutCount: 0,
1044
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
1045
+ minTokensReclaimed: 0,
1046
+ beneficiary: payable(reserveAddr),
1047
+ metadata: meta2
1048
+ });
1049
+
1050
+ uint256 reserveDefifa = IERC20(_defifaProjectTokenAccount).balanceOf(reserveAddr);
1051
+ assertGt(reserveDefifa, 0, "reserved minter got DEFIFA tokens");
1052
+
1053
+ // All fee tokens distributed.
1054
+ assertEq(IERC20(_defifaProjectTokenAccount).balanceOf(address(_nft)), 0, "no DEFIFA left");
1055
+ assertEq(IERC20(_protocolFeeProjectTokenAccount).balanceOf(address(_nft)), 0, "no NANA left");
1056
+ }
1057
+
1058
+ // =========================================================================
1059
+ // CASH OUT ORDERING: First vs last to exit — fair distribution
1060
+ // =========================================================================
1061
+
1062
+ function test_fork_cashOutOrdering_fairAcrossExitOrder() external onlyFork {
1063
+ _setupGame(4, 10 ether);
1064
+ _toScoring();
1065
+
1066
+ _attestAndRatify(_evenScorecard(4));
1067
+
1068
+ // Cash out users in reverse order and forward order — results should be similar.
1069
+ uint256[] memory received = new uint256[](4);
1070
+ for (uint256 i; i < 4; i++) {
1071
+ uint256 bb = _users[i].balance;
1072
+ _cashOut(_users[i], i + 1, 1);
1073
+ received[i] = _users[i].balance - bb;
1074
+ }
1075
+
1076
+ // With equal weights, all should receive approximately equal amounts.
1077
+ for (uint256 i = 1; i < 4; i++) {
1078
+ assertApproxEqRel(received[i], received[0], 0.001 ether, "equal-weight payouts are equal");
1079
+ }
1080
+ }
1081
+
1082
+ // =========================================================================
1083
+ // GAME POT REPORTING: currentGamePotOf accuracy
1084
+ // =========================================================================
1085
+
1086
+ function test_fork_gamePotReporting() external onlyFork {
1087
+ _setupGame(4, 1 ether);
1088
+ _toScoring();
1089
+
1090
+ (uint256 potExcluding,,) = deployer.currentGamePotOf(_pid, false);
1091
+ (uint256 potIncluding,,) = deployer.currentGamePotOf(_pid, true);
1092
+ assertEq(potExcluding, 4 ether, "pot excluding = 4 ETH");
1093
+ assertEq(potIncluding, 4 ether, "pot including = 4 ETH (no fulfillment yet)");
1094
+
1095
+ _attestAndRatify(_evenScorecard(4));
1096
+
1097
+ uint256 fee = deployer.fulfilledCommitmentsOf(_pid);
1098
+ (potExcluding,,) = deployer.currentGamePotOf(_pid, false);
1099
+ (potIncluding,,) = deployer.currentGamePotOf(_pid, true);
1100
+ assertEq(potExcluding, 4 ether - fee, "pot excluding = surplus");
1101
+ assertEq(potIncluding, 4 ether, "pot including = original pot");
1102
+ }
1103
+
1104
+ // =========================================================================
1105
+ // PHASE TRANSITIONS: Correct sequence
1106
+ // =========================================================================
1107
+
1108
+ function test_fork_phaseTransitions_correctSequence() external onlyFork {
1109
+ DefifaLaunchProjectData memory d = _launchData(4, 1 ether);
1110
+ (_pid, _nft, _gov) = _launch(d);
1111
+
1112
+ // COUNTDOWN.
1113
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.COUNTDOWN));
1114
+
1115
+ // MINT.
1116
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
1117
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.MINT));
1118
+
1119
+ // REFUND.
1120
+ vm.warp(d.start - d.refundPeriodDuration);
1121
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.REFUND));
1122
+
1123
+ // SCORING.
1124
+ vm.warp(d.start);
1125
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.SCORING));
1126
+ }
1127
+
1128
+ // =========================================================================
1129
+ // GAME TIMES: timesFor view returns correct values
1130
+ // =========================================================================
1131
+
1132
+ function test_fork_timesFor() external onlyFork {
1133
+ DefifaLaunchProjectData memory d = _launchData(4, 1 ether);
1134
+ (_pid, _nft, _gov) = _launch(d);
1135
+
1136
+ (uint48 start, uint24 mintDur, uint24 refundDur) = deployer.timesFor(_pid);
1137
+ assertEq(start, d.start, "start matches");
1138
+ assertEq(mintDur, d.mintPeriodDuration, "mint duration matches");
1139
+ assertEq(refundDur, d.refundPeriodDuration, "refund duration matches");
1140
+ }
1141
+
1142
+ // =========================================================================
1143
+ // EDGE: NFT transfer → new owner cashes out, firstOwnerOf preserved
1144
+ // =========================================================================
1145
+
1146
+ function test_fork_nftTransfer_newOwnerCashesOut() external onlyFork {
1147
+ _setupGame(4, 1 ether);
1148
+ _toScoring();
1149
+
1150
+ // Give tier 1 all the weight.
1151
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(4);
1152
+ sc[0].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT();
1153
+ _attestAndRatify(sc);
1154
+
1155
+ address original = _users[0];
1156
+ address recipient = _addr(999);
1157
+
1158
+ uint256 tokenId = _generateTokenId(1, 1);
1159
+
1160
+ // Verify firstOwnerOf before transfer.
1161
+ assertEq(_nft.firstOwnerOf(tokenId), original, "firstOwner = minter before transfer");
1162
+
1163
+ // Transfer NFT.
1164
+ vm.prank(original);
1165
+ _nft.transferFrom(original, recipient, tokenId);
1166
+
1167
+ // firstOwnerOf should still be the original minter.
1168
+ assertEq(_nft.firstOwnerOf(tokenId), original, "firstOwner = minter after transfer");
1169
+
1170
+ // New owner cashes out.
1171
+ uint256 bb = recipient.balance;
1172
+ bytes memory meta = _cashOutMeta(1, 1);
1173
+ vm.prank(recipient);
1174
+ JBMultiTerminal(address(jbMultiTerminal()))
1175
+ .cashOutTokensOf({
1176
+ holder: recipient,
1177
+ projectId: _pid,
1178
+ cashOutCount: 0,
1179
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
1180
+ minTokensReclaimed: 0,
1181
+ beneficiary: payable(recipient),
1182
+ metadata: meta
1183
+ });
1184
+ assertGt(recipient.balance - bb, 0, "new owner received ETH");
1185
+ }
1186
+
1187
+ // =========================================================================
1188
+ // EDGE: Intra-tier fairness — 5 holders same tier, sequential cash outs
1189
+ // =========================================================================
1190
+
1191
+ function test_fork_intraTierFairness_5holders() external onlyFork {
1192
+ DefifaLaunchProjectData memory d = _launchData(2, 1 ether);
1193
+ (_pid, _nft, _gov) = _launch(d);
1194
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
1195
+
1196
+ // 5 people mint tier 1, 1 person mints tier 2.
1197
+ _users = new address[](6);
1198
+ for (uint256 i; i < 5; i++) {
1199
+ _users[i] = _addr(i);
1200
+ _mint(_users[i], 1, 1 ether);
1201
+ _delegateSelf(_users[i], 1);
1202
+ vm.warp(_tsReader.timestamp() + 1);
1203
+ }
1204
+ _users[5] = _addr(5);
1205
+ _mint(_users[5], 2, 1 ether);
1206
+ _delegateSelf(_users[5], 2);
1207
+ vm.warp(_tsReader.timestamp() + 1);
1208
+
1209
+ _toScoring();
1210
+
1211
+ // All weight to tier 1.
1212
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(2);
1213
+ sc[0].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT();
1214
+ _attestAndRatify(sc);
1215
+
1216
+ // Each of the 5 holders cashes out sequentially.
1217
+ // Due to integer division (weight / 5), each should get the same amount.
1218
+ uint256[] memory received = new uint256[](5);
1219
+ for (uint256 i; i < 5; i++) {
1220
+ uint256 bb = _users[i].balance;
1221
+ _cashOut(_users[i], 1, i + 1);
1222
+ received[i] = _users[i].balance - bb;
1223
+ }
1224
+
1225
+ // All 5 should receive the same amount (integer division means equal shares).
1226
+ for (uint256 i = 1; i < 5; i++) {
1227
+ assertEq(received[i], received[0], "all tier-1 holders get equal cash out");
1228
+ }
1229
+ }
1230
+
1231
+ // =========================================================================
1232
+ // EDGE: Multi-token cash out — burn 3 NFTs from same tier in one tx
1233
+ // =========================================================================
1234
+
1235
+ function test_fork_multiTokenCashOut_sameTier() external onlyFork {
1236
+ DefifaLaunchProjectData memory d = _launchData(2, 1 ether);
1237
+ (_pid, _nft, _gov) = _launch(d);
1238
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
1239
+
1240
+ address user = _addr(0);
1241
+ // Mint 3 tokens in tier 1.
1242
+ for (uint256 i; i < 3; i++) {
1243
+ _mint(user, 1, 1 ether);
1244
+ vm.warp(_tsReader.timestamp() + 1);
1245
+ }
1246
+ // Need someone in tier 2 for delegation/quorum.
1247
+ address user2 = _addr(1);
1248
+ _mint(user2, 2, 1 ether);
1249
+ _delegateSelf(user2, 2);
1250
+ vm.warp(_tsReader.timestamp() + 1);
1251
+
1252
+ // Delegate tier 1.
1253
+ DefifaDelegation[] memory dd = new DefifaDelegation[](1);
1254
+ dd[0] = DefifaDelegation({delegatee: user, tierId: 1});
1255
+ vm.prank(user);
1256
+ _nft.setTierDelegatesTo(dd);
1257
+ vm.warp(_tsReader.timestamp() + 1);
1258
+
1259
+ _users = new address[](2);
1260
+ _users[0] = user;
1261
+ _users[1] = user2;
1262
+
1263
+ _toScoring();
1264
+
1265
+ // All weight to tier 1.
1266
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(2);
1267
+ sc[0].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT();
1268
+ _attestAndRatify(sc);
1269
+
1270
+ // Build multi-token cash out metadata (3 tokens at once).
1271
+ uint256[] memory tokenIds = new uint256[](3);
1272
+ tokenIds[0] = _generateTokenId(1, 1);
1273
+ tokenIds[1] = _generateTokenId(1, 2);
1274
+ tokenIds[2] = _generateTokenId(1, 3);
1275
+ bytes[] memory data = new bytes[](1);
1276
+ data[0] = abi.encode(tokenIds);
1277
+ bytes4[] memory ids = new bytes4[](1);
1278
+ ids[0] = metadataHelper().getId("cashOut", address(hook));
1279
+ bytes memory meta = metadataHelper().createMetadata(ids, data);
1280
+
1281
+ uint256 bb = user.balance;
1282
+ vm.prank(user);
1283
+ JBMultiTerminal(address(jbMultiTerminal()))
1284
+ .cashOutTokensOf({
1285
+ holder: user,
1286
+ projectId: _pid,
1287
+ cashOutCount: 0,
1288
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
1289
+ minTokensReclaimed: 0,
1290
+ beneficiary: payable(user),
1291
+ metadata: meta
1292
+ });
1293
+ assertGt(user.balance - bb, 0, "batch cash out returned ETH");
1294
+ assertEq(_nft.balanceOf(user), 0, "all 3 NFTs burned");
1295
+ }
1296
+
1297
+ // =========================================================================
1298
+ // EDGE: Cross-tier cash out — burn tokens from different tiers in one tx
1299
+ // =========================================================================
1300
+
1301
+ function test_fork_crossTierCashOut_singleTx() external onlyFork {
1302
+ _setupGame(4, 1 ether);
1303
+ _toScoring();
1304
+
1305
+ _attestAndRatify(_evenScorecard(4));
1306
+
1307
+ // User 0 holds tier 1, user 1 holds tier 2. Transfer tier 2 to user 0.
1308
+ vm.prank(_users[1]);
1309
+ _nft.transferFrom(_users[1], _users[0], _generateTokenId(2, 1));
1310
+
1311
+ // User 0 now holds tier 1 token 1 and tier 2 token 1. Cash out both in one tx.
1312
+ uint256[] memory tokenIds = new uint256[](2);
1313
+ tokenIds[0] = _generateTokenId(1, 1);
1314
+ tokenIds[1] = _generateTokenId(2, 1);
1315
+ bytes[] memory data = new bytes[](1);
1316
+ data[0] = abi.encode(tokenIds);
1317
+ bytes4[] memory ids = new bytes4[](1);
1318
+ ids[0] = metadataHelper().getId("cashOut", address(hook));
1319
+ bytes memory meta = metadataHelper().createMetadata(ids, data);
1320
+
1321
+ uint256 bb = _users[0].balance;
1322
+ vm.prank(_users[0]);
1323
+ JBMultiTerminal(address(jbMultiTerminal()))
1324
+ .cashOutTokensOf({
1325
+ holder: _users[0],
1326
+ projectId: _pid,
1327
+ cashOutCount: 0,
1328
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
1329
+ minTokensReclaimed: 0,
1330
+ beneficiary: payable(_users[0]),
1331
+ metadata: meta
1332
+ });
1333
+ assertGt(_users[0].balance - bb, 0, "cross-tier batch cash out returned ETH");
1334
+ }
1335
+
1336
+ // =========================================================================
1337
+ // EDGE: Zero-power attestation — non-holder attests with 0 weight
1338
+ // =========================================================================
1339
+
1340
+ function test_fork_zeroPowerAttestation() external onlyFork {
1341
+ _setupGame(4, 1 ether);
1342
+ _toScoring();
1343
+
1344
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
1345
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
1346
+
1347
+ // Warp to attestation period.
1348
+ uint256 attestStart = _gov.attestationStartTimeOf(_gameId);
1349
+ uint256 current = _tsReader.timestamp();
1350
+ vm.warp((attestStart > current ? attestStart : current) + 1);
1351
+
1352
+ // Non-holder attests — should succeed but add 0 weight.
1353
+ address stranger = _addr(999);
1354
+ vm.prank(stranger);
1355
+ uint256 weight = _gov.attestToScorecardFrom(_gameId, pid);
1356
+ assertEq(weight, 0, "non-holder has 0 attestation power");
1357
+
1358
+ // But they can't attest again.
1359
+ vm.prank(stranger);
1360
+ vm.expectRevert(DefifaGovernor.DefifaGovernor_AlreadyAttested.selector);
1361
+ _gov.attestToScorecardFrom(_gameId, pid);
1362
+ }
1363
+
1364
+ // =========================================================================
1365
+ // EDGE: Delegation to address(0) via setTierDelegateTo (no validation)
1366
+ // =========================================================================
1367
+
1368
+ function test_fork_delegateToZero_viaSetTierDelegateTo() external onlyFork {
1369
+ _setupGame(4, 1 ether);
1370
+
1371
+ // setTierDelegateTo allows address(0) — no check (unlike setTierDelegatesTo which reverts).
1372
+ vm.prank(_users[0]);
1373
+ _nft.setTierDelegateTo(address(0), 1);
1374
+
1375
+ // Verify setTierDelegatesTo would revert for address(0).
1376
+ DefifaDelegation[] memory dd = new DefifaDelegation[](1);
1377
+ dd[0] = DefifaDelegation({delegatee: address(0), tierId: 1});
1378
+ vm.prank(_users[0]);
1379
+ vm.expectRevert(DefifaHook.DefifaHook_DelegateAddressZero.selector);
1380
+ _nft.setTierDelegatesTo(dd);
1381
+
1382
+ _toScoring();
1383
+
1384
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
1385
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
1386
+
1387
+ uint256 attestStart = _gov.attestationStartTimeOf(_gameId);
1388
+ uint256 current = _tsReader.timestamp();
1389
+ vm.warp((attestStart > current ? attestStart : current) + 1);
1390
+
1391
+ // After delegating to address(0), user's attestation power is reduced
1392
+ // (delegate checkpoint partially drained). Verify it's less than a normal holder.
1393
+ vm.prank(_users[0]);
1394
+ uint256 w0 = _gov.attestToScorecardFrom(_gameId, pid);
1395
+ vm.prank(_users[1]);
1396
+ uint256 w1 = _gov.attestToScorecardFrom(_gameId, pid);
1397
+ assertTrue(w0 < w1, "address(0) delegate has less power than normal delegate");
1398
+ }
1399
+
1400
+ // =========================================================================
1401
+ // EDGE: minParticipation boundary — balance == minParticipation → NO_CONTEST
1402
+ // =========================================================================
1403
+
1404
+ function test_fork_minParticipation_exactBoundary_meets() external onlyFork {
1405
+ // balance == minParticipation: check uses `<`, so 4 < 4 = false → SCORING.
1406
+ DefifaLaunchProjectData memory d = _launchDataWith(4, 1 ether, 4 ether, 0);
1407
+ (_pid, _nft, _gov) = _launch(d);
1408
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
1409
+
1410
+ _users = new address[](4);
1411
+ for (uint256 i; i < 4; i++) {
1412
+ _users[i] = _addr(i);
1413
+ _mint(_users[i], i + 1, 1 ether);
1414
+ _delegateSelf(_users[i], i + 1);
1415
+ vm.warp(_tsReader.timestamp() + 1);
1416
+ }
1417
+
1418
+ _toScoring();
1419
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.SCORING));
1420
+ }
1421
+
1422
+ function test_fork_minParticipation_belowThreshold() external onlyFork {
1423
+ // balance < minParticipation → NO_CONTEST.
1424
+ DefifaLaunchProjectData memory d = _launchDataWith(4, 1 ether, 4 ether + 1, 0);
1425
+ (_pid, _nft, _gov) = _launch(d);
1426
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
1427
+
1428
+ _users = new address[](4);
1429
+ for (uint256 i; i < 4; i++) {
1430
+ _users[i] = _addr(i);
1431
+ _mint(_users[i], i + 1, 1 ether);
1432
+ vm.warp(_tsReader.timestamp() + 1);
1433
+ }
1434
+
1435
+ _toScoring();
1436
+ // balance = 4 ether, minParticipation = 4 ether + 1 wei → 4e18 < 4e18+1 → NO_CONTEST.
1437
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.NO_CONTEST));
1438
+ }
1439
+
1440
+ // =========================================================================
1441
+ // EDGE: Cash out during SCORING (before weights set) → NothingToClaim
1442
+ // =========================================================================
1443
+
1444
+ function test_fork_cashOutDuringScoring_reverts() external onlyFork {
1445
+ _setupGame(4, 1 ether);
1446
+ _toScoring();
1447
+
1448
+ // No scorecard ratified yet. Cash out with 0 weight → hook reverts with NothingToClaim.
1449
+ bytes memory meta = _cashOutMeta(1, 1);
1450
+ vm.prank(_users[0]);
1451
+ vm.expectRevert(DefifaHook.DefifaHook_NothingToClaim.selector);
1452
+ JBMultiTerminal(address(jbMultiTerminal()))
1453
+ .cashOutTokensOf({
1454
+ holder: _users[0],
1455
+ projectId: _pid,
1456
+ cashOutCount: 0,
1457
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
1458
+ minTokensReclaimed: 0,
1459
+ beneficiary: payable(_users[0]),
1460
+ metadata: meta
1461
+ });
1462
+ }
1463
+
1464
+ // =========================================================================
1465
+ // EDGE: NFT transfer destroys delegation — new owner can't vote in SCORING
1466
+ // =========================================================================
1467
+
1468
+ function test_fork_nftTransfer_recipientGetsAttestationPower() external onlyFork {
1469
+ _setupGame(4, 1 ether);
1470
+
1471
+ address recipient = _addr(999);
1472
+
1473
+ // Transfer tier 1 NFT during MINT.
1474
+ vm.prank(_users[0]);
1475
+ _nft.transferFrom(_users[0], recipient, _generateTokenId(1, 1));
1476
+
1477
+ // Recipient has the NFT but delegation went to address(0) (recipient's default).
1478
+ // Recipient can re-delegate during MINT.
1479
+ DefifaDelegation[] memory dd = new DefifaDelegation[](1);
1480
+ dd[0] = DefifaDelegation({delegatee: recipient, tierId: 1});
1481
+ vm.prank(recipient);
1482
+ _nft.setTierDelegatesTo(dd);
1483
+ vm.warp(_tsReader.timestamp() + 1);
1484
+
1485
+ _toScoring();
1486
+
1487
+ // Both the original owner and recipient have attestation power due to checkpoint history.
1488
+ // The key invariant: the recipient who re-delegated can attest with non-zero weight.
1489
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
1490
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
1491
+
1492
+ uint256 attestStart = _gov.attestationStartTimeOf(_gameId);
1493
+ uint256 current = _tsReader.timestamp();
1494
+ vm.warp((attestStart > current ? attestStart : current) + 1);
1495
+
1496
+ // Recipient should have attestation power after re-delegation.
1497
+ vm.prank(recipient);
1498
+ uint256 w1 = _gov.attestToScorecardFrom(_gameId, pid);
1499
+ assertGt(w1, 0, "recipient has attestation power after transfer and re-delegation");
1500
+ }
1501
+
1502
+ // =========================================================================
1503
+ // EDGE: Delegation change reverts outside MINT phase
1504
+ // =========================================================================
1505
+
1506
+ function test_fork_delegateDuringScoring_reverts() external onlyFork {
1507
+ _setupGame(4, 1 ether);
1508
+ _toScoring();
1509
+
1510
+ DefifaDelegation[] memory dd = new DefifaDelegation[](1);
1511
+ dd[0] = DefifaDelegation({delegatee: _users[0], tierId: 1});
1512
+ vm.prank(_users[0]);
1513
+ vm.expectRevert(DefifaHook.DefifaHook_DelegateChangesUnavailableInThisPhase.selector);
1514
+ _nft.setTierDelegatesTo(dd);
1515
+ }
1516
+
1517
+ // =========================================================================
1518
+ // EDGE: Scorecard timeout boundary — exact tick
1519
+ // =========================================================================
1520
+
1521
+ function test_fork_scorecardTimeout_exactBoundary() external onlyFork {
1522
+ DefifaLaunchProjectData memory d = _launchDataWith(4, 1 ether, 0, 7 days);
1523
+ (_pid, _nft, _gov) = _launch(d);
1524
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
1525
+
1526
+ _users = new address[](4);
1527
+ for (uint256 i; i < 4; i++) {
1528
+ _users[i] = _addr(i);
1529
+ _mint(_users[i], i + 1, 1 ether);
1530
+ _delegateSelf(_users[i], i + 1);
1531
+ vm.warp(_tsReader.timestamp() + 1);
1532
+ }
1533
+
1534
+ // Advance to scoring start.
1535
+ vm.warp(d.start);
1536
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.SCORING));
1537
+
1538
+ // At exactly start + scorecardTimeout, check uses `>` so should still be SCORING.
1539
+ vm.warp(d.start + 7 days);
1540
+ assertEq(
1541
+ uint256(deployer.currentGamePhaseOf(_pid)),
1542
+ uint256(DefifaGamePhase.SCORING),
1543
+ "at exact boundary: still SCORING"
1544
+ );
1545
+
1546
+ // One second later → NO_CONTEST.
1547
+ vm.warp(d.start + 7 days + 1);
1548
+ assertEq(
1549
+ uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.NO_CONTEST), "past boundary: NO_CONTEST"
1550
+ );
1551
+ }
1552
+
1553
+ // =========================================================================
1554
+ // EDGE: Refund then re-mint same tier (user comes back)
1555
+ // =========================================================================
1556
+
1557
+ function test_fork_refundThenRemint() external onlyFork {
1558
+ _setupGame(4, 1 ether);
1559
+
1560
+ address user = _users[0];
1561
+ uint256 tokenId1 = _generateTokenId(1, 1);
1562
+
1563
+ // Verify user holds token.
1564
+ assertEq(_nft.ownerOf(tokenId1), user);
1565
+
1566
+ // Refund.
1567
+ _refund(user, 1);
1568
+ assertEq(_nft.balanceOf(user), 0, "NFT burned after refund");
1569
+
1570
+ // Re-mint same tier. Token number should be 2 now (first was burned).
1571
+ _mint(user, 1, 1 ether);
1572
+ uint256 tokenId2 = _generateTokenId(1, 2);
1573
+ assertEq(_nft.ownerOf(tokenId2), user, "user re-minted tier 1 with new token number");
1574
+ }
1575
+
1576
+ // =========================================================================
1577
+ // EDGE: Defeated scorecard after another is ratified
1578
+ // =========================================================================
1579
+
1580
+ function test_fork_defeatedScorecard_afterRatification() external onlyFork {
1581
+ _setupGame(4, 1 ether);
1582
+ _toScoring();
1583
+
1584
+ // Submit two competing scorecards.
1585
+ DefifaTierCashOutWeight[] memory scA = _buildScorecard(4);
1586
+ scA[0].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT();
1587
+ uint256 pidA = _gov.submitScorecardFor(_gameId, scA);
1588
+
1589
+ DefifaTierCashOutWeight[] memory scB = _evenScorecard(4);
1590
+ uint256 pidB = _gov.submitScorecardFor(_gameId, scB);
1591
+
1592
+ // Attest and ratify scorecard A.
1593
+ _attestAllFor(pidA);
1594
+ _gov.ratifyScorecardFrom(_gameId, scA);
1595
+
1596
+ // Scorecard A is RATIFIED, scorecard B is DEFEATED.
1597
+ assertEq(uint256(_gov.stateOf(_gameId, pidA)), uint256(DefifaScorecardState.RATIFIED));
1598
+ assertEq(uint256(_gov.stateOf(_gameId, pidB)), uint256(DefifaScorecardState.DEFEATED));
1599
+ }
1600
+
1601
+ // =========================================================================
1602
+ // EDGE: Reserve mint → attestation power
1603
+ // =========================================================================
1604
+
1605
+ function test_fork_reserveMint_getsAttestationPower() external onlyFork {
1606
+ _setupGame(4, 1 ether);
1607
+
1608
+ // The reserve beneficiary is address(0) in our default params (no reserved token beneficiary).
1609
+ // But reservedRate is 1001 (1 reserve per 1001 mints). With only 1 mint, no reserves trigger.
1610
+ // Let's verify the existing voting power works correctly even with the reserve rate set.
1611
+
1612
+ // Verify all 4 users have attestation power through their delegation.
1613
+ _toScoring();
1614
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
1615
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
1616
+
1617
+ uint256 attestStart = _gov.attestationStartTimeOf(_gameId);
1618
+ uint256 current = _tsReader.timestamp();
1619
+ vm.warp((attestStart > current ? attestStart : current) + 1);
1620
+
1621
+ // Each user (sole holder of their tier) should get equal, non-zero attestation power.
1622
+ // Note: the protocol gives sole holders 2x MAX_ATTESTATION_POWER_TIER because
1623
+ // delegate checkpoint units (from store's votingUnits = price) are 2x total tier checkpoints.
1624
+ uint256 firstWeight;
1625
+ for (uint256 i; i < 4; i++) {
1626
+ vm.prank(_users[i]);
1627
+ uint256 w = _gov.attestToScorecardFrom(_gameId, pid);
1628
+ assertGt(w, 0, "sole holder has attestation power");
1629
+ if (i == 0) firstWeight = w;
1630
+ else assertEq(w, firstWeight, "all sole holders get equal power");
1631
+ }
1632
+ }
1633
+
1634
+ // =========================================================================
1635
+ // EDGE: Quorum with odd number of minted tiers (rounding)
1636
+ // =========================================================================
1637
+
1638
+ function test_fork_quorum_oddTierCount() external onlyFork {
1639
+ // 3 tiers minted. Quorum = (3 * MAX_ATTESTATION_POWER_TIER) / 2 = 1.5e9 → rounds to 1_500_000_000.
1640
+ _setupGame(3, 1 ether);
1641
+ _toScoring();
1642
+
1643
+ uint256 q = _gov.quorum(_gameId);
1644
+ uint256 expectedQuorum = (3 * _gov.MAX_ATTESTATION_POWER_TIER()) / 2;
1645
+ assertEq(q, expectedQuorum, "quorum = floor(3 * 1e9 / 2)");
1646
+
1647
+ // 2 of 3 tiers attesting should exceed quorum.
1648
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(3);
1649
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
1650
+
1651
+ uint256 attestStart = _gov.attestationStartTimeOf(_gameId);
1652
+ uint256 current = _tsReader.timestamp();
1653
+ vm.warp((attestStart > current ? attestStart : current) + 1);
1654
+
1655
+ // Only users 0 and 1 attest (2 of 3 tiers).
1656
+ vm.prank(_users[0]);
1657
+ _gov.attestToScorecardFrom(_gameId, pid);
1658
+ vm.prank(_users[1]);
1659
+ _gov.attestToScorecardFrom(_gameId, pid);
1660
+
1661
+ // 2e9 > 1.5e9 → quorum met.
1662
+ vm.warp(_tsReader.timestamp() + _gov.attestationGracePeriodOf(_gameId) + 1);
1663
+ assertEq(uint256(_gov.stateOf(_gameId, pid)), uint256(DefifaScorecardState.SUCCEEDED));
1664
+ }
1665
+
1666
+ // =========================================================================
1667
+ // EDGE: Quorum NOT met with 1 of 5 tiers (below 50%)
1668
+ // =========================================================================
1669
+
1670
+ function test_fork_quorum_notMet_1of5() external onlyFork {
1671
+ // Use 5 tiers so quorum = 5 * MAX / 2 = 2.5e9.
1672
+ // A sole holder contributes ~2e9 (due to 2x attestation factor) < 2.5e9 → quorum NOT met.
1673
+ _setupGame(5, 1 ether);
1674
+ _toScoring();
1675
+
1676
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(5);
1677
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
1678
+
1679
+ uint256 attestStart = _gov.attestationStartTimeOf(_gameId);
1680
+ uint256 current = _tsReader.timestamp();
1681
+ vm.warp((attestStart > current ? attestStart : current) + 1);
1682
+
1683
+ // Only 1 of 5 tiers attests.
1684
+ vm.prank(_users[0]);
1685
+ uint256 w = _gov.attestToScorecardFrom(_gameId, pid);
1686
+
1687
+ // Verify the single attestation is below quorum.
1688
+ uint256 q = _gov.quorum(_gameId);
1689
+ assertLt(w, q, "single holder weight < quorum");
1690
+
1691
+ // State should still be ACTIVE after grace period (quorum not met).
1692
+ vm.warp(_tsReader.timestamp() + _gov.attestationGracePeriodOf(_gameId) + 1);
1693
+ assertEq(uint256(_gov.stateOf(_gameId, pid)), uint256(DefifaScorecardState.ACTIVE));
1694
+
1695
+ // Ratification should fail.
1696
+ vm.expectRevert(DefifaGovernor.DefifaGovernor_NotAllowed.selector);
1697
+ _gov.ratifyScorecardFrom(_gameId, sc);
1698
+ }
1699
+
1700
+ // =========================================================================
1701
+ // EDGE: NO_CONTEST full cycle — trigger, then all users refund at mint price
1702
+ // =========================================================================
1703
+
1704
+ function test_fork_noContest_fullRefundCycle() external onlyFork {
1705
+ DefifaLaunchProjectData memory d = _launchDataWith(4, 1 ether, 0, 7 days);
1706
+ (_pid, _nft, _gov) = _launch(d);
1707
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
1708
+
1709
+ _users = new address[](4);
1710
+ for (uint256 i; i < 4; i++) {
1711
+ _users[i] = _addr(i);
1712
+ _mint(_users[i], i + 1, 1 ether);
1713
+ _delegateSelf(_users[i], i + 1);
1714
+ vm.warp(_tsReader.timestamp() + 1);
1715
+ }
1716
+
1717
+ // Let scorecard timeout expire → NO_CONTEST.
1718
+ vm.warp(d.start + 7 days + 1);
1719
+ assertEq(uint256(deployer.currentGamePhaseOf(_pid)), uint256(DefifaGamePhase.NO_CONTEST));
1720
+
1721
+ // Trigger no-contest.
1722
+ deployer.triggerNoContestFor(_pid);
1723
+
1724
+ // Advance to let the new ruleset take effect.
1725
+ vm.warp(_tsReader.timestamp() + 1);
1726
+
1727
+ // All users should be able to refund at mint price (1 ETH each).
1728
+ for (uint256 i; i < 4; i++) {
1729
+ uint256 bb = _users[i].balance;
1730
+ _refund(_users[i], i + 1);
1731
+ assertEq(_users[i].balance - bb, 1 ether, "NO_CONTEST refund = mint price");
1732
+ }
1733
+
1734
+ // Treasury should be empty.
1735
+ assertEq(_balance(), 0, "treasury empty after all refunds");
1736
+ }
1737
+
1738
+ // =========================================================================
1739
+ // EDGE: Attestation weight shared proportionally within tier
1740
+ // =========================================================================
1741
+
1742
+ function test_fork_attestationWeight_proportionalInTier() external onlyFork {
1743
+ DefifaLaunchProjectData memory d = _launchData(2, 1 ether);
1744
+ (_pid, _nft, _gov) = _launch(d);
1745
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
1746
+
1747
+ // 3 people in tier 1, 1 person in tier 2.
1748
+ address alice = _addr(0);
1749
+ address bob = _addr(1);
1750
+ address carol = _addr(2);
1751
+ address dan = _addr(3);
1752
+
1753
+ _mint(alice, 1, 1 ether);
1754
+ _delegateSelf(alice, 1);
1755
+ vm.warp(_tsReader.timestamp() + 1);
1756
+ _mint(bob, 1, 1 ether);
1757
+ _delegateSelf(bob, 1);
1758
+ vm.warp(_tsReader.timestamp() + 1);
1759
+ _mint(carol, 1, 1 ether);
1760
+ _delegateSelf(carol, 1);
1761
+ vm.warp(_tsReader.timestamp() + 1);
1762
+ _mint(dan, 2, 1 ether);
1763
+ _delegateSelf(dan, 2);
1764
+ vm.warp(_tsReader.timestamp() + 1);
1765
+
1766
+ _users = new address[](4);
1767
+ _users[0] = alice;
1768
+ _users[1] = bob;
1769
+ _users[2] = carol;
1770
+ _users[3] = dan;
1771
+
1772
+ _toScoring();
1773
+
1774
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(2);
1775
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
1776
+
1777
+ uint256 attestStart = _gov.attestationStartTimeOf(_gameId);
1778
+ uint256 current = _tsReader.timestamp();
1779
+ vm.warp((attestStart > current ? attestStart : current) + 1);
1780
+
1781
+ // Alice holds 1/3 of tier 1 → proportionally less power than Dan (sole holder of tier 2).
1782
+ vm.prank(alice);
1783
+ uint256 wAlice = _gov.attestToScorecardFrom(_gameId, pid);
1784
+
1785
+ // Dan holds 1/1 of tier 2 → full power for his tier.
1786
+ vm.prank(dan);
1787
+ uint256 wDan = _gov.attestToScorecardFrom(_gameId, pid);
1788
+
1789
+ // Verify proportionality: Alice should have roughly 1/3 of Dan's power.
1790
+ assertGt(wDan, wAlice, "sole holder has more power than 1/3 holder");
1791
+ assertGt(wAlice, 0, "partial holder still has power");
1792
+ // Alice = 1/3 of tier 1, Dan = all of tier 2. Allow 1 wei rounding tolerance.
1793
+ assertApproxEqAbs(wAlice * 3, wDan, 3, "3 x alice power ~= dan power");
1794
+ }
1795
+
1796
+ // =========================================================================
1797
+ // EDGE: Cash out non-owned token ID → Unauthorized
1798
+ // =========================================================================
1799
+
1800
+ function test_fork_cashOut_wrongTokenId_reverts() external onlyFork {
1801
+ _setupGame(4, 1 ether);
1802
+ _toScoring();
1803
+ _attestAndRatify(_evenScorecard(4));
1804
+
1805
+ // User 0 tries to cash out user 1's token.
1806
+ uint256[] memory tokenIds = new uint256[](1);
1807
+ tokenIds[0] = _generateTokenId(2, 1); // tier 2, token 1 — belongs to user 1
1808
+ bytes[] memory data = new bytes[](1);
1809
+ data[0] = abi.encode(tokenIds);
1810
+ bytes4[] memory ids = new bytes4[](1);
1811
+ ids[0] = metadataHelper().getId("cashOut", address(hook));
1812
+ bytes memory meta = metadataHelper().createMetadata(ids, data);
1813
+
1814
+ vm.prank(_users[0]);
1815
+ vm.expectRevert(
1816
+ abi.encodeWithSelector(
1817
+ DefifaHook.DefifaHook_Unauthorized.selector, _generateTokenId(2, 1), _users[1], _users[0]
1818
+ )
1819
+ );
1820
+ JBMultiTerminal(address(jbMultiTerminal()))
1821
+ .cashOutTokensOf({
1822
+ holder: _users[0],
1823
+ projectId: _pid,
1824
+ cashOutCount: 0,
1825
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
1826
+ minTokensReclaimed: 0,
1827
+ beneficiary: payable(_users[0]),
1828
+ metadata: meta
1829
+ });
1830
+ }
1831
+
1832
+ // =========================================================================
1833
+ // EDGE: Submit scorecard outside SCORING phase
1834
+ // =========================================================================
1835
+
1836
+ function test_fork_submitScorecard_duringMint_reverts() external onlyFork {
1837
+ _setupGame(4, 1 ether);
1838
+ // Still in MINT phase.
1839
+
1840
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
1841
+ vm.expectRevert(DefifaGovernor.DefifaGovernor_NotAllowed.selector);
1842
+ _gov.submitScorecardFor(_gameId, sc);
1843
+ }
1844
+
1845
+ // =========================================================================
1846
+ // EDGE: Two-player precise accounting — winner takes (surplus - dust)
1847
+ // =========================================================================
1848
+
1849
+ function test_fork_twoplayer_preciseAccounting() external onlyFork {
1850
+ _setupGame(2, 5 ether);
1851
+
1852
+ uint256 totalPot = _balance();
1853
+ assertEq(totalPot, 10 ether, "2 players x 5 ETH");
1854
+
1855
+ _toScoring();
1856
+
1857
+ // Tier 1 wins 100%.
1858
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(2);
1859
+ sc[0].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT();
1860
+ _attestAndRatify(sc);
1861
+
1862
+ uint256 surplus = _surplus();
1863
+ // surplus = totalPot - fees (7.5%)
1864
+ uint256 expectedSurplus = totalPot - (totalPot * 75_000_000 / JBConstants.SPLITS_TOTAL_PERCENT);
1865
+ assertEq(surplus, expectedSurplus, "surplus = pot - 7.5% fees");
1866
+
1867
+ // Winner cashes out entire surplus.
1868
+ uint256 bb = _users[0].balance;
1869
+ _cashOut(_users[0], 1, 1);
1870
+ uint256 winnerGot = _users[0].balance - bb;
1871
+
1872
+ // Winner should get the full surplus (minus rounding dust).
1873
+ assertApproxEqAbs(winnerGot, surplus, 1, "winner gets full surplus");
1874
+ }
1875
+
1876
+ // =========================================================================
1877
+ // FUZZ: Fund conservation across varying tier/player counts
1878
+ // =========================================================================
1879
+
1880
+ function test_fork_fuzz_fundConservation(uint8 rawTiers, uint8 rawPlayers) external onlyFork {
1881
+ uint8 nTiers = uint8(bound(rawTiers, 2, 12));
1882
+ uint8 nPPT = uint8(bound(rawPlayers, 1, 3));
1883
+
1884
+ _setupMultiN(nTiers, nPPT, 1 ether);
1885
+ _toScoring();
1886
+
1887
+ uint256 tw = _nft.TOTAL_CASHOUT_WEIGHT();
1888
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(nTiers);
1889
+ uint256 assigned;
1890
+ for (uint256 i; i < nTiers; i++) {
1891
+ if (i == nTiers - 1) {
1892
+ sc[i].cashOutWeight = tw - assigned;
1893
+ } else {
1894
+ sc[i].cashOutWeight = tw / nTiers;
1895
+ }
1896
+ assigned += sc[i].cashOutWeight;
1897
+ }
1898
+
1899
+ _attestAndRatify(sc);
1900
+ uint256 pot = _surplus();
1901
+
1902
+ uint256 total;
1903
+ for (uint256 i; i < _users.length; i++) {
1904
+ uint256 bb = _users[i].balance;
1905
+ uint256 tid = (i / nPPT) + 1;
1906
+ uint256 tnum = (i % nPPT) + 1;
1907
+ _cashOut(_users[i], tid, tnum);
1908
+ total += _users[i].balance - bb;
1909
+ }
1910
+
1911
+ assertApproxEqAbs(total + _surplus(), pot, _users.length, "fund conservation");
1912
+ }
1913
+
1914
+ // =========================================================================
1915
+ // SCORECARD STATE MACHINE: PENDING → ACTIVE → SUCCEEDED → RATIFIED
1916
+ // =========================================================================
1917
+
1918
+ function test_fork_scorecardStateMachine() external onlyFork {
1919
+ _setupGame(4, 1 ether);
1920
+ _toScoring();
1921
+
1922
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
1923
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
1924
+
1925
+ // On fork, attestationStartTimeOf is an absolute timestamp already in the past,
1926
+ // so the scorecard goes straight to ACTIVE (no PENDING window).
1927
+ uint256 attestStart = _gov.attestationStartTimeOf(_gameId);
1928
+ uint256 current = _tsReader.timestamp();
1929
+ vm.warp((attestStart > current ? attestStart : current) + 1);
1930
+ assertEq(uint256(_gov.stateOf(_gameId, pid)), uint256(DefifaScorecardState.ACTIVE));
1931
+
1932
+ // Attest all.
1933
+ for (uint256 i; i < _users.length; i++) {
1934
+ vm.prank(_users[i]);
1935
+ _gov.attestToScorecardFrom(_gameId, pid);
1936
+ }
1937
+
1938
+ // Still ACTIVE during grace period (even if quorum met).
1939
+ assertEq(uint256(_gov.stateOf(_gameId, pid)), uint256(DefifaScorecardState.ACTIVE));
1940
+
1941
+ // SUCCEEDED: after grace period.
1942
+ vm.warp(_tsReader.timestamp() + _gov.attestationGracePeriodOf(_gameId) + 1);
1943
+ assertEq(uint256(_gov.stateOf(_gameId, pid)), uint256(DefifaScorecardState.SUCCEEDED));
1944
+
1945
+ // RATIFIED: after ratification.
1946
+ _gov.ratifyScorecardFrom(_gameId, sc);
1947
+ assertEq(uint256(_gov.stateOf(_gameId, pid)), uint256(DefifaScorecardState.RATIFIED));
1948
+ }
1949
+
1950
+ // =========================================================================
1951
+ // SCORECARD: Ratification before SUCCEEDED state reverts
1952
+ // =========================================================================
1953
+
1954
+ function test_fork_ratifyBeforeSucceeded_reverts() external onlyFork {
1955
+ _setupGame(4, 1 ether);
1956
+ _toScoring();
1957
+
1958
+ DefifaTierCashOutWeight[] memory sc = _evenScorecard(4);
1959
+ _gov.submitScorecardFor(_gameId, sc);
1960
+
1961
+ // No attestations yet — try to ratify.
1962
+ vm.expectRevert(DefifaGovernor.DefifaGovernor_NotAllowed.selector);
1963
+ _gov.ratifyScorecardFrom(_gameId, sc);
1964
+ }
1965
+
1966
+ // =========================================================================
1967
+ // SCORECARD: Unknown scorecard reverts
1968
+ // =========================================================================
1969
+
1970
+ function test_fork_unknownScorecardState_reverts() external onlyFork {
1971
+ _setupGame(4, 1 ether);
1972
+ _toScoring();
1973
+
1974
+ // Query state for a non-existent scorecard.
1975
+ vm.expectRevert(DefifaGovernor.DefifaGovernor_UnknownProposal.selector);
1976
+ _gov.stateOf(_gameId, 12_345);
1977
+ }
1978
+
1979
+ // =========================================================================
1980
+ // ADVERSARIAL: Zero-weight scorecard (all zeros except minimum)
1981
+ // =========================================================================
1982
+
1983
+ function test_fork_zeroWeightTiers_winnerTakeAll() external onlyFork {
1984
+ _setupGame(4, 1 ether);
1985
+ _toScoring();
1986
+
1987
+ // Give all weight to tier 4, zero to others.
1988
+ DefifaTierCashOutWeight[] memory sc = _buildScorecard(4);
1989
+ sc[3].cashOutWeight = _nft.TOTAL_CASHOUT_WEIGHT();
1990
+
1991
+ _attestAndRatify(sc);
1992
+
1993
+ // Tiers 1-3 get nothing.
1994
+ for (uint256 i; i < 3; i++) {
1995
+ uint256 weight = _nft.cashOutWeightOf(_generateTokenId(i + 1, 1));
1996
+ assertEq(weight, 0, "zero-weight tier has 0 cash out weight");
1997
+ }
1998
+
1999
+ // Tier 4 gets everything.
2000
+ uint256 weight4 = _nft.cashOutWeightOf(_generateTokenId(4, 1));
2001
+ assertGt(weight4, 0, "tier 4 has weight");
2002
+
2003
+ uint256 bb = _users[3].balance;
2004
+ _cashOut(_users[3], 4, 1);
2005
+ assertGt(_users[3].balance - bb, 0, "tier 4 holder received ETH");
2006
+ }
2007
+
2008
+ // =========================================================================
2009
+ // ADVERSARIAL: Scorecard tier order violation reverts
2010
+ // =========================================================================
2011
+
2012
+ function test_fork_scorecardBadTierOrder_reverts() external onlyFork {
2013
+ _setupGame(4, 1 ether);
2014
+ _toScoring();
2015
+
2016
+ // Tiers out of order: [3, 1, 2, 4].
2017
+ DefifaTierCashOutWeight[] memory sc = new DefifaTierCashOutWeight[](4);
2018
+ sc[0] = DefifaTierCashOutWeight({id: 3, cashOutWeight: _nft.TOTAL_CASHOUT_WEIGHT() / 4});
2019
+ sc[1] = DefifaTierCashOutWeight({id: 1, cashOutWeight: _nft.TOTAL_CASHOUT_WEIGHT() / 4});
2020
+ sc[2] = DefifaTierCashOutWeight({id: 2, cashOutWeight: _nft.TOTAL_CASHOUT_WEIGHT() / 4});
2021
+ sc[3] = DefifaTierCashOutWeight({id: 4, cashOutWeight: _nft.TOTAL_CASHOUT_WEIGHT() / 4});
2022
+
2023
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
2024
+ _attestAllFor(pid);
2025
+ vm.expectRevert(DefifaHookLib.DefifaHook_BadTierOrder.selector);
2026
+ _gov.ratifyScorecardFrom(_gameId, sc);
2027
+ }
2028
+
2029
+ // =========================================================================
2030
+ // MINTING: Multi-tier mint in single transaction
2031
+ // =========================================================================
2032
+
2033
+ function test_fork_multiTierMint_singleTx() external onlyFork {
2034
+ DefifaLaunchProjectData memory d = _launchData(4, 1 ether);
2035
+ (_pid, _nft, _gov) = _launch(d);
2036
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
2037
+
2038
+ address user = _addr(0);
2039
+ vm.deal(user, 3 ether);
2040
+
2041
+ // Mint tiers 1, 2, 3 in one tx (3 ETH).
2042
+ uint16[] memory m = new uint16[](3);
2043
+ m[0] = 1;
2044
+ m[1] = 2;
2045
+ m[2] = 3;
2046
+ bytes[] memory data = new bytes[](1);
2047
+ data[0] = abi.encode(user, m);
2048
+ bytes4[] memory ids = new bytes4[](1);
2049
+ ids[0] = metadataHelper().getId("pay", address(hook));
2050
+
2051
+ vm.prank(user);
2052
+ jbMultiTerminal().pay{value: 3 ether}(
2053
+ _pid, JBConstants.NATIVE_TOKEN, 3 ether, user, 0, "", metadataHelper().createMetadata(ids, data)
2054
+ );
2055
+
2056
+ assertEq(_nft.balanceOf(user), 3, "user holds 3 NFTs");
2057
+ }
2058
+
2059
+ // =========================================================================
2060
+ // GRACE PERIOD: Enforced minimum of 1 day
2061
+ // =========================================================================
2062
+
2063
+ function test_fork_gracePeriod_minimumEnforced() external onlyFork {
2064
+ DefifaLaunchProjectData memory d = DefifaLaunchProjectData({
2065
+ name: "DEFIFA",
2066
+ projectUri: "",
2067
+ contractUri: "",
2068
+ baseUri: "",
2069
+ token: JBAccountingContext({token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: JBCurrencyIds.ETH}),
2070
+ mintPeriodDuration: 1 days,
2071
+ start: uint48(block.timestamp + 3 days),
2072
+ refundPeriodDuration: 1 days,
2073
+ store: new JB721TiersHookStore(),
2074
+ splits: new JBSplit[](0),
2075
+ attestationStartTime: 0,
2076
+ attestationGracePeriod: 1, // Very short — should be clamped to 1 day.
2077
+ defaultAttestationDelegate: address(0),
2078
+ tierPrice: uint104(1 ether),
2079
+ tiers: _makeTierParams(4),
2080
+ defaultTokenUriResolver: IJB721TokenUriResolver(address(0)),
2081
+ terminal: jbMultiTerminal(),
2082
+ minParticipation: 0,
2083
+ scorecardTimeout: 0
2084
+ });
2085
+ (_pid, _nft, _gov) = _launch(d);
2086
+
2087
+ // Governor should enforce minimum grace period of 1 day.
2088
+ assertGe(_gov.attestationGracePeriodOf(_gameId), 1 days, "grace period >= 1 day");
2089
+ }
2090
+
2091
+ // =========================================================================
2092
+ // CASH OUT WEIGHT: totalCashOutWeight() is constant
2093
+ // =========================================================================
2094
+
2095
+ function test_fork_totalCashOutWeight_constant() external onlyFork {
2096
+ _setupGame(4, 1 ether);
2097
+
2098
+ // Before scorecard.
2099
+ assertEq(_nft.totalCashOutWeight(), _nft.TOTAL_CASHOUT_WEIGHT(), "constant before scorecard");
2100
+
2101
+ _toScoring();
2102
+ _attestAndRatify(_evenScorecard(4));
2103
+
2104
+ // After scorecard.
2105
+ assertEq(_nft.totalCashOutWeight(), _nft.TOTAL_CASHOUT_WEIGHT(), "constant after scorecard");
2106
+ }
2107
+
2108
+ // =========================================================================
2109
+ // SETUP HELPERS
2110
+ // =========================================================================
2111
+
2112
+ function _setupGame(uint8 nTiers, uint256 tierPrice) internal {
2113
+ DefifaLaunchProjectData memory d = _launchData(nTiers, tierPrice);
2114
+ (_pid, _nft, _gov) = _launch(d);
2115
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
2116
+ _users = new address[](nTiers);
2117
+ for (uint256 i; i < nTiers; i++) {
2118
+ _users[i] = _addr(i);
2119
+ _mint(_users[i], i + 1, tierPrice);
2120
+ _delegateSelf(_users[i], i + 1);
2121
+ vm.warp(_tsReader.timestamp() + 1);
2122
+ }
2123
+ }
2124
+
2125
+ function _setupMultiPlayer() internal {
2126
+ DefifaLaunchProjectData memory d = _launchData(4, 1 ether);
2127
+ (_pid, _nft, _gov) = _launch(d);
2128
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
2129
+ _users = new address[](8);
2130
+ for (uint256 i; i < 5; i++) {
2131
+ _users[i] = _addr(100 + i);
2132
+ _mint(_users[i], 1, 1 ether);
2133
+ _delegateSelf(_users[i], 1);
2134
+ vm.warp(_tsReader.timestamp() + 1);
2135
+ }
2136
+ for (uint256 i; i < 3; i++) {
2137
+ _users[5 + i] = _addr(200 + i);
2138
+ _mint(_users[5 + i], i + 2, 1 ether);
2139
+ _delegateSelf(_users[5 + i], i + 2);
2140
+ vm.warp(_tsReader.timestamp() + 1);
2141
+ }
2142
+ }
2143
+
2144
+ function _setupMultiN(uint8 nTiers, uint8 nPPT, uint256 tierPrice) internal {
2145
+ DefifaLaunchProjectData memory d = _launchData(nTiers, tierPrice);
2146
+ (_pid, _nft, _gov) = _launch(d);
2147
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
2148
+ uint256 total = uint256(nTiers) * uint256(nPPT);
2149
+ _users = new address[](total);
2150
+ uint256 idx;
2151
+ for (uint256 t; t < nTiers; t++) {
2152
+ for (uint256 p; p < nPPT; p++) {
2153
+ _users[idx] = _addr(idx);
2154
+ _mint(_users[idx], t + 1, tierPrice);
2155
+ _delegateSelf(_users[idx], t + 1);
2156
+ vm.warp(_tsReader.timestamp() + 1);
2157
+ idx++;
2158
+ }
2159
+ }
2160
+ }
2161
+
2162
+ function _setupPartial(uint8 nTiers, uint256 nMint, uint256 tierPrice) internal {
2163
+ DefifaLaunchProjectData memory d = _launchData(nTiers, tierPrice);
2164
+ (_pid, _nft, _gov) = _launch(d);
2165
+ vm.warp(d.start - d.mintPeriodDuration - d.refundPeriodDuration);
2166
+ _users = new address[](nMint);
2167
+ for (uint256 i; i < nMint; i++) {
2168
+ _users[i] = _addr(i);
2169
+ _mint(_users[i], i + 1, tierPrice);
2170
+ _delegateSelf(_users[i], i + 1);
2171
+ vm.warp(_tsReader.timestamp() + 1);
2172
+ }
2173
+ }
2174
+
2175
+ function _toScoring() internal {
2176
+ vm.warp(_tsReader.timestamp() + 3 days + 1);
2177
+ }
2178
+
2179
+ // =========================================================================
2180
+ // PRIMITIVE HELPERS
2181
+ // =========================================================================
2182
+
2183
+ function _launchData(uint8 n, uint256 tierPrice) internal returns (DefifaLaunchProjectData memory) {
2184
+ return _launchDataWith(n, tierPrice, 0, 0);
2185
+ }
2186
+
2187
+ function _launchDataWith(
2188
+ uint8 n,
2189
+ uint256 tierPrice,
2190
+ uint256 minParticipation,
2191
+ uint32 scorecardTimeout
2192
+ )
2193
+ internal
2194
+ returns (DefifaLaunchProjectData memory)
2195
+ {
2196
+ return DefifaLaunchProjectData({
2197
+ name: "DEFIFA",
2198
+ projectUri: "",
2199
+ contractUri: "",
2200
+ baseUri: "",
2201
+ token: JBAccountingContext({token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: JBCurrencyIds.ETH}),
2202
+ mintPeriodDuration: 1 days,
2203
+ start: uint48(block.timestamp + 3 days),
2204
+ refundPeriodDuration: 1 days,
2205
+ store: new JB721TiersHookStore(),
2206
+ splits: new JBSplit[](0),
2207
+ attestationStartTime: 0,
2208
+ attestationGracePeriod: 100_381,
2209
+ defaultAttestationDelegate: address(0),
2210
+ tierPrice: uint104(tierPrice),
2211
+ tiers: _makeTierParams(n),
2212
+ defaultTokenUriResolver: IJB721TokenUriResolver(address(0)),
2213
+ terminal: jbMultiTerminal(),
2214
+ minParticipation: minParticipation,
2215
+ scorecardTimeout: scorecardTimeout
2216
+ });
2217
+ }
2218
+
2219
+ function _launchDataWithSplits(
2220
+ uint8 n,
2221
+ uint256 tierPrice,
2222
+ JBSplit[] memory splits
2223
+ )
2224
+ internal
2225
+ returns (DefifaLaunchProjectData memory)
2226
+ {
2227
+ return DefifaLaunchProjectData({
2228
+ name: "DEFIFA",
2229
+ projectUri: "",
2230
+ contractUri: "",
2231
+ baseUri: "",
2232
+ token: JBAccountingContext({token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: JBCurrencyIds.ETH}),
2233
+ mintPeriodDuration: 1 days,
2234
+ start: uint48(block.timestamp + 3 days),
2235
+ refundPeriodDuration: 1 days,
2236
+ store: new JB721TiersHookStore(),
2237
+ splits: splits,
2238
+ attestationStartTime: 0,
2239
+ attestationGracePeriod: 100_381,
2240
+ defaultAttestationDelegate: address(0),
2241
+ tierPrice: uint104(tierPrice),
2242
+ tiers: _makeTierParams(n),
2243
+ defaultTokenUriResolver: IJB721TokenUriResolver(address(0)),
2244
+ terminal: jbMultiTerminal(),
2245
+ minParticipation: 0,
2246
+ scorecardTimeout: 0
2247
+ });
2248
+ }
2249
+
2250
+ function _makeTierParams(uint8 n) internal pure returns (DefifaTierParams[] memory tp) {
2251
+ tp = new DefifaTierParams[](n);
2252
+ for (uint256 i; i < n; i++) {
2253
+ tp[i] = DefifaTierParams({
2254
+ reservedRate: 1001,
2255
+ reservedTokenBeneficiary: address(0),
2256
+ encodedIPFSUri: bytes32(0),
2257
+ shouldUseReservedTokenBeneficiaryAsDefault: false,
2258
+ name: "DEFIFA"
2259
+ });
2260
+ }
2261
+ }
2262
+
2263
+ function _launch(DefifaLaunchProjectData memory d) internal returns (uint256 p, DefifaHook n, DefifaGovernor g) {
2264
+ g = governor;
2265
+ p = deployer.launchGameWith(d);
2266
+ JBRuleset memory fc = jbRulesets().currentOf(p);
2267
+ if (fc.dataHook() == address(0)) (fc,) = jbRulesets().latestQueuedOf(p);
2268
+ n = DefifaHook(fc.dataHook());
2269
+ }
2270
+
2271
+ function _addr(uint256 i) internal pure returns (address) {
2272
+ return address(bytes20(keccak256(abi.encode("fork_user", i))));
2273
+ }
2274
+
2275
+ function _mint(address user, uint256 tid, uint256 amt) internal {
2276
+ vm.deal(user, amt);
2277
+ uint16[] memory m = new uint16[](1);
2278
+ m[0] = uint16(tid);
2279
+ bytes[] memory data = new bytes[](1);
2280
+ data[0] = abi.encode(user, m);
2281
+ bytes4[] memory ids = new bytes4[](1);
2282
+ ids[0] = metadataHelper().getId("pay", address(hook));
2283
+ vm.prank(user);
2284
+ jbMultiTerminal().pay{value: amt}(
2285
+ _pid, JBConstants.NATIVE_TOKEN, amt, user, 0, "", metadataHelper().createMetadata(ids, data)
2286
+ );
2287
+ }
2288
+
2289
+ function _delegateSelf(address user, uint256 tid) internal {
2290
+ DefifaDelegation[] memory dd = new DefifaDelegation[](1);
2291
+ dd[0] = DefifaDelegation({delegatee: user, tierId: tid});
2292
+ vm.prank(user);
2293
+ _nft.setTierDelegatesTo(dd);
2294
+ }
2295
+
2296
+ function _buildScorecard(uint256 n) internal pure returns (DefifaTierCashOutWeight[] memory sc) {
2297
+ sc = new DefifaTierCashOutWeight[](n);
2298
+ for (uint256 i; i < n; i++) {
2299
+ sc[i].id = i + 1;
2300
+ }
2301
+ }
2302
+
2303
+ function _evenScorecard(uint256 n) internal view returns (DefifaTierCashOutWeight[] memory sc) {
2304
+ sc = _buildScorecard(n);
2305
+ uint256 tw = _nft.TOTAL_CASHOUT_WEIGHT();
2306
+ uint256 assigned;
2307
+ for (uint256 i; i < n; i++) {
2308
+ if (i == n - 1) {
2309
+ sc[i].cashOutWeight = tw - assigned;
2310
+ } else {
2311
+ sc[i].cashOutWeight = tw / n;
2312
+ }
2313
+ assigned += sc[i].cashOutWeight;
2314
+ }
2315
+ }
2316
+
2317
+ function _attestAndRatify(DefifaTierCashOutWeight[] memory sc) internal {
2318
+ uint256 pid = _gov.submitScorecardFor(_gameId, sc);
2319
+ _attestAllFor(pid);
2320
+ _gov.ratifyScorecardFrom(_gameId, sc);
2321
+ vm.warp(_tsReader.timestamp() + 1);
2322
+ }
2323
+
2324
+ function _attestAllFor(uint256 pid) internal {
2325
+ // attestationStartTimeOf returns an absolute timestamp; on fork it may already be in the past.
2326
+ uint256 attestStart = _gov.attestationStartTimeOf(_gameId);
2327
+ uint256 current = _tsReader.timestamp();
2328
+ vm.warp((attestStart > current ? attestStart : current) + 1);
2329
+ for (uint256 i; i < _users.length; i++) {
2330
+ vm.prank(_users[i]);
2331
+ _gov.attestToScorecardFrom(_gameId, pid);
2332
+ }
2333
+ vm.warp(_tsReader.timestamp() + _gov.attestationGracePeriodOf(_gameId) + 1);
2334
+ }
2335
+
2336
+ function _surplus() internal view returns (uint256) {
2337
+ return
2338
+ jbMultiTerminal()
2339
+ .currentSurplusOf(_pid, jbMultiTerminal().accountingContextsOf(_pid), 18, JBCurrencyIds.ETH);
2340
+ }
2341
+
2342
+ function _balance() internal view returns (uint256) {
2343
+ return jbMultiTerminal().STORE().balanceOf(address(jbMultiTerminal()), _pid, JBConstants.NATIVE_TOKEN);
2344
+ }
2345
+
2346
+ function _cashOut(address user, uint256 tid, uint256 tnum) internal {
2347
+ bytes memory meta = _cashOutMeta(tid, tnum);
2348
+ vm.prank(user);
2349
+ JBMultiTerminal(address(jbMultiTerminal()))
2350
+ .cashOutTokensOf({
2351
+ holder: user,
2352
+ projectId: _pid,
2353
+ cashOutCount: 0,
2354
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
2355
+ minTokensReclaimed: 0,
2356
+ beneficiary: payable(user),
2357
+ metadata: meta
2358
+ });
2359
+ }
2360
+
2361
+ function _cashOutMeta(uint256 tid, uint256 tnum) internal view returns (bytes memory) {
2362
+ uint256[] memory cid = new uint256[](1);
2363
+ cid[0] = (tid * 1_000_000_000) + tnum;
2364
+ bytes[] memory data = new bytes[](1);
2365
+ data[0] = abi.encode(cid);
2366
+ bytes4[] memory ids = new bytes4[](1);
2367
+ ids[0] = metadataHelper().getId("cashOut", address(hook));
2368
+ return metadataHelper().createMetadata(ids, data);
2369
+ }
2370
+
2371
+ function _cashOutAllUsers() internal returns (uint256 total) {
2372
+ for (uint256 i; i < _users.length; i++) {
2373
+ uint256 bb = _users[i].balance;
2374
+ _cashOut(_users[i], i + 1, 1);
2375
+ total += _users[i].balance - bb;
2376
+ }
2377
+ }
2378
+
2379
+ function _refund(address user, uint256 tid) internal {
2380
+ JB721Tier memory tier = _nft.store().tierOf(address(_nft), tid, false);
2381
+ uint256 nb = _nft.store().numberOfBurnedFor(address(_nft), tid);
2382
+ uint256 tnum = tier.initialSupply - tier.remainingSupply + nb;
2383
+ bytes memory meta = _cashOutMeta(tid, tnum);
2384
+ vm.prank(user);
2385
+ JBMultiTerminal(address(jbMultiTerminal()))
2386
+ .cashOutTokensOf({
2387
+ holder: user,
2388
+ projectId: _pid,
2389
+ cashOutCount: 0,
2390
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
2391
+ minTokensReclaimed: 0,
2392
+ beneficiary: payable(user),
2393
+ metadata: meta
2394
+ });
2395
+ }
2396
+
2397
+ function _generateTokenId(uint256 _tierId, uint256 _tokenNumber) internal pure returns (uint256) {
2398
+ return (_tierId * 1_000_000_000) + _tokenNumber;
2399
+ }
2400
+ }