@ballkidz/defifa 0.0.12 → 0.0.13

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