@ballkidz/defifa 0.0.24 → 0.0.26

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