@ballkidz/defifa 0.0.25 → 0.0.27

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 (65) hide show
  1. package/AUDIT_INSTRUCTIONS.md +6 -2
  2. package/README.md +11 -2
  3. package/RISKS.md +3 -1
  4. package/STYLE_GUIDE.md +14 -11
  5. package/package.json +31 -14
  6. package/script/Deploy.s.sol +4 -1
  7. package/src/DefifaDeployer.sol +79 -47
  8. package/src/DefifaGovernor.sol +57 -12
  9. package/src/DefifaHook.sol +83 -26
  10. package/src/DefifaProjectOwner.sol +4 -3
  11. package/src/DefifaTokenUriResolver.sol +113 -20
  12. package/src/enums/DefifaGamePhase.sol +6 -0
  13. package/src/enums/DefifaScorecardState.sol +4 -0
  14. package/src/interfaces/IDefifaDeployer.sol +5 -0
  15. package/src/interfaces/IDefifaGamePhaseReporter.sol +4 -0
  16. package/src/interfaces/IDefifaGamePotReporter.sol +10 -0
  17. package/src/interfaces/IDefifaGovernor.sol +4 -0
  18. package/src/interfaces/IDefifaHook.sol +5 -0
  19. package/src/interfaces/IDefifaTokenUriResolver.sol +3 -0
  20. package/src/libraries/DefifaFontImporter.sol +1 -1
  21. package/src/libraries/DefifaHookLib.sol +9 -10
  22. package/src/structs/DefifaAttestations.sol +3 -2
  23. package/src/structs/DefifaDelegation.sol +1 -0
  24. package/src/structs/DefifaLaunchProjectData.sol +2 -3
  25. package/src/structs/DefifaOpsData.sol +1 -0
  26. package/src/structs/DefifaScorecard.sol +2 -0
  27. package/src/structs/DefifaTierCashOutWeight.sol +3 -1
  28. package/src/structs/DefifaTierParams.sol +1 -0
  29. package/CRYPTO_ECON.pdf +0 -0
  30. package/CRYPTO_ECON.tex +0 -997
  31. package/foundry.lock +0 -17
  32. package/references/operations.md +0 -32
  33. package/references/runtime.md +0 -43
  34. package/slither-ci.config.json +0 -10
  35. package/sphinx.lock +0 -521
  36. package/test/BWAFunctionComparison.t.sol +0 -1320
  37. package/test/DefifaAdversarialQuorum.t.sol +0 -617
  38. package/test/DefifaAuditLowGuards.t.sol +0 -308
  39. package/test/DefifaFeeAccounting.t.sol +0 -581
  40. package/test/DefifaGovernanceHardening.t.sol +0 -1315
  41. package/test/DefifaGovernor.t.sol +0 -1378
  42. package/test/DefifaHookRegressions.t.sol +0 -415
  43. package/test/DefifaMintCostInvariant.t.sol +0 -319
  44. package/test/DefifaNoContest.t.sol +0 -941
  45. package/test/DefifaSecurity.t.sol +0 -741
  46. package/test/DefifaUSDC.t.sol +0 -480
  47. package/test/Fork.t.sol +0 -2388
  48. package/test/TestAuditGaps.sol +0 -984
  49. package/test/TestQALastMile.t.sol +0 -514
  50. package/test/audit/AttestationDoubleCount.t.sol +0 -218
  51. package/test/audit/CodexNemesisCurrencyMismatchBypass.t.sol +0 -112
  52. package/test/audit/CodexNemesisNoContestReserveDrain.t.sol +0 -238
  53. package/test/audit/CodexNemesisOneTierZeroTimeoutLockVerified.t.sol +0 -218
  54. package/test/audit/CodexNemesisSingleTierTimeoutLock.t.sol +0 -237
  55. package/test/audit/CodexRegistryMismatch.t.sol +0 -191
  56. package/test/audit/CodexTierCapMismatch.t.sol +0 -171
  57. package/test/audit/CurrencyMismatchFix.t.sol +0 -265
  58. package/test/audit/FixPendingReserveDilution.t.sol +0 -366
  59. package/test/audit/H5TierCapValidation.t.sol +0 -184
  60. package/test/audit/PendingReserveDilution.t.sol +0 -298
  61. package/test/audit/PendingReserveQuorumGrief.t.sol +0 -355
  62. package/test/audit/PendingReserveSnapshotBypass.t.sol +0 -319
  63. package/test/regression/AttestationDelegateBeneficiary.t.sol +0 -271
  64. package/test/regression/FulfillmentBlocksRatification.t.sol +0 -279
  65. package/test/regression/GracePeriodBypass.t.sol +0 -302
@@ -20,7 +20,10 @@ import {DefifaScorecard} from "./structs/DefifaScorecard.sol";
20
20
  import {DefifaTierCashOutWeight} from "./structs/DefifaTierCashOutWeight.sol";
21
21
  import {DefifaHookLib} from "./libraries/DefifaHookLib.sol";
22
22
 
23
- /// @notice Manages the ratification of Defifa scorecards.
23
+ /// @notice Manages the ratification of Defifa scorecards through token-weighted attestation. After a game ends,
24
+ /// anyone can submit a scorecard proposing cash-out weights for each tier. NFT holders attest to scorecards using
25
+ /// their voting power (proportional to NFTs held). A scorecard is ratified once it reaches quorum and survives the
26
+ /// grace period, after which the deployer applies the weights to the game's treasury.
24
27
  contract DefifaGovernor is Ownable, IDefifaGovernor {
25
28
  //*********************************************************************//
26
29
  // --------------------------- custom errors ------------------------- //
@@ -31,6 +34,7 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
31
34
  error DefifaGovernor_AlreadyRatified();
32
35
  error DefifaGovernor_DuplicateScorecard();
33
36
  error DefifaGovernor_GameNotFound();
37
+ error DefifaGovernor_GracePeriodTooShort();
34
38
  error DefifaGovernor_IncorrectTierOrder();
35
39
  error DefifaGovernor_NotAllowed();
36
40
  error DefifaGovernor_NotAttested();
@@ -45,6 +49,9 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
45
49
  /// @notice The max attestation power each tier has if every token within the tier attestations.
46
50
  uint256 public constant override MAX_ATTESTATION_POWER_TIER = 1_000_000_000;
47
51
 
52
+ /// @notice The minimum attestation grace period enforced during game initialization.
53
+ uint256 public constant override MIN_ATTESTATION_GRACE_PERIOD = 1 days;
54
+
48
55
  //*********************************************************************//
49
56
  // --------------- public immutable stored properties ---------------- //
50
57
  //*********************************************************************//
@@ -104,6 +111,12 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
104
111
  /// @custom:param tierId The tier ID (0-indexed).
105
112
  mapping(uint256 => mapping(uint256 => mapping(uint256 => uint256))) internal _scorecardTierWeightsOf;
106
113
 
114
+ /// @notice The timestamp when quorum was first reached for a scorecard.
115
+ /// @dev Reset to 0 if attestations drop below quorum via revocation.
116
+ /// @custom:param gameId The ID of the game.
117
+ /// @custom:param scorecardId The ID of the scorecard.
118
+ mapping(uint256 => mapping(uint256 => uint48)) internal _quorumReachedAtOf;
119
+
107
120
  //*********************************************************************//
108
121
  // -------------------------- constructor ---------------------------- //
109
122
  //*********************************************************************//
@@ -164,6 +177,11 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
164
177
  // Increase the attestation count.
165
178
  attestations.count += weight;
166
179
 
180
+ // Record when quorum is first reached so the timelock anchors to this moment.
181
+ if (_quorumReachedAtOf[gameId][scorecardId] == 0 && attestations.count >= scorecard.quorumSnapshot) {
182
+ _quorumReachedAtOf[gameId][scorecardId] = uint48(block.timestamp);
183
+ }
184
+
167
185
  // Store the BWA weight that was added (used for accurate subtraction on revoke).
168
186
  attestations.attestedWeightOf[msg.sender] = weight;
169
187
 
@@ -237,6 +255,12 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
237
255
  attestations.count -= weight;
238
256
  attestations.attestedWeightOf[msg.sender] = 0;
239
257
 
258
+ // Reset quorum timestamp if attestations drop below quorum.
259
+ DefifaScorecard storage scorecard = _scorecardOf[gameId][scorecardId];
260
+ if (attestations.count < scorecard.quorumSnapshot) {
261
+ _quorumReachedAtOf[gameId][scorecardId] = 0;
262
+ }
263
+
240
264
  emit AttestationRevoked(gameId, scorecardId, msg.sender, weight);
241
265
  }
242
266
 
@@ -302,17 +326,24 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
302
326
  if (scorecard.attestationsBegin != 0) revert DefifaGovernor_DuplicateScorecard();
303
327
 
304
328
  uint256 attestationStartTime = attestationStartTimeOf(gameId);
329
+
330
+ // Game phase timing is timestamp-based by design.
331
+ uint256 currentTimestamp = block.timestamp;
305
332
  uint256 timeUntilAttestationsBegin =
306
- block.timestamp > attestationStartTime ? 0 : attestationStartTime - block.timestamp;
333
+ currentTimestamp > attestationStartTime ? 0 : attestationStartTime - currentTimestamp;
307
334
 
308
335
  // Casting to uint48 is safe because block.timestamp fits in uint48 until year 8921556.
309
336
  // forge-lint: disable-next-line(unsafe-typecast)
310
- uint48 attestationsBegin = uint48(block.timestamp + timeUntilAttestationsBegin);
337
+ uint48 attestationsBegin = uint48(currentTimestamp + timeUntilAttestationsBegin);
311
338
  scorecard.attestationsBegin = attestationsBegin;
312
339
  // Grace period extends from when attestations begin, not from submission time.
313
340
  // This prevents the grace period from expiring before attestations even start
314
341
  // when a scorecard is submitted early.
315
- scorecard.gracePeriodEnds = uint48(attestationsBegin + attestationGracePeriodOf(gameId));
342
+ uint256 gracePeriodEnds = uint256(attestationsBegin) + attestationGracePeriodOf(gameId);
343
+ if (gracePeriodEnds > type(uint48).max) revert DefifaGovernor_Uint48Overflow();
344
+ // Safe after the explicit max check above.
345
+ // forge-lint: disable-next-line(unsafe-typecast)
346
+ scorecard.gracePeriodEnds = uint48(gracePeriodEnds);
316
347
 
317
348
  // Store tier weights for BWA computation.
318
349
  for (uint256 i; i < numberOfTierWeights;) {
@@ -335,9 +366,9 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
335
366
  // slither-disable-next-line calls-loop
336
367
  JB721Tier memory tier =
337
368
  hookStore.tierOf({hook: metadata.dataHook, id: tierId, includeResolvedUri: false});
369
+ // Use adjusted pending reserves that account for refund-phase burns.
338
370
  // slither-disable-next-line calls-loop
339
- uint256 pendingReserves =
340
- hookStore.numberOfPendingReservesFor({hook: metadata.dataHook, tierId: tierId});
371
+ uint256 pendingReserves = IDefifaHook(metadata.dataHook).adjustedPendingReservesFor(tierId);
341
372
  // slither-disable-next-line calls-loop
342
373
  uint256 submittedTierAttestationUnits =
343
374
  IDefifaHook(metadata.dataHook).currentSupplyOfTier(tierId) * tier.votingUnits;
@@ -457,8 +488,8 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
457
488
  // Set a default attestation start time if needed.
458
489
  if (attestationStartTime == 0) attestationStartTime = block.timestamp;
459
490
 
460
- // Enforce a minimum grace period of 1 day to prevent instant ratification.
461
- if (attestationGracePeriod < 1 days) attestationGracePeriod = 1 days;
491
+ // Enforce a minimum grace period to prevent instant ratification.
492
+ if (attestationGracePeriod < MIN_ATTESTATION_GRACE_PERIOD) revert DefifaGovernor_GracePeriodTooShort();
462
493
 
463
494
  // Ensure values fit within their allocated 48-bit widths before packing.
464
495
  if (attestationStartTime > type(uint48).max) revert DefifaGovernor_Uint48Overflow();
@@ -554,8 +585,9 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
554
585
  // When the reserve beneficiary later mints, their new NFTs add to the numerator while
555
586
  // pending reserves decrease by the same amount — so no one's voting power shifts.
556
587
  {
588
+ // Use adjusted pending reserves that account for refund-phase burns.
557
589
  // slither-disable-next-line calls-loop
558
- uint256 pendingReserves = store.numberOfPendingReservesFor({hook: metadata.dataHook, tierId: tierId});
590
+ uint256 pendingReserves = IDefifaHook(metadata.dataHook).adjustedPendingReservesFor(tierId);
559
591
  if (pendingReserves != 0) {
560
592
  // slither-disable-next-line calls-loop
561
593
  JB721Tier memory tier =
@@ -700,8 +732,9 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
700
732
  // burner erase another participant's quorum contribution without erasing their claim.
701
733
  // slither-disable-next-line calls-loop
702
734
  uint256 currentTierSupply = hook.currentSupplyOfTier(tierId);
735
+ // Use adjusted pending reserves that account for refund-phase burns.
703
736
  // slither-disable-next-line calls-loop
704
- uint256 pendingReserves = store.numberOfPendingReservesFor({hook: metadata.dataHook, tierId: tierId});
737
+ uint256 pendingReserves = hook.adjustedPendingReservesFor(tierId);
705
738
  if (currentTierSupply != 0 || pendingReserves != 0) {
706
739
  eligibleTierWeights += MAX_ATTESTATION_POWER_TIER;
707
740
  }
@@ -744,12 +777,16 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
744
777
 
745
778
  // If the scorecard has attestations beginning in the future, the state is PENDING.
746
779
  // At exactly `attestationsBegin`, attestations are open so the state is ACTIVE.
780
+ // Game phase timing is timestamp-based by design.
781
+ // forge-lint: disable-next-line(block-timestamp)
747
782
  if (scorecard.attestationsBegin > block.timestamp) {
748
783
  return DefifaScorecardState.PENDING;
749
784
  }
750
785
 
751
786
  // If the scorecard's grace period has not yet ended, the state is ACTIVE.
752
787
  // At exactly `gracePeriodEnds`, the grace period has elapsed so we fall through to the quorum check.
788
+ // Game phase timing is timestamp-based by design.
789
+ // forge-lint: disable-next-line(block-timestamp)
753
790
  if (scorecard.gracePeriodEnds > block.timestamp) {
754
791
  return DefifaScorecardState.ACTIVE;
755
792
  }
@@ -757,8 +794,16 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
757
794
  // If quorum has been reached (using the concentration-adjusted snapshot), check timelock.
758
795
  if (scorecard.quorumSnapshot <= _scorecardAttestationsOf[gameId][scorecardId].count) {
759
796
  uint256 timelockDur = timelockDurationOf(gameId);
760
- if (timelockDur > 0 && block.timestamp < uint256(scorecard.gracePeriodEnds) + timelockDur) {
761
- return DefifaScorecardState.QUEUED;
797
+ if (timelockDur > 0) {
798
+ // Anchor the timelock to the later of grace period end or when quorum was reached.
799
+ uint256 quorumReachedAt = _quorumReachedAtOf[gameId][scorecardId];
800
+ uint256 timelockAnchor =
801
+ quorumReachedAt > scorecard.gracePeriodEnds ? quorumReachedAt : uint256(scorecard.gracePeriodEnds);
802
+ // Game phase timing is timestamp-based by design.
803
+ // forge-lint: disable-next-line(block-timestamp)
804
+ if (block.timestamp < timelockAnchor + timelockDur) {
805
+ return DefifaScorecardState.QUEUED;
806
+ }
762
807
  }
763
808
  return DefifaScorecardState.SUCCEEDED;
764
809
  }
@@ -36,7 +36,10 @@ import {DefifaTierCashOutWeight} from "./structs/DefifaTierCashOutWeight.sol";
36
36
  import {DefifaGamePhase} from "./enums/DefifaGamePhase.sol";
37
37
  import {DefifaHookLib} from "./libraries/DefifaHookLib.sol";
38
38
 
39
- /// @notice A hook that transforms Juicebox treasury interactions into a Defifa game.
39
+ /// @notice The 721 hook that powers Defifa games. Extends JB721Hook to enforce game phase rules on minting and
40
+ /// cashing out, track per-tier voting power via checkpoints, and apply scorecard-determined cash-out weights after
41
+ /// ratification. Mints are only allowed during the MINT phase, refunds during the REFUND phase, and cash outs
42
+ /// with scoring weights only after a scorecard is ratified in the COMPLETE phase.
40
43
  contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
41
44
  using Checkpoints for Checkpoints.Trace208;
42
45
 
@@ -45,6 +48,7 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
45
48
  //*********************************************************************//
46
49
 
47
50
  error DefifaHook_BadTierOrder();
51
+ error DefifaHook_IdenticalTokens();
48
52
  error DefifaHook_DelegateAddressZero();
49
53
  error DefifaHook_DelegateChangesUnavailableInThisPhase();
50
54
  error DefifaHook_GameIsntScoringYet();
@@ -116,14 +120,23 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
116
120
  // --------------------- public stored properties -------------------- //
117
121
  //*********************************************************************//
118
122
 
123
+ /// @notice The amount that has been redeemed from this game, refunds are not counted.
124
+ uint256 public override amountRedeemed;
125
+
126
+ /// @notice The common base for the tokenUri's
127
+ string public override baseURI;
128
+
129
+ /// @notice A flag indicating if the cashout weights has been set.
130
+ bool public override cashOutWeightIsSet;
131
+
119
132
  /// @notice The address of the origin 'DefifaHook', used to check in the init if the contract is the original or not
120
133
  address public immutable override CODE_ORIGIN;
121
134
 
122
- /// @notice The contract that stores and manages the NFT's data.
123
- IJB721TiersHookStore public override store;
135
+ /// @notice Contract metadata uri.
136
+ string public override contractURI;
124
137
 
125
- /// @notice The contract storing all funding cycle configurations.
126
- IJBRulesets public override rulesets;
138
+ /// @notice The address that'll be set as the attestation delegate by default.
139
+ address public override defaultAttestationDelegate;
127
140
 
128
141
  /// @notice The contract reporting game phases.
129
142
  IDefifaGamePhaseReporter public override gamePhaseReporter;
@@ -138,20 +151,16 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
138
151
  /// @notice The currency that is accepted when minting tier NFTs.
139
152
  uint256 public override pricingCurrency;
140
153
 
141
- /// @notice A flag indicating if the cashout weights has been set.
142
- bool public override cashOutWeightIsSet;
143
-
144
- /// @notice The common base for the tokenUri's
145
- string public override baseURI;
146
-
147
- /// @notice Contract metadata uri.
148
- string public override contractURI;
154
+ /// @notice The number of tokens burned from a tier during non-COMPLETE phases (refund, no-contest).
155
+ /// @dev Used to adjust pending reserve counts so reserves that correspond to refunded mints
156
+ /// are excluded from the cash-out denominator.
157
+ mapping(uint256 => uint256) public refundedBurnsFrom;
149
158
 
150
- /// @notice The address that'll be set as the attestation delegate by default.
151
- address public override defaultAttestationDelegate;
159
+ /// @notice The contract storing all funding cycle configurations.
160
+ IJBRulesets public override rulesets;
152
161
 
153
- /// @notice The amount that has been redeemed from this game, refunds are not counted.
154
- uint256 public override amountRedeemed;
162
+ /// @notice The contract that stores and manages the NFT's data.
163
+ IJB721TiersHookStore public override store;
155
164
 
156
165
  /// @notice The amount of tokens that have been redeemed from a tier, refunds are not counted.
157
166
  /// @custom:param The tier from which tokens have been redeemed.
@@ -161,6 +170,41 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
161
170
  // ------------------------- external views -------------------------- //
162
171
  //*********************************************************************//
163
172
 
173
+ /// @notice Returns the adjusted pending reserve count for a tier, accounting for refund-phase burns.
174
+ /// @dev Recalculates reserves from (paidMints - burns) / reserveFrequency since the relationship
175
+ /// between burns and reserves is not linear — it depends on the tier's reserve frequency.
176
+ /// @param tierId The tier ID.
177
+ /// @return The adjusted pending reserve count (floored at 0).
178
+ // slither-disable-next-line calls-loop
179
+ function adjustedPendingReservesFor(uint256 tierId) public view returns (uint256) {
180
+ uint256 refundBurns = refundedBurnsFrom[tierId];
181
+
182
+ // If no refund burns, return the store's value directly.
183
+ if (refundBurns == 0) return store.numberOfPendingReservesFor({hook: address(this), tierId: tierId});
184
+
185
+ // Get the tier to access reserveFrequency and supply data.
186
+ JB721Tier memory tier = store.tierOf({hook: address(this), id: tierId, includeResolvedUri: false});
187
+
188
+ // No reserves if no reserve frequency.
189
+ if (tier.reserveFrequency == 0) return 0;
190
+
191
+ // Calculate the number of reserves already minted.
192
+ uint256 reservesMinted = store.numberOfReservesMintedFor({hook: address(this), tierId: tierId});
193
+
194
+ // Calculate non-reserve mints: initialSupply - remainingSupply - reservesMinted.
195
+ uint256 nonReserveMints = tier.initialSupply - tier.remainingSupply - reservesMinted;
196
+
197
+ // Subtract refund burns from non-reserve mints (burns can't exceed non-reserve mints).
198
+ uint256 adjustedMints = nonReserveMints > refundBurns ? nonReserveMints - refundBurns : 0;
199
+
200
+ // Recalculate available reserves: ceil(adjustedMints / reserveFrequency).
201
+ uint256 availableReserves = adjustedMints / tier.reserveFrequency;
202
+ if (adjustedMints % tier.reserveFrequency > 0) ++availableReserves;
203
+
204
+ // Return pending = available - already minted (floored at 0).
205
+ return availableReserves > reservesMinted ? availableReserves - reservesMinted : 0;
206
+ }
207
+
164
208
  /// @notice The first owner of each token ID, which corresponds to the address that originally contributed to the
165
209
  /// project to receive the NFT.
166
210
  /// @param tokenId The ID of the token to get the first owner of.
@@ -304,8 +348,7 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
304
348
  // slither-disable-next-line calls-loop
305
349
  cumulativeMintPrice -= hookStore.tierOfTokenId({
306
350
  hook: address(this), tokenId: decodedTokenIds[i], includeResolvedUri: false
307
- })
308
- .price;
351
+ }).price;
309
352
  }
310
353
 
311
354
  unchecked {
@@ -418,12 +461,15 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
418
461
  // If the game isn't complete, we do not have any tokens to claim.
419
462
  if (gamePhaseReporter.currentGamePhaseOf(PROJECT_ID) != DefifaGamePhase.COMPLETE) return (0, 0);
420
463
 
464
+ // Include unminted reserves in the denominator. Once reserves are pending, their future recipients are
465
+ // entitled to fee-token claims as if the reserve NFTs had already been minted; otherwise paid holders could
466
+ // claim too large a share before the reserve mint transaction lands.
421
467
  // slither-disable-next-line unused-return
422
468
  return DefifaHookLib.computeTokensClaim({
423
469
  tokenIds: tokenIds,
424
470
  hookStore: store,
425
471
  hook: address(this),
426
- totalMintCost: _totalMintCost,
472
+ totalMintCost: _totalMintCost + _pendingReserveMintCost(),
427
473
  defifaBalance: DEFIFA_TOKEN.balanceOf(address(this)),
428
474
  baseProtocolBalance: BASE_PROTOCOL_TOKEN.balanceOf(address(this))
429
475
  });
@@ -449,6 +495,8 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
449
495
  JB721Hook(_directory)
450
496
  Ownable(msg.sender)
451
497
  {
498
+ if (address(_defifaToken) == address(_baseProtocolToken)) revert DefifaHook_IdenticalTokens();
499
+
452
500
  CODE_ORIGIN = address(this);
453
501
  DEFIFA_TOKEN = _defifaToken;
454
502
  BASE_PROTOCOL_TOKEN = _baseProtocolToken;
@@ -574,6 +622,7 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
574
622
  /// @notice Mint reserved tokens within the tier for the provided value.
575
623
  /// @param tierId The ID of the tier to mint within.
576
624
  /// @param count The number of reserved tokens to mint.
625
+ // slither-disable-next-line reentrancy-benign,reentrancy-no-eth
577
626
  function mintReservesFor(uint256 tierId, uint256 count) public override {
578
627
  // Minting reserves must not be paused.
579
628
  // slither-disable-next-line calls-loop
@@ -703,13 +752,20 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
703
752
  }
704
753
 
705
754
  // Burn the token.
755
+ // slither-disable-next-line reentrancy-no-eth
706
756
  _burn(tokenId);
707
757
 
708
- // Track per-tier redemptions during the complete phase.
758
+ // slither-disable-next-line calls-loop
759
+ uint256 tierId = hookStore.tierIdOfToken(tokenId);
709
760
  if (isComplete) {
761
+ // Track per-tier redemptions during the complete phase.
762
+ unchecked {
763
+ ++tokensRedeemedFrom[tierId];
764
+ }
765
+ } else {
766
+ // Track non-COMPLETE burns (refund/no-contest) so pending reserve counts can be adjusted.
710
767
  unchecked {
711
- // slither-disable-next-line reentrancy-no-eth,calls-loop
712
- ++tokensRedeemedFrom[hookStore.tierIdOfToken(tokenId)];
768
+ ++refundedBurnsFrom[tierId];
713
769
  }
714
770
  }
715
771
 
@@ -735,7 +791,7 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
735
791
  // are accounted for, preventing paid holders from claiming a disproportionate share.
736
792
  // slither-disable-next-line reentrancy-events
737
793
  beneficiaryReceivedTokens = _claimTokensFor({
738
- beneficiary: context.holder,
794
+ beneficiary: context.beneficiary,
739
795
  shareToBeneficiary: cumulativeMintPrice,
740
796
  outOfTotal: _totalMintCost + _pendingReserveMintCost()
741
797
  });
@@ -854,11 +910,12 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
854
910
 
855
911
  for (uint256 i; i < numberOfTiers;) {
856
912
  uint256 tierId = i + 1;
857
- // slither-disable-next-line calls-loop
858
- uint256 pendingReserves = hookStore.numberOfPendingReservesFor({hook: address(this), tierId: tierId});
913
+ uint256 pendingReserves = adjustedPendingReservesFor(tierId);
859
914
  if (pendingReserves != 0) {
860
915
  // slither-disable-next-line calls-loop
861
916
  JB721Tier memory tier = hookStore.tierOf({hook: address(this), id: tierId, includeResolvedUri: false});
917
+
918
+ // Pending reserves dilute claims by the same economic weight as paid mints at this tier's price.
862
919
  cost += pendingReserves * tier.price;
863
920
  }
864
921
  unchecked {
@@ -8,9 +8,10 @@ import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Recei
8
8
 
9
9
  import {DefifaDeployer} from "./DefifaDeployer.sol";
10
10
 
11
- /// @notice A contract that can be sent a project to be burned, while still allowing defifa permissions.
12
- /// @dev Once the project NFT is transferred here, it cannot be recovered. This contract permanently
13
- /// holds the project NFT and grants SET_SPLIT_GROUPS permission to the Defifa deployer.
11
+ /// @notice A dead-end owner for Defifa project NFTs. When the project NFT is transferred here, this contract
12
+ /// permanently holds it and grants SET_SPLIT_GROUPS permission to the Defifa deployer allowing the deployer to
13
+ /// manage splits without any human having project ownership.
14
+ /// @dev Once the project NFT is transferred here, it cannot be recovered.
14
15
  contract DefifaProjectOwner is IERC721Receiver {
15
16
  //*********************************************************************//
16
17
  // --------------------------- custom errors ------------------------- //
@@ -19,7 +19,8 @@ import {DefifaGamePhase} from "./enums/DefifaGamePhase.sol";
19
19
  import {IDefifaHook} from "./interfaces/IDefifaHook.sol";
20
20
  import {IDefifaTokenUriResolver} from "./interfaces/IDefifaTokenUriResolver.sol";
21
21
 
22
- /// @notice Standard Token URIs for Defifa games.
22
+ /// @notice Generates on-chain SVG token URIs for Defifa game NFTs. Each NFT image shows the tier name, game phase,
23
+ /// and current cash-out value. Uses an on-chain typeface for rendering text within the SVG.
23
24
  contract DefifaTokenUriResolver is IDefifaTokenUriResolver, IJB721TokenUriResolver {
24
25
  using Strings for uint256;
25
26
 
@@ -70,12 +71,13 @@ contract DefifaTokenUriResolver is IDefifaTokenUriResolver, IJB721TokenUriResolv
70
71
  // Keep a reference to the rarity text;
71
72
  string memory valueText;
72
73
 
73
- // Keep a reference to the game's name.
74
+ // Keep a reference to the game's name (escaped for JSON/SVG safety).
74
75
  // TODO: Somehow make the `IDefifaHook` have the `name` function.
75
- string memory title = ERC721(address(hook)).name();
76
+ string memory titleSvg = _escapeSvg(ERC721(address(hook)).name());
76
77
 
77
78
  // Keep a reference to the tier's name.
78
- string memory team;
79
+ string memory teamJson;
80
+ string memory teamSvg;
79
81
 
80
82
  // Keep a reference to the SVG parts.
81
83
  string[] memory parts = new string[](4);
@@ -88,8 +90,10 @@ contract DefifaTokenUriResolver is IDefifaTokenUriResolver, IJB721TokenUriResolv
88
90
  JB721Tier memory tier =
89
91
  hook.store().tierOfTokenId({hook: address(hook), tokenId: tokenId, includeResolvedUri: false});
90
92
 
91
- // Set the tier's name.
92
- team = hook.tierNameOf(tier.id);
93
+ // Set the tier's name (escaped for JSON/SVG safety).
94
+ string memory rawTeam = hook.tierNameOf(tier.id);
95
+ teamJson = _escapeJson(rawTeam);
96
+ teamSvg = _escapeSvg(rawTeam);
93
97
 
94
98
  // Check to see if the tier has a URI. Return it if it does.
95
99
  if (tier.encodedIPFSUri != bytes32(0)) {
@@ -101,11 +105,11 @@ contract DefifaTokenUriResolver is IDefifaTokenUriResolver, IJB721TokenUriResolv
101
105
  parts[1] = string(
102
106
  abi.encodePacked(
103
107
  '{"name":"',
104
- team,
108
+ teamJson,
105
109
  '", "id": "',
106
110
  uint256(tier.id).toString(),
107
111
  '","description":"Team: ',
108
- team,
112
+ teamJson,
109
113
  ", ID: ",
110
114
  uint256(tier.id).toString(),
111
115
  '.","image":"data:image/svg+xml;base64,'
@@ -201,27 +205,27 @@ contract DefifaTokenUriResolver is IDefifaTokenUriResolver, IJB721TokenUriResolv
201
205
  gamePhaseText,
202
206
  "</text>",
203
207
  '<text x="10" y="85" style="font-size:26px; font-family: Capsules-500; font-weight:500; fill: #c0b3f1;">',
204
- _getSubstring(title, 0, 30),
208
+ _getSubstring(titleSvg, 0, 30),
205
209
  "</text>",
206
210
  '<text x="10" y="120" style="font-size:26px; font-family: Capsules-500; font-weight:500; fill: #c0b3f1;">',
207
- _getSubstring(title, 30, 60),
211
+ _getSubstring(titleSvg, 30, 60),
208
212
  "</text>",
209
213
  '<text x="10" y="205" style="font-size:80px; font-family: Capsules-700; font-weight:700; fill: #fea282;">',
210
- bytes(_getSubstring(team, 20, 30)).length != 0 && bytes(_getSubstring(team, 10, 20)).length != 0
211
- ? _getSubstring(team, 0, 10)
214
+ bytes(_getSubstring(teamSvg, 20, 30)).length != 0 && bytes(_getSubstring(teamSvg, 10, 20)).length != 0
215
+ ? _getSubstring(teamSvg, 0, 10)
212
216
  : "",
213
217
  "</text>",
214
218
  '<text x="10" y="295" style="font-size:80px; font-family: Capsules-700; font-weight:700; fill: #fea282;">',
215
- bytes(_getSubstring(team, 20, 30)).length != 0
216
- ? _getSubstring(team, 10, 20)
217
- : bytes(_getSubstring(team, 10, 20)).length != 0 ? _getSubstring(team, 0, 10) : "",
219
+ bytes(_getSubstring(teamSvg, 20, 30)).length != 0
220
+ ? _getSubstring(teamSvg, 10, 20)
221
+ : bytes(_getSubstring(teamSvg, 10, 20)).length != 0 ? _getSubstring(teamSvg, 0, 10) : "",
218
222
  "</text>",
219
223
  '<text x="10" y="385" style="font-size:80px; font-family: Capsules-700; font-weight:700; fill: #fea282;">',
220
- bytes(_getSubstring(team, 20, 30)).length != 0
221
- ? _getSubstring(team, 20, 30)
222
- : bytes(_getSubstring(team, 10, 20)).length != 0
223
- ? _getSubstring(team, 10, 20)
224
- : _getSubstring(team, 0, 10),
224
+ bytes(_getSubstring(teamSvg, 20, 30)).length != 0
225
+ ? _getSubstring(teamSvg, 20, 30)
226
+ : bytes(_getSubstring(teamSvg, 10, 20)).length != 0
227
+ ? _getSubstring(teamSvg, 10, 20)
228
+ : _getSubstring(teamSvg, 0, 10),
225
229
  "</text>",
226
230
  '<text x="10" y="430" style="font-size:16px; font-family: Capsules-500; font-weight:500; fill: #c0b3f1;">TOKEN ID: ',
227
231
  tokenId.toString(),
@@ -312,4 +316,93 @@ contract DefifaTokenUriResolver is IDefifaTokenUriResolver, IJB721TokenUriResolv
312
316
  }
313
317
  return string(result);
314
318
  }
319
+
320
+ /// @notice Escapes special characters for safe JSON string interpolation.
321
+ /// @param input The raw string.
322
+ /// @return The escaped string.
323
+ function _escapeJson(string memory input) internal pure returns (string memory) {
324
+ bytes memory b = bytes(input);
325
+ // Worst case: every char needs escaping (2x length).
326
+ bytes memory out = new bytes(b.length * 2);
327
+ uint256 j;
328
+ for (uint256 i; i < b.length;) {
329
+ bytes1 c = b[i];
330
+ if (c == '"' || c == "\\") {
331
+ out[j++] = "\\";
332
+ out[j++] = c;
333
+ } else if (c == 0x0a) {
334
+ // newline
335
+ out[j++] = "\\";
336
+ out[j++] = "n";
337
+ } else if (c == 0x0d) {
338
+ // carriage return
339
+ out[j++] = "\\";
340
+ out[j++] = "r";
341
+ } else {
342
+ out[j++] = c;
343
+ }
344
+ unchecked {
345
+ ++i;
346
+ }
347
+ }
348
+ // Trim to actual length.
349
+ bytes memory trimmed = new bytes(j);
350
+ for (uint256 k; k < j;) {
351
+ trimmed[k] = out[k];
352
+ unchecked {
353
+ ++k;
354
+ }
355
+ }
356
+ return string(trimmed);
357
+ }
358
+
359
+ /// @notice Escapes special characters for safe SVG text interpolation.
360
+ /// @param input The raw string.
361
+ /// @return The escaped string.
362
+ function _escapeSvg(string memory input) internal pure returns (string memory) {
363
+ bytes memory b = bytes(input);
364
+ // Worst case: each char becomes 5 chars (&amp;).
365
+ bytes memory out = new bytes(b.length * 5);
366
+ uint256 j;
367
+ for (uint256 i; i < b.length;) {
368
+ bytes1 c = b[i];
369
+ if (c == "&") {
370
+ out[j++] = "&";
371
+ out[j++] = "a";
372
+ out[j++] = "m";
373
+ out[j++] = "p";
374
+ out[j++] = ";";
375
+ } else if (c == "<") {
376
+ out[j++] = "&";
377
+ out[j++] = "l";
378
+ out[j++] = "t";
379
+ out[j++] = ";";
380
+ } else if (c == ">") {
381
+ out[j++] = "&";
382
+ out[j++] = "g";
383
+ out[j++] = "t";
384
+ out[j++] = ";";
385
+ } else if (c == '"') {
386
+ out[j++] = "&";
387
+ out[j++] = "q";
388
+ out[j++] = "u";
389
+ out[j++] = "o";
390
+ out[j++] = "t";
391
+ out[j++] = ";";
392
+ } else {
393
+ out[j++] = c;
394
+ }
395
+ unchecked {
396
+ ++i;
397
+ }
398
+ }
399
+ bytes memory trimmed = new bytes(j);
400
+ for (uint256 k; k < j;) {
401
+ trimmed[k] = out[k];
402
+ unchecked {
403
+ ++k;
404
+ }
405
+ }
406
+ return string(trimmed);
407
+ }
315
408
  }
@@ -1,6 +1,12 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity ^0.8.0;
3
3
 
4
+ /// @notice The lifecycle phases of a Defifa game.
5
+ /// COUNTDOWN — before minting opens. MINT — players can mint tier NFTs. REFUND — minting closed but refunds
6
+ /// allowed.
7
+ /// SCORING — event has ended, scorecards can be submitted and attested. COMPLETE — scorecard ratified, cash outs
8
+ /// open.
9
+ /// NO_CONTEST — game voided (minimum participation not met or scorecard timed out), full refunds available.
4
10
  enum DefifaGamePhase {
5
11
  COUNTDOWN,
6
12
  MINT,
@@ -1,6 +1,10 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity ^0.8.0;
3
3
 
4
+ /// @notice The governance lifecycle of a submitted scorecard.
5
+ /// PENDING — submitted but attestation period hasn't started. ACTIVE — accepting attestations.
6
+ /// DEFEATED — failed to reach quorum. SUCCEEDED — reached quorum, in grace period.
7
+ /// QUEUED — grace period passed, awaiting application. RATIFIED — applied to the game's cash-out weights.
4
8
  enum DefifaScorecardState {
5
9
  PENDING,
6
10
  ACTIVE,
@@ -2,6 +2,7 @@
2
2
  pragma solidity ^0.8.0;
3
3
 
4
4
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
5
+ import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
5
6
  import {IJB721TokenUriResolver} from "@bananapus/721-hook-v6/src/interfaces/IJB721TokenUriResolver.sol";
6
7
  import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
7
8
  import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
@@ -88,6 +89,10 @@ interface IDefifaDeployer {
88
89
  /// @return The code origin address.
89
90
  function HOOK_CODE_ORIGIN() external view returns (address);
90
91
 
92
+ /// @notice The 721 tiers hook store used by all games.
93
+ /// @return The hook store contract.
94
+ function HOOK_STORE() external view returns (IJB721TiersHookStore);
95
+
91
96
  /// @notice The address registry used for content-addressable deployment lookups.
92
97
  /// @return The address registry contract.
93
98
  function REGISTRY() external view returns (IJBAddressRegistry);
@@ -3,6 +3,10 @@ pragma solidity ^0.8.0;
3
3
 
4
4
  import {DefifaGamePhase} from "../enums/DefifaGamePhase.sol";
5
5
 
6
+ /// @notice Reports the current lifecycle phase of a Defifa game.
6
7
  interface IDefifaGamePhaseReporter {
8
+ /// @notice The current phase of a game (COUNTDOWN, MINT, REFUND, SCORING, COMPLETE, or NO_CONTEST).
9
+ /// @param gameId The ID of the game.
10
+ /// @return The current game phase.
7
11
  function currentGamePhaseOf(uint256 gameId) external view returns (DefifaGamePhase);
8
12
  }