@ballkidz/defifa 0.0.12 → 0.0.14

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 (44) hide show
  1. package/ADMINISTRATION.md +3 -3
  2. package/ARCHITECTURE.md +3 -2
  3. package/AUDIT_INSTRUCTIONS.md +5 -5
  4. package/CHANGE_LOG.md +62 -5
  5. package/CRYPTO_ECON.md +506 -271
  6. package/CRYPTO_ECON.pdf +0 -0
  7. package/CRYPTO_ECON.tex +438 -241
  8. package/RISKS.md +13 -1
  9. package/SKILLS.md +5 -3
  10. package/USER_JOURNEYS.md +4 -3
  11. package/package.json +6 -6
  12. package/src/DefifaDeployer.sol +128 -130
  13. package/src/DefifaGovernor.sol +304 -83
  14. package/src/DefifaHook.sol +184 -171
  15. package/src/enums/DefifaScorecardState.sol +1 -0
  16. package/src/interfaces/IDefifaGovernor.sol +42 -2
  17. package/src/libraries/DefifaHookLib.sol +69 -62
  18. package/src/structs/DefifaAttestations.sol +3 -3
  19. package/src/structs/DefifaLaunchProjectData.sol +1 -0
  20. package/src/structs/DefifaScorecard.sol +2 -0
  21. package/test/BWAFunctionComparison.t.sol +1320 -0
  22. package/test/DefifaAdversarialQuorum.t.sol +52 -37
  23. package/test/DefifaAuditLowGuards.t.sol +9 -5
  24. package/test/DefifaFeeAccounting.t.sol +2 -1
  25. package/test/DefifaGovernanceHardening.t.sol +1315 -0
  26. package/test/DefifaGovernor.t.sol +8 -4
  27. package/test/DefifaHookRegressions.t.sol +2 -1
  28. package/test/DefifaMintCostInvariant.t.sol +2 -1
  29. package/test/DefifaNoContest.t.sol +3 -2
  30. package/test/DefifaSecurity.t.sol +55 -47
  31. package/test/DefifaUSDC.t.sol +3 -2
  32. package/test/Fork.t.sol +37 -32
  33. package/test/TestAuditGaps.sol +6 -4
  34. package/test/TestQALastMile.t.sol +6 -3
  35. package/test/audit/{CodexAttestationDoubleCount.t.sol → AttestationDoubleCount.t.sol} +3 -2
  36. package/test/audit/FixPendingReserveDilution.t.sol +366 -0
  37. package/test/audit/PendingReserveDilution.t.sol +298 -0
  38. package/test/audit/PendingReserveQuorumGrief.t.sol +355 -0
  39. package/test/audit/PendingReserveSnapshotBypass.t.sol +279 -0
  40. package/test/regression/AttestationDelegateBeneficiary.t.sol +2 -1
  41. package/test/regression/FulfillmentBlocksRatification.t.sol +2 -1
  42. package/test/regression/GracePeriodBypass.t.sol +2 -1
  43. package/test/SVG.t.sol +0 -164
  44. package/test/deployScript.t.sol +0 -144
@@ -12,6 +12,8 @@ import {DefifaGamePhase} from "./enums/DefifaGamePhase.sol";
12
12
  import {DefifaScorecardState} from "./enums/DefifaScorecardState.sol";
13
13
  import {IDefifaDeployer} from "./interfaces/IDefifaDeployer.sol";
14
14
  import {IDefifaGovernor} from "./interfaces/IDefifaGovernor.sol";
15
+ import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
16
+ import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
15
17
  import {IDefifaHook} from "./interfaces/IDefifaHook.sol";
16
18
  import {DefifaAttestations} from "./structs/DefifaAttestations.sol";
17
19
  import {DefifaScorecard} from "./structs/DefifaScorecard.sol";
@@ -30,6 +32,7 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
30
32
  error DefifaGovernor_GameNotFound();
31
33
  error DefifaGovernor_IncorrectTierOrder();
32
34
  error DefifaGovernor_NotAllowed();
35
+ error DefifaGovernor_NotAttested();
33
36
  error DefifaGovernor_Uint48Overflow();
34
37
  error DefifaGovernor_UnknownProposal();
35
38
  error DefifaGovernor_UnownedProposedCashoutValue();
@@ -65,6 +68,7 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
65
68
  //*********************************************************************//
66
69
 
67
70
  /// @notice The scorecard information, packed into a uint256.
71
+ /// @dev Bits 0-47: attestationStartTime, bits 48-95: attestationGracePeriod, bits 96-143: timelockDuration.
68
72
  /// @custom:param gameId The ID of the game for which the scorecard info applies.
69
73
  mapping(uint256 => uint256) internal _packedScorecardInfoOf;
70
74
 
@@ -78,6 +82,19 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
78
82
  /// @custom:param scorecardId The ID of the scorecard that has been attested to.
79
83
  mapping(uint256 => mapping(uint256 => DefifaAttestations)) internal _scorecardAttestationsOf;
80
84
 
85
+ /// @notice Snapshot of pending reserves per tier at scorecard submission time.
86
+ /// @dev Prevents reserve dilution between submission and attestation.
87
+ /// @custom:param gameId The ID of the game.
88
+ /// @custom:param scorecardId The ID of the scorecard.
89
+ /// @custom:param tierId The tier ID (1-indexed).
90
+ mapping(uint256 => mapping(uint256 => mapping(uint256 => uint256))) internal _pendingReservesSnapshotOf;
91
+
92
+ /// @notice Tier weights per scorecard for BWA computation.
93
+ /// @custom:param gameId The ID of the game.
94
+ /// @custom:param scorecardId The ID of the scorecard.
95
+ /// @custom:param tierId The tier ID (0-indexed).
96
+ mapping(uint256 => mapping(uint256 => mapping(uint256 => uint256))) internal _scorecardTierWeightsOf;
97
+
81
98
  //*********************************************************************//
82
99
  // -------------------------- constructor ---------------------------- //
83
100
  //*********************************************************************//
@@ -97,37 +114,48 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
97
114
  function attestToScorecardFrom(uint256 gameId, uint256 scorecardId) external override returns (uint256 weight) {
98
115
  // Get the game's current funding cycle along with its metadata.
99
116
  // slither-disable-next-line unused-return
100
- (, JBRulesetMetadata memory _metadata) = CONTROLLER.currentRulesetOf(gameId);
117
+ (, JBRulesetMetadata memory metadata) = CONTROLLER.currentRulesetOf(gameId);
101
118
 
102
119
  // Make sure the game is in its scoring phase.
103
- if (IDefifaHook(_metadata.dataHook).gamePhaseReporter().currentGamePhaseOf(gameId) != DefifaGamePhase.SCORING) {
120
+ if (IDefifaHook(metadata.dataHook).gamePhaseReporter().currentGamePhaseOf(gameId) != DefifaGamePhase.SCORING) {
104
121
  revert DefifaGovernor_NotAllowed();
105
122
  }
106
123
 
107
124
  // Keep a reference to the scorecard being attested to.
108
- DefifaScorecard storage _scorecard = _scorecardOf[gameId][scorecardId];
125
+ DefifaScorecard storage scorecard = _scorecardOf[gameId][scorecardId];
109
126
 
110
127
  // Keep a reference to the scorecard state.
111
- DefifaScorecardState _state = stateOf({gameId: gameId, scorecardId: scorecardId});
128
+ DefifaScorecardState state = stateOf({gameId: gameId, scorecardId: scorecardId});
112
129
 
113
- if (_state != DefifaScorecardState.ACTIVE && _state != DefifaScorecardState.SUCCEEDED) {
130
+ if (
131
+ state != DefifaScorecardState.ACTIVE && state != DefifaScorecardState.SUCCEEDED
132
+ && state != DefifaScorecardState.QUEUED
133
+ ) {
114
134
  revert DefifaGovernor_NotAllowed();
115
135
  }
116
136
 
117
137
  // Keep a reference to the attestations for the scorecard.
118
- DefifaAttestations storage _attestations = _scorecardAttestationsOf[gameId][scorecardId];
138
+ DefifaAttestations storage attestations = _scorecardAttestationsOf[gameId][scorecardId];
119
139
 
120
140
  // Make sure the account isn't attesting to the same scorecard again.
121
- if (_attestations.hasAttested[msg.sender]) revert DefifaGovernor_AlreadyAttested();
141
+ if (attestations.attestedWeightOf[msg.sender] != 0) revert DefifaGovernor_AlreadyAttested();
122
142
 
123
- // Get a reference to the attestation weight.
124
- weight = getAttestationWeight({gameId: gameId, account: msg.sender, timestamp: _scorecard.attestationsBegin});
143
+ // Get a reference to the BWA-adjusted attestation weight, snapshotted at one second before
144
+ // `attestationsBegin`. Using `attestationsBegin - 1` ensures the checkpoint is from before the
145
+ // attestation window opens, preventing same-block transfer/re-attest exploits.
146
+ weight = getBWAAttestationWeight({
147
+ gameId: gameId, scorecardId: scorecardId, account: msg.sender, timestamp: scorecard.attestationsBegin - 1
148
+ });
149
+
150
+ // Revert if BWA reduces this account's power to zero (e.g. 100% beneficiary of the scorecard).
151
+ // Without this guard, zero-weight attestors could call repeatedly since attestedWeightOf stays 0.
152
+ if (weight == 0) revert DefifaGovernor_NotAllowed();
125
153
 
126
154
  // Increase the attestation count.
127
- _attestations.count += weight;
155
+ attestations.count += weight;
128
156
 
129
- // Store the fact that the account has attested to the scorecard.
130
- _attestations.hasAttested[msg.sender] = true;
157
+ // Store the BWA weight that was added (used for accurate subtraction on revoke).
158
+ attestations.attestedWeightOf[msg.sender] = weight;
131
159
 
132
160
  emit ScorecardAttested(gameId, scorecardId, weight, msg.sender);
133
161
  }
@@ -149,13 +177,13 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
149
177
 
150
178
  // Get the game's current funding cycle along with its metadata.
151
179
  // slither-disable-next-line unused-return
152
- (, JBRulesetMetadata memory _metadata) = CONTROLLER.currentRulesetOf(gameId);
180
+ (, JBRulesetMetadata memory metadata) = CONTROLLER.currentRulesetOf(gameId);
153
181
 
154
182
  // Build the calldata to the target
155
- bytes memory _calldata = _buildScorecardCalldataFor(tierWeights);
183
+ bytes memory scorecardCalldata = _buildScorecardCalldataFor(tierWeights);
156
184
 
157
185
  // Attempt to execute the proposal.
158
- scorecardId = _hashScorecardOf({gameHook: _metadata.dataHook, calldataBytes: _calldata});
186
+ scorecardId = _hashScorecardOf({gameHook: metadata.dataHook, calldataBytes: scorecardCalldata});
159
187
 
160
188
  // Make sure the proposal being ratified has succeeded.
161
189
  if (stateOf({gameId: gameId, scorecardId: scorecardId}) != DefifaScorecardState.SUCCEEDED) {
@@ -166,7 +194,7 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
166
194
  ratifiedScorecardIdOf[gameId] = scorecardId;
167
195
 
168
196
  // Execute the scorecard via low-level call since the governor is the delegate's owner.
169
- (bool success, bytes memory returndata) = _metadata.dataHook.call(_calldata);
197
+ (bool success, bytes memory returndata) = metadata.dataHook.call(scorecardCalldata);
170
198
  // slither-disable-next-line unused-return
171
199
  Address.verifyCallResult({success: success, returndata: returndata});
172
200
 
@@ -178,6 +206,28 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
178
206
  emit ScorecardRatified(gameId, scorecardId, msg.sender);
179
207
  }
180
208
 
209
+ /// @notice Revoke a previously submitted attestation. Only allowed during the ACTIVE phase.
210
+ /// @dev Once a scorecard enters QUEUED (grace period ended + quorum met), revocations are disabled.
211
+ /// This prevents the griefing loop (attest/revoke cycling) while allowing corrective action during debate.
212
+ /// @param gameId The ID of the game.
213
+ /// @param scorecardId The ID of the scorecard to revoke attestation from.
214
+ function revokeAttestationFrom(uint256 gameId, uint256 scorecardId) external virtual override {
215
+ // Only allow revocation during ACTIVE phase.
216
+ if (stateOf(gameId, scorecardId) != DefifaScorecardState.ACTIVE) revert DefifaGovernor_NotAllowed();
217
+
218
+ DefifaAttestations storage attestations = _scorecardAttestationsOf[gameId][scorecardId];
219
+ uint256 weight = attestations.attestedWeightOf[msg.sender];
220
+
221
+ // Must have previously attested.
222
+ if (weight == 0) revert DefifaGovernor_NotAttested();
223
+
224
+ // Subtract the weight and clear the attestation.
225
+ attestations.count -= weight;
226
+ attestations.attestedWeightOf[msg.sender] = 0;
227
+
228
+ emit AttestationRevoked(gameId, scorecardId, msg.sender, weight);
229
+ }
230
+
181
231
  /// @notice Submits a scorecard to be attested to.
182
232
  /// @param gameId The ID of the game.
183
233
  /// @param tierWeights The weights of each tier in the scorecard.
@@ -198,22 +248,22 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
198
248
  if (_packedScorecardInfoOf[gameId] == 0) revert DefifaGovernor_GameNotFound();
199
249
 
200
250
  // Make sure no weight is assigned to an unowned tier.
201
- uint256 _numberOfTierWeights = tierWeights.length;
251
+ uint256 numberOfTierWeights = tierWeights.length;
202
252
 
203
253
  // Get the game's current funding cycle along with its metadata.
204
254
  // slither-disable-next-line unused-return
205
- (, JBRulesetMetadata memory _metadata) = CONTROLLER.currentRulesetOf(gameId);
255
+ (, JBRulesetMetadata memory metadata) = CONTROLLER.currentRulesetOf(gameId);
206
256
 
207
257
  // Make sure the game is in its scoring phase.
208
- if (IDefifaHook(_metadata.dataHook).gamePhaseReporter().currentGamePhaseOf(gameId) != DefifaGamePhase.SCORING) {
258
+ if (IDefifaHook(metadata.dataHook).gamePhaseReporter().currentGamePhaseOf(gameId) != DefifaGamePhase.SCORING) {
209
259
  revert DefifaGovernor_NotAllowed();
210
260
  }
211
261
 
212
262
  // If there's a weight assigned to the tier, make sure there is a token backed by it.
213
- for (uint256 _i; _i < _numberOfTierWeights; _i++) {
263
+ for (uint256 i; i < numberOfTierWeights; i++) {
214
264
  if (
215
- tierWeights[_i].cashOutWeight > 0
216
- && IDefifaHook(_metadata.dataHook).currentSupplyOfTier(tierWeights[_i].id) == 0
265
+ tierWeights[i].cashOutWeight > 0
266
+ && IDefifaHook(metadata.dataHook).currentSupplyOfTier(tierWeights[i].id) == 0
217
267
  ) {
218
268
  revert DefifaGovernor_UnownedProposedCashoutValue();
219
269
  }
@@ -221,34 +271,81 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
221
271
 
222
272
  // Hash the scorecard.
223
273
  scorecardId =
224
- _hashScorecardOf({gameHook: _metadata.dataHook, calldataBytes: _buildScorecardCalldataFor(tierWeights)});
274
+ _hashScorecardOf({gameHook: metadata.dataHook, calldataBytes: _buildScorecardCalldataFor(tierWeights)});
225
275
 
226
276
  // Store the scorecard
227
- DefifaScorecard storage _scorecard = _scorecardOf[gameId][scorecardId];
228
- if (_scorecard.attestationsBegin != 0) revert DefifaGovernor_DuplicateScorecard();
277
+ DefifaScorecard storage scorecard = _scorecardOf[gameId][scorecardId];
278
+ if (scorecard.attestationsBegin != 0) revert DefifaGovernor_DuplicateScorecard();
229
279
 
230
- uint256 _attestationStartTime = attestationStartTimeOf(gameId);
231
- uint256 _timeUntilAttestationsBegin =
232
- block.timestamp > _attestationStartTime ? 0 : _attestationStartTime - block.timestamp;
280
+ uint256 attestationStartTime = attestationStartTimeOf(gameId);
281
+ uint256 timeUntilAttestationsBegin =
282
+ block.timestamp > attestationStartTime ? 0 : attestationStartTime - block.timestamp;
233
283
 
234
284
  // Casting to uint48 is safe because block.timestamp fits in uint48 until year 8921556.
235
285
  // forge-lint: disable-next-line(unsafe-typecast)
236
- uint48 _attestationsBegin = uint48(block.timestamp + _timeUntilAttestationsBegin);
237
- _scorecard.attestationsBegin = _attestationsBegin;
286
+ uint48 attestationsBegin = uint48(block.timestamp + timeUntilAttestationsBegin);
287
+ scorecard.attestationsBegin = attestationsBegin;
238
288
  // Grace period extends from when attestations begin, not from submission time.
239
289
  // This prevents the grace period from expiring before attestations even start
240
290
  // when a scorecard is submitted early.
241
- _scorecard.gracePeriodEnds = uint48(_attestationsBegin + attestationGracePeriodOf(gameId));
291
+ scorecard.gracePeriodEnds = uint48(attestationsBegin + attestationGracePeriodOf(gameId));
292
+
293
+ // Store tier weights for BWA computation.
294
+ for (uint256 i; i < numberOfTierWeights; i++) {
295
+ _scorecardTierWeightsOf[gameId][scorecardId][tierWeights[i].id - 1] = tierWeights[i].cashOutWeight;
296
+ }
297
+
298
+ // Snapshot pending reserves for each tier at submission time.
299
+ // This prevents reserve minting between submission and attestation from diluting votes.
300
+ {
301
+ IJB721TiersHookStore _store = IDefifaHook(metadata.dataHook).store();
302
+ uint256 _numberOfTiers = _store.maxTierIdOf(metadata.dataHook);
303
+ for (uint256 i; i < _numberOfTiers; i++) {
304
+ uint256 tierId = i + 1;
305
+ _pendingReservesSnapshotOf[gameId][scorecardId][tierId] =
306
+ _store.numberOfPendingReservesFor(metadata.dataHook, tierId);
307
+ }
308
+ }
309
+
310
+ // Concentration-adjusted quorum: penalty = headroom * maxShare².
311
+ // Headroom = max achievable BWA - base quorum = (N-2) * MAX / 2.
312
+ // This is the gap honest attestors can fill above base quorum.
313
+ // maxShare² is nonlinear: gentle for moderate concentration, steep for extreme.
314
+ // The penalty can never exceed headroom, so quorum is always reachable by non-beneficiaries.
315
+ uint256 baseQuorum = quorum(gameId);
316
+ uint256 adjustedQuorum = baseQuorum;
317
+
318
+ if (baseQuorum >= MAX_ATTESTATION_POWER_TIER) {
319
+ // headroom = maxBWA - baseQuorum = (N-1)*MAX - N*MAX/2 = (N-2)*MAX/2.
320
+ uint256 headroom = baseQuorum - MAX_ATTESTATION_POWER_TIER;
321
+ // Subtract numberOfTierWeights to account for per-tier mulDiv truncation in BWA.
322
+ if (headroom > numberOfTierWeights) headroom -= numberOfTierWeights;
323
+
324
+ // Find the largest tier weight.
325
+ uint256 totalCashOutWeight = IDefifaHook(metadata.dataHook).TOTAL_CASHOUT_WEIGHT();
326
+ uint256 maxWeight;
327
+ for (uint256 i; i < numberOfTierWeights; i++) {
328
+ if (tierWeights[i].cashOutWeight > maxWeight) maxWeight = tierWeights[i].cashOutWeight;
329
+ }
330
+
331
+ // maxShare² in totalCashOutWeight scale (nonlinear: gentle for moderate, steep for extreme).
332
+ uint256 maxShareSquared = mulDiv(maxWeight, maxWeight, totalCashOutWeight);
333
+
334
+ // Penalty fills headroom proportional to concentration².
335
+ adjustedQuorum += mulDiv(headroom, maxShareSquared, totalCashOutWeight);
336
+ }
337
+
338
+ scorecard.quorumSnapshot = adjustedQuorum;
242
339
 
243
340
  // Keep a reference to the default attestation delegate.
244
- address _defaultAttestationDelegate = IDefifaHook(_metadata.dataHook).defaultAttestationDelegate();
341
+ address defaultAttestationDelegate = IDefifaHook(metadata.dataHook).defaultAttestationDelegate();
245
342
 
246
343
  // If the scorecard is being sent from the default attestation delegate, store it.
247
- if (msg.sender == _defaultAttestationDelegate) {
344
+ if (msg.sender == defaultAttestationDelegate) {
248
345
  defaultAttestationDelegateProposalOf[gameId] = scorecardId;
249
346
  }
250
347
 
251
- emit ScorecardSubmitted(gameId, scorecardId, tierWeights, msg.sender == _defaultAttestationDelegate, msg.sender);
348
+ emit ScorecardSubmitted(gameId, scorecardId, tierWeights, msg.sender == defaultAttestationDelegate, msg.sender);
252
349
  }
253
350
 
254
351
  //*********************************************************************//
@@ -269,7 +366,7 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
269
366
  /// @param account The address to check the attestation status of.
270
367
  /// @return A flag indicating if the given account has already attested to the scorecard.
271
368
  function hasAttestedTo(uint256 gameId, uint256 scorecardId, address account) external view returns (bool) {
272
- return _scorecardAttestationsOf[gameId][scorecardId].hasAttested[account];
369
+ return _scorecardAttestationsOf[gameId][scorecardId].attestedWeightOf[account] != 0;
273
370
  }
274
371
 
275
372
  /// @notice The ID of a scorecard representing the provided tier weights.
@@ -297,10 +394,12 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
297
394
  /// @param attestationStartTime The amount of time between a scorecard being submitted and attestations to it being
298
395
  /// enabled, measured in seconds.
299
396
  /// @param attestationGracePeriod The amount of time that must go by before a scorecard can be ratified.
397
+ /// @param timelockDuration The cooling period after quorum is met before a scorecard can be ratified.
300
398
  function initializeGame(
301
399
  uint256 gameId,
302
400
  uint256 attestationStartTime,
303
- uint256 attestationGracePeriod
401
+ uint256 attestationGracePeriod,
402
+ uint256 timelockDuration
304
403
  )
305
404
  public
306
405
  virtual
@@ -319,18 +418,21 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
319
418
  // Ensure values fit within their allocated 48-bit widths before packing.
320
419
  if (attestationStartTime > type(uint48).max) revert DefifaGovernor_Uint48Overflow();
321
420
  if (attestationGracePeriod > type(uint48).max) revert DefifaGovernor_Uint48Overflow();
421
+ if (timelockDuration > type(uint48).max) revert DefifaGovernor_Uint48Overflow();
322
422
 
323
423
  // Pack the values.
324
- uint256 _packed;
424
+ uint256 packed;
325
425
  // attestation start time in bits 0-47 (48 bits).
326
- _packed |= attestationStartTime;
426
+ packed |= attestationStartTime;
327
427
  // attestation grace period in bits 48-95 (48 bits).
328
- _packed |= attestationGracePeriod << 48;
428
+ packed |= attestationGracePeriod << 48;
429
+ // timelock duration in bits 96-143 (48 bits).
430
+ packed |= timelockDuration << 96;
329
431
 
330
432
  // Store the packed value.
331
- _packedScorecardInfoOf[gameId] = _packed;
433
+ _packedScorecardInfoOf[gameId] = packed;
332
434
 
333
- emit GameInitialized(gameId, attestationStartTime, attestationGracePeriod, msg.sender);
435
+ emit GameInitialized(gameId, attestationStartTime, attestationGracePeriod, timelockDuration, msg.sender);
334
436
  }
335
437
 
336
438
  //*********************************************************************//
@@ -379,68 +481,173 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
379
481
  {
380
482
  // Get the game's current funding cycle along with its metadata.
381
483
  // slither-disable-next-line unused-return
382
- (, JBRulesetMetadata memory _metadata) = CONTROLLER.currentRulesetOf(gameId);
484
+ (, JBRulesetMetadata memory metadata) = CONTROLLER.currentRulesetOf(gameId);
485
+
486
+ // Get a reference to the hook and its store.
487
+ IDefifaHook hook = IDefifaHook(metadata.dataHook);
488
+ IJB721TiersHookStore store = hook.store();
383
489
 
384
490
  // Get a reference to the number of tiers.
385
- uint256 _numberOfTiers = IDefifaHook(_metadata.dataHook).store().maxTierIdOf(_metadata.dataHook);
491
+ uint256 numberOfTiers = store.maxTierIdOf(metadata.dataHook);
386
492
 
387
493
  // slither-disable-next-line calls-inside-a-loop
388
- for (uint256 _i; _i < _numberOfTiers; _i++) {
494
+ for (uint256 i; i < numberOfTiers; i++) {
389
495
  // Tiers are 1-indexed.
390
- uint256 _tierId = _i + 1;
496
+ uint256 tierId = i + 1;
391
497
 
392
498
  // Get this account's attestation units within the tier (snapshot at timestamp).
393
- uint256 _tierAttestationUnitsForAccount = IDefifaHook(_metadata.dataHook)
394
- .getPastTierAttestationUnitsOf({account: account, tier: _tierId, timestamp: timestamp});
499
+ uint256 tierAttestationUnitsForAccount =
500
+ hook.getPastTierAttestationUnitsOf({account: account, tier: tierId, timestamp: timestamp});
501
+
502
+ // Get the total attestation units for this tier (snapshot at timestamp).
503
+ uint256 tierTotalAttestationUnits =
504
+ hook.getPastTierTotalAttestationUnitsOf({tier: tierId, timestamp: timestamp});
505
+
506
+ // Include unminted pending reserves in the total (denominator only). This ensures every
507
+ // token holder's voting power already accounts for reserves that will eventually be minted.
508
+ // When the reserve beneficiary later mints, their new NFTs add to the numerator while
509
+ // pending reserves decrease by the same amount — so no one's voting power shifts.
510
+ {
511
+ uint256 pendingReserves = store.numberOfPendingReservesFor(metadata.dataHook, tierId);
512
+ if (pendingReserves != 0) {
513
+ JB721Tier memory tier =
514
+ store.tierOf({hook: metadata.dataHook, id: tierId, includeResolvedUri: false});
515
+ tierTotalAttestationUnits += pendingReserves * tier.votingUnits;
516
+ }
517
+ }
395
518
 
396
519
  // Scale the account's share of the tier to MAX_ATTESTATION_POWER_TIER.
397
520
  // e.g. holding 3 of 10 tokens → 3/10 * MAX_ATTESTATION_POWER_TIER attestation power from this tier.
398
521
  unchecked {
399
- if (_tierAttestationUnitsForAccount != 0) {
522
+ if (tierAttestationUnitsForAccount != 0) {
400
523
  attestationPower += mulDiv({
401
524
  x: MAX_ATTESTATION_POWER_TIER,
402
- y: _tierAttestationUnitsForAccount,
403
- denominator: IDefifaHook(_metadata.dataHook)
404
- .getPastTierTotalAttestationUnitsOf({tier: _tierId, timestamp: timestamp})
525
+ y: tierAttestationUnitsForAccount,
526
+ denominator: tierTotalAttestationUnits
405
527
  });
406
528
  }
407
529
  }
408
530
  }
409
531
  }
410
532
 
533
+ /// @notice Gets an account's BWA-adjusted attestation power relative to a specific scorecard.
534
+ /// @dev BWA (Benefit-Weighted Attestation) reduces a tier's attestation power by how much
535
+ /// that tier benefits from the scorecard. Power is reduced by `(tierWeight / totalCashOutWeight)`.
536
+ /// This means a tier with 100% of the scorecard weight gets 0 attestation power for that scorecard,
537
+ /// while a tier with 0% weight retains full power. This prevents beneficiaries from self-attesting.
538
+ /// @param gameId The ID of the game.
539
+ /// @param scorecardId The ID of the scorecard (determines tier weight lookup).
540
+ /// @param account The account to compute BWA power for.
541
+ /// @param timestamp The snapshot timestamp.
542
+ /// @return bwaAttestationPower The BWA-adjusted attestation power.
543
+ // forge-lint: disable-next-line(mixed-case-function)
544
+ function getBWAAttestationWeight(
545
+ uint256 gameId,
546
+ uint256 scorecardId,
547
+ address account,
548
+ uint48 timestamp
549
+ )
550
+ public
551
+ view
552
+ virtual
553
+ override
554
+ returns (uint256 bwaAttestationPower)
555
+ {
556
+ // Get the game's current funding cycle along with its metadata.
557
+ // slither-disable-next-line unused-return
558
+ (, JBRulesetMetadata memory metadata) = CONTROLLER.currentRulesetOf(gameId);
559
+
560
+ // Get a reference to the hook and its store.
561
+ IDefifaHook hook = IDefifaHook(metadata.dataHook);
562
+ IJB721TiersHookStore store = hook.store();
563
+
564
+ // Get a reference to the number of tiers.
565
+ uint256 numberOfTiers = store.maxTierIdOf(metadata.dataHook);
566
+
567
+ // Cache the total cashout weight denominator from the hook.
568
+ uint256 totalCashOutWeight = hook.TOTAL_CASHOUT_WEIGHT();
569
+
570
+ // slither-disable-next-line calls-inside-a-loop
571
+ for (uint256 i; i < numberOfTiers; i++) {
572
+ // Tiers are 1-indexed.
573
+ uint256 tierId = i + 1;
574
+
575
+ // Get this account's attestation units within the tier (snapshot at timestamp).
576
+ uint256 tierAttestationUnitsForAccount =
577
+ hook.getPastTierAttestationUnitsOf({account: account, tier: tierId, timestamp: timestamp});
578
+
579
+ if (tierAttestationUnitsForAccount != 0) {
580
+ // Get the total attestation units for this tier (snapshot at timestamp).
581
+ uint256 tierTotalAttestationUnits =
582
+ hook.getPastTierTotalAttestationUnitsOf({tier: tierId, timestamp: timestamp});
583
+
584
+ // Include unminted pending reserves in the total (denominator only).
585
+ // Uses the snapshot taken at scorecard submission time to prevent reserve
586
+ // minting between submission and attestation from diluting votes.
587
+ {
588
+ uint256 pendingReserves = _pendingReservesSnapshotOf[gameId][scorecardId][tierId];
589
+ if (pendingReserves != 0) {
590
+ JB721Tier memory tier =
591
+ store.tierOf({hook: metadata.dataHook, id: tierId, includeResolvedUri: false});
592
+ tierTotalAttestationUnits += pendingReserves * tier.votingUnits;
593
+ }
594
+ }
595
+
596
+ // Raw power for this tier.
597
+ uint256 rawPower =
598
+ mulDiv(MAX_ATTESTATION_POWER_TIER, tierAttestationUnitsForAccount, tierTotalAttestationUnits);
599
+
600
+ // BWA reduction: power * (1 - tierWeight / totalCashOutWeight).
601
+ uint256 tierWeight = _scorecardTierWeightsOf[gameId][scorecardId][i];
602
+ uint256 bwaMultiplier = totalCashOutWeight - tierWeight;
603
+
604
+ bwaAttestationPower += mulDiv(rawPower, bwaMultiplier, totalCashOutWeight);
605
+ }
606
+ }
607
+ }
608
+
411
609
  /// @notice The number of attestation units that must have participated in a proposal for it to be ratified.
412
- /// @dev Each tier with at least one minted token contributes MAX_ATTESTATION_POWER_TIER to the total
413
- /// eligible weight. Quorum is 50% of this total. Because every tier has equal max attestation power
414
- /// regardless of supply, each tier's community has equal influence a tier with 1 token and a tier
415
- /// with 100 tokens both cap at MAX_ATTESTATION_POWER_TIER when fully attested. This prevents
416
- /// high-supply tiers from dominating governance, keeping the game fair across all outcomes.
417
- /// @dev Note: quorum is computed from the live supply (currentSupplyOfTier) rather than a snapshot. This means
418
- /// the quorum threshold can shift between when a scorecard is submitted and when it is evaluated, e.g. if
419
- /// tokens are burned after attestation. In practice this is acceptable because attestation weights are
420
- /// snapshotted and quorum only increases (new mints) or decreases (burns) — the latter makes ratification
421
- /// easier, not harder.
610
+ /// @dev Each tier with participation contributes MAX_ATTESTATION_POWER_TIER to the total eligible weight.
611
+ /// A tier counts as "participated" if it has circulating tokens OR unminted pending reserves the latter
612
+ /// means mints occurred (triggering reserve accrual) even if all paid tokens were later burned during REFUND.
613
+ /// Quorum is 50% of this total. Because every tier has equal max attestation power regardless of supply,
614
+ /// each tier's community has equal influence. This prevents high-supply tiers from dominating governance.
615
+ /// @dev No snapshot needed: during SCORING, supply is frozen (no new paid mints, no burns). Reserve minting
616
+ /// doesn't change which tiers are counted because tiers with pending reserves are already included.
422
617
  /// @return The quorum number of attestations.
423
618
  function quorum(uint256 gameId) public view override returns (uint256) {
424
619
  // Get the game's current funding cycle along with its metadata.
425
620
  // slither-disable-next-line unused-return
426
- (, JBRulesetMetadata memory _metadata) = CONTROLLER.currentRulesetOf(gameId);
621
+ (, JBRulesetMetadata memory metadata) = CONTROLLER.currentRulesetOf(gameId);
622
+
623
+ // Get a reference to the hook and its store.
624
+ IDefifaHook hook = IDefifaHook(metadata.dataHook);
625
+ IJB721TiersHookStore store = hook.store();
427
626
 
428
627
  // Get a reference to the number of tiers.
429
- uint256 _numberOfTiers = IDefifaHook(_metadata.dataHook).store().maxTierIdOf(_metadata.dataHook);
628
+ uint256 numberOfTiers = store.maxTierIdOf(metadata.dataHook);
430
629
 
431
630
  // Keep a reference to the total eligible tier weight.
432
- uint256 _eligibleTierWeights;
631
+ uint256 eligibleTierWeights;
433
632
 
434
633
  // slither-disable-next-line calls-inside-a-loop
435
- for (uint256 _i; _i < _numberOfTiers; _i++) {
436
- // Each minted tier contributes MAX_ATTESTATION_POWER_TIER to the quorum denominator.
437
- if (IDefifaHook(_metadata.dataHook).currentSupplyOfTier(_i + 1) != 0) {
438
- _eligibleTierWeights += MAX_ATTESTATION_POWER_TIER;
634
+ for (uint256 i; i < numberOfTiers; i++) {
635
+ uint256 tierId = i + 1;
636
+
637
+ // A tier contributes to quorum if it has circulating tokens OR unminted pending reserves.
638
+ // Pending reserves exist when participation occurred (mints triggered reserve accrual),
639
+ // even if all paid tokens were later burned during REFUND. The reserve beneficiary still
640
+ // has a stake in that tier's outcome, so the tier should count toward governance quorum.
641
+ if (
642
+ hook.currentSupplyOfTier(tierId) != 0
643
+ || store.numberOfPendingReservesFor(metadata.dataHook, tierId) != 0
644
+ ) {
645
+ eligibleTierWeights += MAX_ATTESTATION_POWER_TIER;
439
646
  }
440
647
  }
441
648
 
442
- // Quorum = 50% of all minted tiers' attestation power.
443
- return _eligibleTierWeights / 2;
649
+ // Quorum = 50% of all participated tiers' attestation power.
650
+ return eligibleTierWeights / 2;
444
651
  }
445
652
 
446
653
  /// @notice The state of a proposal.
@@ -450,45 +657,59 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
450
657
  /// @dev Boundary semantics (inclusive):
451
658
  /// - At exactly `attestationsBegin`, the state transitions from PENDING to ACTIVE (attestations are open).
452
659
  /// - At exactly `gracePeriodEnds`, the grace period has elapsed and the state transitions from ACTIVE to
453
- /// SUCCEEDED (if quorum is met) or remains ACTIVE (if not).
660
+ /// QUEUED (if quorum met + timelock > 0) or SUCCEEDED (if quorum met + no timelock).
454
661
  function stateOf(uint256 gameId, uint256 scorecardId) public view virtual override returns (DefifaScorecardState) {
455
662
  // Keep a reference to the ratified scorecard ID.
456
- uint256 _ratifiedScorecardId = ratifiedScorecardIdOf[gameId];
663
+ uint256 ratifiedScorecardId = ratifiedScorecardIdOf[gameId];
457
664
 
458
665
  // If the game has already ratified a scorecard, return succeeded if the ratified proposal is being checked.
459
666
  // Else return defeated.
460
- if (_ratifiedScorecardId != 0) {
461
- return _ratifiedScorecardId == scorecardId ? DefifaScorecardState.RATIFIED : DefifaScorecardState.DEFEATED;
667
+ if (ratifiedScorecardId != 0) {
668
+ return ratifiedScorecardId == scorecardId ? DefifaScorecardState.RATIFIED : DefifaScorecardState.DEFEATED;
462
669
  }
463
670
 
464
671
  // Get a reference to the scorecard.
465
- DefifaScorecard memory _scorecard = _scorecardOf[gameId][scorecardId];
672
+ DefifaScorecard memory scorecard = _scorecardOf[gameId][scorecardId];
466
673
 
467
674
  // Make sure the proposal is known.
468
675
  // slither-disable-next-line incorrect-equality
469
- if (_scorecard.attestationsBegin == 0) {
676
+ if (scorecard.attestationsBegin == 0) {
470
677
  revert DefifaGovernor_UnknownProposal();
471
678
  }
472
679
 
473
680
  // If the scorecard has attestations beginning in the future, the state is PENDING.
474
681
  // At exactly `attestationsBegin`, attestations are open so the state is ACTIVE.
475
- if (_scorecard.attestationsBegin > block.timestamp) {
682
+ if (scorecard.attestationsBegin > block.timestamp) {
476
683
  return DefifaScorecardState.PENDING;
477
684
  }
478
685
 
479
686
  // If the scorecard's grace period has not yet ended, the state is ACTIVE.
480
687
  // At exactly `gracePeriodEnds`, the grace period has elapsed so we fall through to the quorum check.
481
- if (_scorecard.gracePeriodEnds > block.timestamp) {
688
+ if (scorecard.gracePeriodEnds > block.timestamp) {
482
689
  return DefifaScorecardState.ACTIVE;
483
690
  }
484
691
 
485
- // If quorum has been reached, the state is SUCCEEDED, otherwise it is ACTIVE.
486
- // Note: scorecards that fail to reach quorum remain ACTIVE indefinitely — there is no DEFEATED
692
+ // If quorum has been reached (using the concentration-adjusted snapshot), check timelock.
693
+ if (scorecard.quorumSnapshot <= _scorecardAttestationsOf[gameId][scorecardId].count) {
694
+ uint256 _timelockDuration = timelockDurationOf(gameId);
695
+ if (_timelockDuration > 0 && block.timestamp < uint256(scorecard.gracePeriodEnds) + _timelockDuration) {
696
+ return DefifaScorecardState.QUEUED;
697
+ }
698
+ return DefifaScorecardState.SUCCEEDED;
699
+ }
700
+
701
+ // Scorecards that fail to reach quorum remain ACTIVE indefinitely — there is no DEFEATED
487
702
  // state transition for unratified scorecards. This is by design: new scorecards can always be
488
703
  // submitted and the game's no-contest timeout (scorecardTimeout) provides the ultimate backstop.
489
- return quorum(gameId) <= _scorecardAttestationsOf[gameId][scorecardId].count
490
- ? DefifaScorecardState.SUCCEEDED
491
- : DefifaScorecardState.ACTIVE;
704
+ return DefifaScorecardState.ACTIVE;
705
+ }
706
+
707
+ /// @notice The timelock duration for a game (cooling period after quorum + grace period before ratification).
708
+ /// @param gameId The ID of the game.
709
+ /// @return The timelock duration in seconds.
710
+ function timelockDurationOf(uint256 gameId) public view override returns (uint256) {
711
+ // timelock duration in bits 96-143 (48 bits).
712
+ return uint256(uint48(_packedScorecardInfoOf[gameId] >> 96));
492
713
  }
493
714
 
494
715
  //*********************************************************************//