@ballkidz/defifa 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/ADMINISTRATION.md +3 -3
  2. package/AUDIT_INSTRUCTIONS.md +422 -0
  3. package/CRYPTO_ECON.md +5 -5
  4. package/RISKS.md +38 -335
  5. package/SKILLS.md +1 -1
  6. package/USER_JOURNEYS.md +691 -0
  7. package/package.json +7 -7
  8. package/script/Deploy.s.sol +14 -3
  9. package/script/helpers/DefifaDeploymentLib.sol +13 -15
  10. package/src/DefifaDeployer.sol +221 -192
  11. package/src/DefifaGovernor.sol +286 -276
  12. package/src/DefifaHook.sol +65 -32
  13. package/src/DefifaProjectOwner.sol +27 -4
  14. package/src/DefifaTokenUriResolver.sol +136 -134
  15. package/src/enums/DefifaGamePhase.sol +1 -1
  16. package/src/enums/DefifaScorecardState.sol +1 -1
  17. package/src/interfaces/IDefifaDeployer.sol +52 -50
  18. package/src/interfaces/IDefifaGamePhaseReporter.sol +2 -2
  19. package/src/interfaces/IDefifaGamePotReporter.sol +1 -1
  20. package/src/interfaces/IDefifaGovernor.sol +53 -54
  21. package/src/interfaces/IDefifaHook.sol +104 -103
  22. package/src/interfaces/IDefifaTokenUriResolver.sol +2 -2
  23. package/src/libraries/DefifaFontImporter.sol +11 -9
  24. package/src/libraries/DefifaHookLib.sol +66 -53
  25. package/src/structs/DefifaAttestations.sol +1 -1
  26. package/src/structs/DefifaDelegation.sol +1 -1
  27. package/src/structs/DefifaLaunchProjectData.sol +4 -4
  28. package/src/structs/DefifaOpsData.sol +1 -1
  29. package/src/structs/DefifaScorecard.sol +1 -1
  30. package/src/structs/DefifaTierCashOutWeight.sol +1 -1
  31. package/src/structs/DefifaTierParams.sol +2 -1
  32. package/test/DefifaAdversarialQuorum.t.sol +602 -0
  33. package/test/DefifaAuditLowGuards.t.sol +304 -0
  34. package/test/DefifaFeeAccounting.t.sol +37 -16
  35. package/test/DefifaGovernor.t.sol +37 -11
  36. package/test/DefifaHookRegressions.t.sol +14 -12
  37. package/test/DefifaMintCostInvariant.t.sol +31 -12
  38. package/test/DefifaNoContest.t.sol +33 -13
  39. package/test/DefifaSecurity.t.sol +45 -25
  40. package/test/DefifaUSDC.t.sol +44 -34
  41. package/test/Fork.t.sol +42 -40
  42. package/test/SVG.t.sol +2 -2
  43. package/test/TestAuditGaps.sol +982 -0
  44. package/test/TestQALastMile.t.sol +511 -0
  45. package/test/regression/FulfillmentBlocksRatification.t.sol +36 -30
  46. package/test/regression/GracePeriodBypass.t.sol +15 -10
@@ -0,0 +1,511 @@
1
+ // SPDX-License-Identifier: UNLICENSED
2
+ pragma solidity 0.8.26;
3
+
4
+ import {TestBaseWorkflow} from "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
5
+
6
+ import {DefifaGovernor} from "../src/DefifaGovernor.sol";
7
+ import {DefifaDeployer} from "../src/DefifaDeployer.sol";
8
+ import {DefifaHook} from "../src/DefifaHook.sol";
9
+ import {DefifaTokenUriResolver} from "../src/DefifaTokenUriResolver.sol";
10
+ import {DefifaGamePhase} from "../src/enums/DefifaGamePhase.sol";
11
+ import {IDefifaDeployer} from "../src/interfaces/IDefifaDeployer.sol";
12
+ import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
13
+
14
+ import {JBTest} from "@bananapus/core-v6/test/helpers/JBTest.sol";
15
+ import {JBRulesetMetadataResolver} from "@bananapus/core-v6/src/libraries/JBRulesetMetadataResolver.sol";
16
+ import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
17
+
18
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
19
+ import {ITypeface} from "lib/typeface/contracts/interfaces/ITypeface.sol";
20
+ import {IJB721TokenUriResolver} from "@bananapus/721-hook-v6/src/interfaces/IJB721TokenUriResolver.sol";
21
+ import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
22
+ import {DefifaDelegation} from "../src/structs/DefifaDelegation.sol";
23
+ import {DefifaLaunchProjectData} from "../src/structs/DefifaLaunchProjectData.sol";
24
+ import {DefifaTierParams} from "../src/structs/DefifaTierParams.sol";
25
+ import {DefifaTierCashOutWeight} from "../src/structs/DefifaTierCashOutWeight.sol";
26
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
27
+ import {JBTerminalConfig} from "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
28
+ import {JBRulesetConfig} from "@bananapus/core-v6/src/structs/JBRulesetConfig.sol";
29
+ import {JBRulesetMetadata} from "@bananapus/core-v6/src/structs/JBRulesetMetadata.sol";
30
+ import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
31
+ import {JBFundAccessLimitGroup} from "@bananapus/core-v6/src/structs/JBFundAccessLimitGroup.sol";
32
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
33
+ import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
34
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
35
+ import {JBCurrencyIds} from "@bananapus/core-v6/src/libraries/JBCurrencyIds.sol";
36
+ import {IJBRulesetApprovalHook} from "@bananapus/core-v6/src/interfaces/IJBRulesets.sol";
37
+ import {JBMultiTerminal} from "@bananapus/core-v6/src/JBMultiTerminal.sol";
38
+
39
+ /// @dev Helper to read block.timestamp via an external call, bypassing the via-ir optimizer's timestamp caching.
40
+ contract QATimestampReader {
41
+ function timestamp() external view returns (uint256) {
42
+ return block.timestamp;
43
+ }
44
+ }
45
+
46
+ // =============================================================================
47
+ // QA LAST-MILE TEST 1: CASHOUT DoS WHEN FULFILLMENT FAILS DURING RATIFICATION
48
+ // =============================================================================
49
+
50
+ /// @title TestQACashOutDoSDuringFulfillmentWindow
51
+ /// @notice Documents the cashout denial-of-service window when fulfillCommitmentsOf reverts during ratification.
52
+ /// @dev When fulfillCommitmentsOf reverts during ratification (try-catch), the game enters COMPLETE phase
53
+ /// (scorecard is set) but the final ruleset — which has empty fundAccessLimitGroups (surplus = balance) —
54
+ /// is never queued. The SCORING ruleset remains active with payoutLimits = type(uint224).max, making
55
+ /// surplus = 0, which causes cashOutCount = 0 in the hook's computeCashOutCount.
56
+ /// Players cannot cash out until fulfillCommitmentsOf is successfully retried.
57
+ /// This is a known, accepted behavior: the DoS is temporary and funds are safe.
58
+ contract TestQACashOutDoSDuringFulfillmentWindow is JBTest, TestBaseWorkflow {
59
+ using JBRulesetMetadataResolver for JBRuleset;
60
+
61
+ QATimestampReader private _tsReader = new QATimestampReader();
62
+
63
+ address _protocolFeeProjectTokenAccount;
64
+ address _defifaProjectTokenAccount;
65
+ uint256 _protocolFeeProjectId;
66
+ uint256 _defifaProjectId;
67
+ uint256 _gameId = 3;
68
+
69
+ DefifaDeployer deployer;
70
+ DefifaHook hook;
71
+ DefifaGovernor governor;
72
+
73
+ address projectOwner = address(bytes20(keccak256("projectOwner")));
74
+
75
+ function setUp() public virtual override {
76
+ super.setUp();
77
+
78
+ JBAccountingContext[] memory _tokens = new JBAccountingContext[](1);
79
+ _tokens[0] = JBAccountingContext({token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: JBCurrencyIds.ETH});
80
+
81
+ JBTerminalConfig[] memory terminalConfigs = new JBTerminalConfig[](1);
82
+ terminalConfigs[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: _tokens});
83
+
84
+ JBRulesetConfig[] memory rulesetConfigs = new JBRulesetConfig[](1);
85
+ rulesetConfigs[0] = JBRulesetConfig({
86
+ mustStartAtOrAfter: 0,
87
+ duration: 10 days,
88
+ weight: 1e18,
89
+ weightCutPercent: 0,
90
+ approvalHook: IJBRulesetApprovalHook(address(0)),
91
+ metadata: JBRulesetMetadata({
92
+ reservedPercent: 0,
93
+ cashOutTaxRate: 0,
94
+ baseCurrency: JBCurrencyIds.ETH,
95
+ pausePay: false,
96
+ pauseCreditTransfers: false,
97
+ allowOwnerMinting: false,
98
+ allowSetCustomToken: false,
99
+ allowTerminalMigration: false,
100
+ allowSetTerminals: false,
101
+ allowSetController: false,
102
+ allowAddAccountingContext: false,
103
+ allowAddPriceFeed: false,
104
+ ownerMustSendPayouts: false,
105
+ holdFees: false,
106
+ useTotalSurplusForCashOuts: false,
107
+ useDataHookForPay: true,
108
+ useDataHookForCashOut: true,
109
+ dataHook: address(0),
110
+ metadata: 0
111
+ }),
112
+ splitGroups: new JBSplitGroup[](0),
113
+ fundAccessLimitGroups: new JBFundAccessLimitGroup[](0)
114
+ });
115
+
116
+ _protocolFeeProjectId =
117
+ jbController().launchProjectFor(address(projectOwner), "", rulesetConfigs, terminalConfigs, "");
118
+ vm.prank(projectOwner);
119
+ _protocolFeeProjectTokenAccount =
120
+ address(jbController().deployERC20For(_protocolFeeProjectId, "Bananapus", "NANA", bytes32(0)));
121
+
122
+ _defifaProjectId =
123
+ jbController().launchProjectFor(address(projectOwner), "", rulesetConfigs, terminalConfigs, "");
124
+ vm.prank(projectOwner);
125
+ _defifaProjectTokenAccount =
126
+ address(jbController().deployERC20For(_defifaProjectId, "Defifa", "DEFIFA", bytes32(0)));
127
+
128
+ hook = new DefifaHook(
129
+ jbDirectory(), IERC20(address(_defifaProjectTokenAccount)), IERC20(_protocolFeeProjectTokenAccount)
130
+ );
131
+ governor = new DefifaGovernor(jbController(), address(this));
132
+ JBAddressRegistry _registry = new JBAddressRegistry();
133
+ DefifaTokenUriResolver _tokenUriResolver = new DefifaTokenUriResolver(ITypeface(address(0)));
134
+ deployer = new DefifaDeployer(
135
+ address(hook),
136
+ _tokenUriResolver,
137
+ governor,
138
+ jbController(),
139
+ _registry,
140
+ _defifaProjectId,
141
+ _protocolFeeProjectId
142
+ );
143
+
144
+ hook.transferOwnership(address(deployer));
145
+ governor.transferOwnership(address(deployer));
146
+ }
147
+
148
+ /// @notice Proves the fix: when sendPayoutsOf reverts during fulfillCommitmentsOf,
149
+ /// the internal try-catch handles it gracefully. The final ruleset is still queued,
150
+ /// and players can cash out immediately (no DoS).
151
+ function test_cashOutDoSDuringFulfillmentWindow() public {
152
+ uint8 nTiers = 4;
153
+ address[] memory _users = new address[](nTiers);
154
+ DefifaLaunchProjectData memory defifaData = _getBasicLaunchData(nTiers);
155
+ (uint256 _projectId, DefifaHook _nft, DefifaGovernor _governor) = _createProject(defifaData);
156
+
157
+ // --- Phase 1: Mint NFTs (1 ETH per user, 4 users = 4 ETH pot) ---
158
+ vm.warp(defifaData.start - defifaData.mintPeriodDuration - defifaData.refundPeriodDuration);
159
+ for (uint256 i = 0; i < nTiers; i++) {
160
+ _users[i] = address(bytes20(keccak256(abi.encode("qa_user", Strings.toString(i)))));
161
+ vm.deal(_users[i], 1 ether);
162
+ uint16[] memory rawMetadata = new uint16[](1);
163
+ // forge-lint: disable-next-line(unsafe-typecast)
164
+ rawMetadata[0] = uint16(i + 1);
165
+ bytes memory metadata = _buildPayMetadata(abi.encode(_users[i], rawMetadata));
166
+
167
+ vm.prank(_users[i]);
168
+ jbMultiTerminal().pay{value: 1 ether}(
169
+ _projectId, JBConstants.NATIVE_TOKEN, 1 ether, _users[i], 0, "", metadata
170
+ );
171
+
172
+ DefifaDelegation[] memory dd = new DefifaDelegation[](1);
173
+ dd[0] = DefifaDelegation({delegatee: _users[i], tierId: i + 1});
174
+ vm.prank(_users[i]);
175
+ _nft.setTierDelegatesTo(dd);
176
+
177
+ vm.warp(_tsReader.timestamp() + 1);
178
+ }
179
+
180
+ // Verify the pot is 4 ETH.
181
+ uint256 potBefore =
182
+ jbMultiTerminal().STORE().balanceOf(address(jbMultiTerminal()), _projectId, JBConstants.NATIVE_TOKEN);
183
+ assertEq(potBefore, 4 ether, "pot should be 4 ETH");
184
+
185
+ // --- Advance to SCORING phase ---
186
+ vm.warp(defifaData.start + 1);
187
+ assertEq(uint256(deployer.currentGamePhaseOf(_projectId)), uint256(DefifaGamePhase.SCORING));
188
+
189
+ // --- Build and ratify scorecard with tier 1 getting all weight ---
190
+ DefifaTierCashOutWeight[] memory scorecards = new DefifaTierCashOutWeight[](nTiers);
191
+ scorecards[0] = DefifaTierCashOutWeight({id: 1, cashOutWeight: _nft.TOTAL_CASHOUT_WEIGHT()});
192
+ for (uint256 i = 1; i < nTiers; i++) {
193
+ scorecards[i] = DefifaTierCashOutWeight({id: i + 1, cashOutWeight: 0});
194
+ }
195
+
196
+ uint256 _proposalId = _governor.submitScorecardFor(_gameId, scorecards);
197
+ vm.warp(_tsReader.timestamp() + _governor.attestationStartTimeOf(_gameId) + 1);
198
+ for (uint256 i = 0; i < _users.length; i++) {
199
+ vm.prank(_users[i]);
200
+ _governor.attestToScorecardFrom(_gameId, _proposalId);
201
+ }
202
+ vm.warp(_tsReader.timestamp() + _governor.attestationGracePeriodOf(_gameId) + 1);
203
+
204
+ // --- Mock sendPayoutsOf on the terminal to revert (the actual failure point) ---
205
+ vm.mockCallRevert(
206
+ address(jbMultiTerminal()),
207
+ abi.encodeWithSelector(JBMultiTerminal.sendPayoutsOf.selector),
208
+ abi.encodeWithSignature("Error(string)", "simulated payout failure")
209
+ );
210
+
211
+ // Ratify — sendPayoutsOf fails but fulfillCommitmentsOf handles it gracefully.
212
+ // CommitmentPayoutFailed is emitted and the final ruleset is still queued.
213
+ vm.expectEmit(true, false, false, false);
214
+ emit IDefifaDeployer.CommitmentPayoutFailed(_gameId, 0, "");
215
+ _governor.ratifyScorecardFrom(_gameId, scorecards);
216
+
217
+ // Clear mock so subsequent calls work.
218
+ vm.clearMockedCalls();
219
+
220
+ // Verify game is in COMPLETE phase (scorecard is set).
221
+ assertEq(uint256(deployer.currentGamePhaseOf(_projectId)), uint256(DefifaGamePhase.COMPLETE));
222
+
223
+ // Verify fulfilledCommitmentsOf returns 1 (sentinel).
224
+ assertEq(deployer.fulfilledCommitmentsOf(_projectId), 1, "should be sentinel value 1");
225
+
226
+ // --- Players CAN cash out immediately (no DoS) ---
227
+ uint256 user0BalBefore = _users[0].balance;
228
+ {
229
+ uint256[] memory cashOutIds = new uint256[](1);
230
+ cashOutIds[0] = _generateTokenId(1, 1);
231
+ bytes memory cashOutMetadata = _buildCashOutMetadata(abi.encode(cashOutIds));
232
+
233
+ vm.prank(_users[0]);
234
+ JBMultiTerminal(address(jbMultiTerminal()))
235
+ .cashOutTokensOf({
236
+ holder: _users[0],
237
+ projectId: _projectId,
238
+ cashOutCount: 0,
239
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
240
+ minTokensReclaimed: 0,
241
+ beneficiary: payable(_users[0]),
242
+ metadata: cashOutMetadata
243
+ });
244
+ }
245
+ uint256 reclaimed = _users[0].balance - user0BalBefore;
246
+ assertGt(reclaimed, 0, "winner should reclaim ETH immediately (no DoS)");
247
+
248
+ // --- Calling fulfillCommitmentsOf again is a no-op (idempotent) ---
249
+ deployer.fulfillCommitmentsOf(_projectId);
250
+ assertEq(deployer.fulfilledCommitmentsOf(_projectId), 1, "should still be sentinel value 1");
251
+ }
252
+
253
+ // ----- Internal helpers ------
254
+
255
+ function _getBasicLaunchData(uint8 nTiers) internal returns (DefifaLaunchProjectData memory) {
256
+ DefifaTierParams[] memory tierParams = new DefifaTierParams[](nTiers);
257
+ for (uint256 i = 0; i < nTiers; i++) {
258
+ tierParams[i] = DefifaTierParams({
259
+ reservedRate: 1001,
260
+ reservedTokenBeneficiary: address(0),
261
+ encodedIPFSUri: bytes32(0),
262
+ shouldUseReservedTokenBeneficiaryAsDefault: false,
263
+ name: "DEFIFA"
264
+ });
265
+ }
266
+
267
+ return DefifaLaunchProjectData({
268
+ name: "DEFIFA",
269
+ projectUri: "",
270
+ contractUri: "",
271
+ baseUri: "",
272
+ tierPrice: 1 ether,
273
+ token: JBAccountingContext({token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: JBCurrencyIds.ETH}),
274
+ mintPeriodDuration: 1 days,
275
+ start: uint48(block.timestamp + 3 days),
276
+ refundPeriodDuration: 1 days,
277
+ store: new JB721TiersHookStore(),
278
+ splits: new JBSplit[](0),
279
+ attestationStartTime: 0,
280
+ attestationGracePeriod: 100_381,
281
+ defaultAttestationDelegate: address(0),
282
+ tiers: tierParams,
283
+ defaultTokenUriResolver: IJB721TokenUriResolver(address(0)),
284
+ terminal: jbMultiTerminal(),
285
+ minParticipation: 0,
286
+ scorecardTimeout: 0
287
+ });
288
+ }
289
+
290
+ function _createProject(DefifaLaunchProjectData memory defifaLaunchData)
291
+ internal
292
+ returns (uint256 projectId, DefifaHook nft, DefifaGovernor _governor)
293
+ {
294
+ _governor = governor;
295
+ (projectId) = deployer.launchGameWith(defifaLaunchData);
296
+ JBRuleset memory _fc = jbRulesets().currentOf(projectId);
297
+ if (_fc.dataHook() == address(0)) {
298
+ (_fc,) = jbRulesets().latestQueuedOf(projectId);
299
+ }
300
+ nft = DefifaHook(_fc.dataHook());
301
+ }
302
+
303
+ function _generateTokenId(uint256 _tierId, uint256 _tokenNumber) internal pure returns (uint256) {
304
+ return (_tierId * 1_000_000_000) + _tokenNumber;
305
+ }
306
+
307
+ function _buildPayMetadata(bytes memory metadata) internal view returns (bytes memory) {
308
+ bytes[] memory data = new bytes[](1);
309
+ data[0] = metadata;
310
+ bytes4[] memory ids = new bytes4[](1);
311
+ ids[0] = metadataHelper().getId("pay", address(hook));
312
+ return metadataHelper().createMetadata(ids, data);
313
+ }
314
+
315
+ function _buildCashOutMetadata(bytes memory decodedData) internal view returns (bytes memory) {
316
+ bytes4[] memory ids = new bytes4[](1);
317
+ ids[0] = metadataHelper().getId("cashOut", address(hook));
318
+ bytes[] memory datas = new bytes[](1);
319
+ datas[0] = decodedData;
320
+ return metadataHelper().createMetadata(ids, datas);
321
+ }
322
+ }
323
+
324
+ // =============================================================================
325
+ // QA LAST-MILE TEST 2: GAME-ID PREDICTION RACE — STALE STORAGE ON REVERT
326
+ // =============================================================================
327
+
328
+ /// @title TestQAGameIdPredictionRace
329
+ /// @notice Tests the gameId prediction race condition in DefifaDeployer.launchGameWith.
330
+ /// @dev The deployer predicts gameId = PROJECTS().count() + 1, then clones and initializes a hook with that ID.
331
+ /// If another project is created between the count() read and launchProjectFor(), the actual ID differs and
332
+ /// the transaction reverts with DefifaDeployer_InvalidGameConfiguration. Because the clone uses
333
+ /// cloneDeterministic with msg.sender in the salt, a retry from the same caller succeeds with a new nonce.
334
+ /// No orphaned state remains after the revert.
335
+ contract TestQAGameIdPredictionRace is JBTest, TestBaseWorkflow {
336
+ using JBRulesetMetadataResolver for JBRuleset;
337
+
338
+ QATimestampReader private _tsReader = new QATimestampReader();
339
+
340
+ address _protocolFeeProjectTokenAccount;
341
+ address _defifaProjectTokenAccount;
342
+ uint256 _protocolFeeProjectId;
343
+ uint256 _defifaProjectId;
344
+
345
+ DefifaDeployer deployer;
346
+ DefifaHook hook;
347
+ DefifaGovernor governor;
348
+
349
+ address projectOwner = address(bytes20(keccak256("projectOwner")));
350
+
351
+ function setUp() public virtual override {
352
+ super.setUp();
353
+
354
+ JBAccountingContext[] memory _tokens = new JBAccountingContext[](1);
355
+ _tokens[0] = JBAccountingContext({token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: JBCurrencyIds.ETH});
356
+
357
+ JBTerminalConfig[] memory terminalConfigs = new JBTerminalConfig[](1);
358
+ terminalConfigs[0] = JBTerminalConfig({terminal: jbMultiTerminal(), accountingContextsToAccept: _tokens});
359
+
360
+ JBRulesetConfig[] memory rulesetConfigs = new JBRulesetConfig[](1);
361
+ rulesetConfigs[0] = JBRulesetConfig({
362
+ mustStartAtOrAfter: 0,
363
+ duration: 10 days,
364
+ weight: 1e18,
365
+ weightCutPercent: 0,
366
+ approvalHook: IJBRulesetApprovalHook(address(0)),
367
+ metadata: JBRulesetMetadata({
368
+ reservedPercent: 0,
369
+ cashOutTaxRate: 0,
370
+ baseCurrency: JBCurrencyIds.ETH,
371
+ pausePay: false,
372
+ pauseCreditTransfers: false,
373
+ allowOwnerMinting: false,
374
+ allowSetCustomToken: false,
375
+ allowTerminalMigration: false,
376
+ allowSetTerminals: false,
377
+ allowSetController: false,
378
+ allowAddAccountingContext: false,
379
+ allowAddPriceFeed: false,
380
+ ownerMustSendPayouts: false,
381
+ holdFees: false,
382
+ useTotalSurplusForCashOuts: false,
383
+ useDataHookForPay: true,
384
+ useDataHookForCashOut: true,
385
+ dataHook: address(0),
386
+ metadata: 0
387
+ }),
388
+ splitGroups: new JBSplitGroup[](0),
389
+ fundAccessLimitGroups: new JBFundAccessLimitGroup[](0)
390
+ });
391
+
392
+ _protocolFeeProjectId =
393
+ jbController().launchProjectFor(address(projectOwner), "", rulesetConfigs, terminalConfigs, "");
394
+ vm.prank(projectOwner);
395
+ _protocolFeeProjectTokenAccount =
396
+ address(jbController().deployERC20For(_protocolFeeProjectId, "Bananapus", "NANA", bytes32(0)));
397
+
398
+ _defifaProjectId =
399
+ jbController().launchProjectFor(address(projectOwner), "", rulesetConfigs, terminalConfigs, "");
400
+ vm.prank(projectOwner);
401
+ _defifaProjectTokenAccount =
402
+ address(jbController().deployERC20For(_defifaProjectId, "Defifa", "DEFIFA", bytes32(0)));
403
+
404
+ hook = new DefifaHook(
405
+ jbDirectory(), IERC20(address(_defifaProjectTokenAccount)), IERC20(_protocolFeeProjectTokenAccount)
406
+ );
407
+ governor = new DefifaGovernor(jbController(), address(this));
408
+ JBAddressRegistry _registry = new JBAddressRegistry();
409
+ DefifaTokenUriResolver _tokenUriResolver = new DefifaTokenUriResolver(ITypeface(address(0)));
410
+ deployer = new DefifaDeployer(
411
+ address(hook),
412
+ _tokenUriResolver,
413
+ governor,
414
+ jbController(),
415
+ _registry,
416
+ _defifaProjectId,
417
+ _protocolFeeProjectId
418
+ );
419
+
420
+ hook.transferOwnership(address(deployer));
421
+ governor.transferOwnership(address(deployer));
422
+ }
423
+
424
+ /// @notice Proves the gameId prediction race: when count() returns a stale value (simulating a front-run
425
+ /// where another project is created between the deployer's count() read and launchProjectFor()),
426
+ /// the deployment reverts. The revert is caught by JBDirectory's own project ID validation
427
+ /// (JBDirectory_InvalidProjectIdInDirectory) before reaching the deployer's explicit check,
428
+ /// providing defense-in-depth. The caller can retry successfully with a fresh count.
429
+ function test_gameIdPredictionRaceRevertsAndRetrySucceeds() public {
430
+ // Record the current project count before the race.
431
+ uint256 countBefore = jbController().PROJECTS().count();
432
+ address projectsAddr = address(jbController().PROJECTS());
433
+
434
+ // Build a valid game launch payload.
435
+ DefifaLaunchProjectData memory defifaData = _getBasicLaunchData(4);
436
+
437
+ // --- Simulate the front-run via mock: make count() return a stale (lower) value ---
438
+ vm.mockCall(projectsAddr, abi.encodeWithSignature("count()"), abi.encode(countBefore - 1));
439
+
440
+ // The deployer's launch reverts due to ID mismatch.
441
+ vm.expectRevert();
442
+ deployer.launchGameWith(defifaData);
443
+
444
+ // Clear the mock so future calls get the real count.
445
+ vm.clearMockedCalls();
446
+
447
+ // --- Verify no orphaned state exists after the revert ---
448
+ uint256 staleGameId = countBefore;
449
+
450
+ assertEq(governor.attestationStartTimeOf(staleGameId), 0, "governor should not be initialized for stale gameId");
451
+ assertEq(
452
+ governor.attestationGracePeriodOf(staleGameId), 0, "governor grace period should be 0 for stale gameId"
453
+ );
454
+ assertEq(deployer.tokenOf(staleGameId), address(0), "no ops stored for stale gameId");
455
+ assertEq(deployer.fulfilledCommitmentsOf(staleGameId), 0, "no commitments for stale gameId");
456
+
457
+ // --- Retry: the caller can successfully launch with the correct gameId ---
458
+ DefifaLaunchProjectData memory retryData = _getBasicLaunchData(4);
459
+
460
+ uint256 countNow = jbController().PROJECTS().count();
461
+ assertEq(countNow, countBefore, "count unchanged after reverted launch");
462
+
463
+ uint256 retryGameId = deployer.launchGameWith(retryData);
464
+
465
+ assertEq(retryGameId, countNow + 1, "retry should get the correct gameId");
466
+ assertEq(
467
+ uint256(deployer.currentGamePhaseOf(retryGameId)),
468
+ uint256(DefifaGamePhase.COUNTDOWN),
469
+ "game should be in COUNTDOWN phase"
470
+ );
471
+ assertGt(governor.attestationGracePeriodOf(retryGameId), 0, "governor should be initialized for retry gameId");
472
+ assertEq(deployer.tokenOf(retryGameId), JBConstants.NATIVE_TOKEN, "ops stored correctly for retry gameId");
473
+ }
474
+
475
+ // ----- Internal helpers ------
476
+
477
+ function _getBasicLaunchData(uint8 nTiers) internal returns (DefifaLaunchProjectData memory) {
478
+ DefifaTierParams[] memory tierParams = new DefifaTierParams[](nTiers);
479
+ for (uint256 i = 0; i < nTiers; i++) {
480
+ tierParams[i] = DefifaTierParams({
481
+ reservedRate: 1001,
482
+ reservedTokenBeneficiary: address(0),
483
+ encodedIPFSUri: bytes32(0),
484
+ shouldUseReservedTokenBeneficiaryAsDefault: false,
485
+ name: "DEFIFA"
486
+ });
487
+ }
488
+
489
+ return DefifaLaunchProjectData({
490
+ name: "DEFIFA",
491
+ projectUri: "",
492
+ contractUri: "",
493
+ baseUri: "",
494
+ tierPrice: 1 ether,
495
+ token: JBAccountingContext({token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: JBCurrencyIds.ETH}),
496
+ mintPeriodDuration: 1 days,
497
+ start: uint48(block.timestamp + 3 days),
498
+ refundPeriodDuration: 1 days,
499
+ store: new JB721TiersHookStore(),
500
+ splits: new JBSplit[](0),
501
+ attestationStartTime: 0,
502
+ attestationGracePeriod: 100_381,
503
+ defaultAttestationDelegate: address(0),
504
+ tiers: tierParams,
505
+ defaultTokenUriResolver: IJB721TokenUriResolver(address(0)),
506
+ terminal: jbMultiTerminal(),
507
+ minParticipation: 0,
508
+ scorecardTimeout: 0
509
+ });
510
+ }
511
+ }
@@ -1,36 +1,39 @@
1
1
  // SPDX-License-Identifier: UNLICENSED
2
2
  pragma solidity 0.8.26;
3
3
 
4
- import "forge-std/Test.sol";
5
- import "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
4
+ import {TestBaseWorkflow} from "@bananapus/core-v6/test/helpers/TestBaseWorkflow.sol";
6
5
 
7
6
  import {DefifaGovernor} from "../../src/DefifaGovernor.sol";
8
7
  import {DefifaDeployer} from "../../src/DefifaDeployer.sol";
9
8
  import {DefifaHook} from "../../src/DefifaHook.sol";
10
9
  import {DefifaTokenUriResolver} from "../../src/DefifaTokenUriResolver.sol";
11
10
  import {DefifaScorecardState} from "../../src/enums/DefifaScorecardState.sol";
12
- import {IDefifaGovernor} from "../../src/interfaces/IDefifaGovernor.sol";
13
11
  import {IDefifaDeployer} from "../../src/interfaces/IDefifaDeployer.sol";
14
12
  import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
15
13
 
16
- import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
17
- import {MetadataResolverHelper} from "@bananapus/core-v6/test/helpers/MetadataResolverHelper.sol";
18
14
  import {JBTest} from "@bananapus/core-v6/test/helpers/JBTest.sol";
19
15
  import {JBRulesetMetadataResolver} from "@bananapus/core-v6/src/libraries/JBRulesetMetadataResolver.sol";
20
- import {
21
- JB721TiersRulesetMetadataResolver
22
- } from "@bananapus/721-hook-v6/src/libraries/JB721TiersRulesetMetadataResolver.sol";
23
16
  import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
24
17
 
25
18
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
26
19
  import {ITypeface} from "lib/typeface/contracts/interfaces/ITypeface.sol";
27
20
  import {IJB721TokenUriResolver} from "@bananapus/721-hook-v6/src/interfaces/IJB721TokenUriResolver.sol";
28
- import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
29
21
  import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
30
- import {DefifaDelegation} from "../../src/structs/DefifaDelegation.sol";
31
22
  import {DefifaLaunchProjectData} from "../../src/structs/DefifaLaunchProjectData.sol";
32
23
  import {DefifaTierParams} from "../../src/structs/DefifaTierParams.sol";
33
24
  import {DefifaTierCashOutWeight} from "../../src/structs/DefifaTierCashOutWeight.sol";
25
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
26
+ import {JBTerminalConfig} from "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
27
+ import {JBRulesetConfig} from "@bananapus/core-v6/src/structs/JBRulesetConfig.sol";
28
+ import {JBRulesetMetadata} from "@bananapus/core-v6/src/structs/JBRulesetMetadata.sol";
29
+ import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
30
+ import {JBFundAccessLimitGroup} from "@bananapus/core-v6/src/structs/JBFundAccessLimitGroup.sol";
31
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
32
+ import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
33
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
34
+ import {JBCurrencyIds} from "@bananapus/core-v6/src/libraries/JBCurrencyIds.sol";
35
+ import {IJBRulesetApprovalHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetApprovalHook.sol";
36
+ import {JBMultiTerminal} from "@bananapus/core-v6/src/JBMultiTerminal.sol";
34
37
 
35
38
  /// @dev Helper to read block.timestamp via an external call, bypassing the via-ir optimizer's timestamp caching.
36
39
  contract TimestampReader3 {
@@ -40,9 +43,9 @@ contract TimestampReader3 {
40
43
  }
41
44
 
42
45
  /// @title FulfillmentBlocksRatification
43
- /// @notice Regression test: ratification should succeed even when fulfillCommitmentsOf reverts.
44
- /// @dev Tests the try-catch wrapper around fulfillCommitmentsOf in ratifyScorecardFrom.
45
- /// The test verifies that the FulfillmentFailed event is emitted and ratification completes.
46
+ /// @notice Regression test: ratification should succeed even when sendPayoutsOf reverts inside fulfillCommitmentsOf.
47
+ /// @dev Tests the internal try-catch wrapper around sendPayoutsOf in fulfillCommitmentsOf.
48
+ /// The test verifies that the CommitmentPayoutFailed event is emitted and ratification completes.
46
49
  contract FulfillmentBlocksRatification is JBTest, TestBaseWorkflow {
47
50
  using JBRulesetMetadataResolver for JBRuleset;
48
51
 
@@ -118,10 +121,10 @@ contract FulfillmentBlocksRatification is JBTest, TestBaseWorkflow {
118
121
  );
119
122
  governor = new DefifaGovernor(jbController(), address(this));
120
123
  JBAddressRegistry _registry = new JBAddressRegistry();
121
- DefifaTokenUriResolver _tokenURIResolver = new DefifaTokenUriResolver(ITypeface(address(0)));
124
+ DefifaTokenUriResolver _tokenUriResolver = new DefifaTokenUriResolver(ITypeface(address(0)));
122
125
  deployer = new DefifaDeployer(
123
126
  address(hook),
124
- _tokenURIResolver,
127
+ _tokenUriResolver,
125
128
  governor,
126
129
  jbController(),
127
130
  _registry,
@@ -133,10 +136,10 @@ contract FulfillmentBlocksRatification is JBTest, TestBaseWorkflow {
133
136
  governor.transferOwnership(address(deployer));
134
137
  }
135
138
 
136
- /// @notice Test that ratification emits FulfillmentFailed when fulfillment reverts,
137
- /// but the scorecard is still ratified.
138
- /// @dev We mock fulfillCommitmentsOf to revert, then verify the ratification still succeeds.
139
- function test_ratificationSucceedsWhenFulfillmentReverts() public {
139
+ /// @notice Test that ratification emits CommitmentPayoutFailed when sendPayoutsOf reverts,
140
+ /// but the scorecard is still ratified and the final ruleset is queued.
141
+ /// @dev We mock sendPayoutsOf to revert, then verify ratification still succeeds.
142
+ function test_ratificationSucceedsWhenPayoutReverts() public {
140
143
  uint8 nTiers = 4;
141
144
  address[] memory _users = new address[](nTiers);
142
145
  DefifaLaunchProjectData memory defifaData = _getBasicLaunchData(nTiers);
@@ -148,6 +151,7 @@ contract FulfillmentBlocksRatification is JBTest, TestBaseWorkflow {
148
151
  _users[i] = address(bytes20(keccak256(abi.encode("user", Strings.toString(i)))));
149
152
  vm.deal(_users[i], 1 ether);
150
153
  uint16[] memory rawMetadata = new uint16[](1);
154
+ // forge-lint: disable-next-line(unsafe-typecast)
151
155
  rawMetadata[0] = uint16(i + 1);
152
156
  bytes memory metadata = _buildPayMetadata(abi.encode(_users[i], rawMetadata));
153
157
  vm.prank(_users[i]);
@@ -182,26 +186,25 @@ contract FulfillmentBlocksRatification is JBTest, TestBaseWorkflow {
182
186
  }
183
187
  vm.warp(_tsReader.timestamp() + _governor.attestationGracePeriodOf(_gameId) + 1);
184
188
 
185
- // Mock fulfillCommitmentsOf to revert
186
- address _deployer = jbController().PROJECTS().ownerOf(_gameId);
189
+ // Mock sendPayoutsOf on the terminal to revert
187
190
  vm.mockCallRevert(
188
- _deployer,
189
- abi.encodeWithSelector(IDefifaDeployer.fulfillCommitmentsOf.selector, _gameId),
190
- abi.encodeWithSignature("Error(string)", "simulated fulfillment failure")
191
+ address(jbMultiTerminal()),
192
+ abi.encodeWithSelector(JBMultiTerminal.sendPayoutsOf.selector),
193
+ abi.encodeWithSignature("Error(string)", "simulated payout failure")
191
194
  );
192
195
 
193
- // Ratification should succeed even though fulfillment will revert.
194
- // We expect the FulfillmentFailed event to be emitted.
196
+ // Ratification should succeed. CommitmentPayoutFailed is emitted from the deployer.
195
197
  vm.expectEmit(true, false, false, false);
196
- emit IDefifaGovernor.FulfillmentFailed(_gameId, "");
198
+ emit IDefifaDeployer.CommitmentPayoutFailed(_gameId, 0, "");
197
199
 
198
200
  _governor.ratifyScorecardFrom(_gameId, scorecards);
199
201
 
202
+ // Clear mock
203
+ vm.clearMockedCalls();
204
+
200
205
  // Verify the scorecard was ratified
201
206
  assertEq(
202
- _governor.ratifiedScorecardIdOf(_gameId),
203
- _proposalId,
204
- "Scorecard should be ratified despite fulfillment failure"
207
+ _governor.ratifiedScorecardIdOf(_gameId), _proposalId, "Scorecard should be ratified despite payout failure"
205
208
  );
206
209
 
207
210
  // Verify the state is RATIFIED
@@ -210,6 +213,9 @@ contract FulfillmentBlocksRatification is JBTest, TestBaseWorkflow {
210
213
  uint256(DefifaScorecardState.RATIFIED),
211
214
  "Scorecard state should be RATIFIED"
212
215
  );
216
+
217
+ // Verify fulfilledCommitmentsOf is sentinel (1)
218
+ assertEq(deployer.fulfilledCommitmentsOf(_projectId), 1, "should be sentinel value 1");
213
219
  }
214
220
 
215
221
  // ----- Internal helpers ------