@ballkidz/defifa 0.0.10 → 0.0.12

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 (39) hide show
  1. package/ADMINISTRATION.md +26 -15
  2. package/ARCHITECTURE.md +35 -3
  3. package/AUDIT_INSTRUCTIONS.md +127 -45
  4. package/CHANGE_LOG.md +107 -0
  5. package/CRYPTO_ECON.md +2 -2
  6. package/README.md +120 -2
  7. package/RISKS.md +21 -4
  8. package/SKILLS.md +174 -59
  9. package/STYLE_GUIDE.md +2 -2
  10. package/USER_JOURNEYS.md +482 -139
  11. package/foundry.toml +1 -1
  12. package/package.json +7 -7
  13. package/script/Deploy.s.sol +1 -1
  14. package/script/helpers/DefifaDeploymentLib.sol +2 -2
  15. package/src/DefifaDeployer.sol +2 -2
  16. package/src/DefifaGovernor.sol +1 -1
  17. package/src/DefifaHook.sol +7 -6
  18. package/src/DefifaProjectOwner.sol +1 -1
  19. package/src/DefifaTokenUriResolver.sol +1 -1
  20. package/src/libraries/DefifaFontImporter.sol +1 -1
  21. package/src/libraries/DefifaHookLib.sol +1 -1
  22. package/test/DefifaAdversarialQuorum.t.sol +1 -1
  23. package/test/DefifaAuditLowGuards.t.sol +1 -1
  24. package/test/DefifaFeeAccounting.t.sol +1 -1
  25. package/test/DefifaGovernor.t.sol +1 -1
  26. package/test/DefifaHookRegressions.t.sol +39 -1
  27. package/test/DefifaMintCostInvariant.t.sol +1 -1
  28. package/test/DefifaNoContest.t.sol +1 -1
  29. package/test/DefifaSecurity.t.sol +1 -1
  30. package/test/DefifaUSDC.t.sol +1 -1
  31. package/test/Fork.t.sol +1 -1
  32. package/test/SVG.t.sol +1 -1
  33. package/test/TestAuditGaps.sol +1 -1
  34. package/test/TestQALastMile.t.sol +1 -1
  35. package/test/audit/CodexAttestationDoubleCount.t.sol +217 -0
  36. package/test/deployScript.t.sol +1 -1
  37. package/test/regression/AttestationDelegateBeneficiary.t.sol +272 -0
  38. package/test/regression/FulfillmentBlocksRatification.t.sol +1 -1
  39. package/test/regression/GracePeriodBypass.t.sol +1 -1
package/USER_JOURNEYS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # defifa-collection-deployer-v6 -- User Journeys
2
2
 
3
- Complete interaction paths for every user role in the Defifa prediction game system. Each journey traces exact function signatures, parameters, and state changes.
3
+ Complete interaction paths for every user role in the Defifa prediction game system. Each journey traces exact function signatures, parameters, state changes, events, and edge cases.
4
4
 
5
5
  ---
6
6
 
@@ -23,9 +23,35 @@ Complete interaction paths for every user role in the Defifa prediction game sys
23
23
 
24
24
  ## Journey 1: Create a Game
25
25
 
26
+ **Entry point:** `DefifaDeployer.launchGameWith(DefifaLaunchProjectData memory launchProjectData) external returns (uint256 gameId)`
27
+
28
+ **Who can call:** Anyone (no access control).
29
+
26
30
  **Actor:** Game Creator
27
31
  **Phase:** Any (game is created and starts in COUNTDOWN)
28
32
 
33
+ ### Parameters
34
+
35
+ - `launchProjectData.name` -- Game name (e.g. `"Super Bowl LXII"`).
36
+ - `launchProjectData.projectUri` -- IPFS URI for project metadata.
37
+ - `launchProjectData.contractUri` -- Contract-level metadata URI.
38
+ - `launchProjectData.baseUri` -- Base URI for token metadata.
39
+ - `launchProjectData.tiers` -- Array of `DefifaTierParams` (name, reservedRate, reservedTokenBeneficiary, encodedIPFSUri, shouldUseReservedTokenBeneficiaryAsDefault).
40
+ - `launchProjectData.tierPrice` -- Uniform price per NFT across all tiers.
41
+ - `launchProjectData.token` -- `JBAccountingContext` (token address, decimals, currency).
42
+ - `launchProjectData.mintPeriodDuration` -- Duration of MINT phase in seconds.
43
+ - `launchProjectData.refundPeriodDuration` -- Duration of REFUND phase in seconds (0 = no refund phase).
44
+ - `launchProjectData.start` -- Unix timestamp when SCORING begins (0 = auto-calculate).
45
+ - `launchProjectData.splits` -- Optional custom splits for fee distribution.
46
+ - `launchProjectData.attestationStartTime` -- Timestamp when attestation begins (0 = `block.timestamp` at deploy).
47
+ - `launchProjectData.attestationGracePeriod` -- Minimum grace period before ratification (0 = enforced minimum of 1 day).
48
+ - `launchProjectData.defaultAttestationDelegate` -- Default attestation delegate (0 = each beneficiary delegates to self).
49
+ - `launchProjectData.defaultTokenUriResolver` -- Token URI resolver (0 = use default SVG).
50
+ - `launchProjectData.terminal` -- `IJBTerminal` instance (e.g. a `JBMultiTerminal`).
51
+ - `launchProjectData.store` -- `JB721TiersHookStore` instance.
52
+ - `launchProjectData.minParticipation` -- Minimum treasury balance for game to proceed to SCORING.
53
+ - `launchProjectData.scorecardTimeout` -- Max time after SCORING begins for a scorecard to be ratified.
54
+
29
55
  ### Step 1: Prepare launch data
30
56
 
31
57
  Build a `DefifaLaunchProjectData` struct:
@@ -65,9 +91,9 @@ DefifaLaunchProjectData({
65
91
  splits: [], // Optional custom splits
66
92
  attestationStartTime: 0, // 0 = block.timestamp at deploy
67
93
  attestationGracePeriod: 0, // 0 = enforced minimum of 1 day
68
- defaultAttestationDelegate: address(0), // 0 = each payer delegates to self
94
+ defaultAttestationDelegate: address(0), // 0 = each beneficiary delegates to self
69
95
  defaultTokenUriResolver: IJB721TokenUriResolver(address(0)), // use default SVG
70
- terminal: jbMultiTerminal,
96
+ terminal: IJBTerminal(address(jbMultiTerminal)),
71
97
  store: jb721TiersHookStore,
72
98
  minParticipation: 1 ether, // Game needs >= 1 ETH to proceed
73
99
  scorecardTimeout: 7 days // 7 days to ratify or NO_CONTEST
@@ -80,20 +106,32 @@ DefifaLaunchProjectData({
80
106
  uint256 gameId = deployer.launchGameWith(launchProjectData);
81
107
  ```
82
108
 
83
- **What happens internally:**
84
- 1. `DefifaDeployer` validates timing: `mintPeriodDuration > 0`, start >= now + refund + mint.
85
- 2. Stores `_opsOf[gameId]` with token, start, durations, safety params.
86
- 3. If user splits provided: copies them plus a Defifa fee split, stores via `CONTROLLER.setSplitGroupsOf()`.
87
- 4. Creates `JB721TierConfig[]` from `DefifaTierParams[]`. All tiers share `tierPrice`, `initialSupply = 999_999_999`, `category = 0`.
88
- 5. Clones `DefifaHook` deterministically: `Clones.cloneDeterministic(HOOK_CODE_ORIGIN, keccak256(abi.encodePacked(msg.sender, nonce)))`.
89
- 6. Calls `hook.initialize(...)` with game config, tier names, URI resolver.
90
- 7. Launches JB project via `CONTROLLER.launchProjectFor()` with 2-3 rulesets (MINT, optional REFUND, SCORING).
91
- 8. Initializes governor: `GOVERNOR.initializeGame(gameId, attestationStartTime, attestationGracePeriod)`.
92
- 9. Transfers hook ownership: `hook.transferOwnership(address(GOVERNOR))`.
93
- 10. Registers hook in address registry.
94
- 11. Emits `LaunchGame(gameId, hook, governor, uriResolver, msg.sender)`.
95
-
96
- **Timing rules:**
109
+ ### State changes
110
+
111
+ 1. `DefifaDeployer._opsOf[gameId]` -- Stores `DefifaOpsData` with token, start, durations, safety params.
112
+ 2. `DefifaDeployer._commitmentPercentOf[gameId]` -- Stores total absolute split percent for fee distribution.
113
+ 3. `DefifaDeployer._nonce` -- Incremented for deterministic clone salt.
114
+ 4. `DefifaHook.store` -- Set to the provided `JB721TiersHookStore`.
115
+ 5. `DefifaHook.rulesets` -- Set to `CONTROLLER.RULESETS()`.
116
+ 6. `DefifaHook.pricingCurrency` -- Set to `launchProjectData.token.currency`.
117
+ 7. `DefifaHook.gamePhaseReporter` -- Set to `DefifaDeployer` (this).
118
+ 8. `DefifaHook.gamePotReporter` -- Set to `DefifaDeployer` (this).
119
+ 9. `DefifaHook.defaultAttestationDelegate` -- Set to the provided address.
120
+ 10. `DefifaHook.baseURI` -- Set if non-empty.
121
+ 11. `DefifaHook.contractURI` -- Set if non-empty.
122
+ 12. `DefifaGovernor._packedScorecardInfoOf[gameId]` -- Packed attestation start time + grace period.
123
+ 13. JB project created via `CONTROLLER.launchProjectFor()` with 2-3 rulesets (MINT, optional REFUND, SCORING).
124
+
125
+ ### Events
126
+
127
+ - `LaunchGame(uint256 indexed gameId, IDefifaHook indexed hook, IDefifaGovernor indexed governor, IJB721TokenUriResolver tokenUriResolver, address caller)` -- Emitted by `DefifaDeployer` on successful launch.
128
+ - `GameInitialized(uint256 indexed gameId, uint256 attestationStartTime, uint256 attestationGracePeriod, address caller)` -- Emitted by `DefifaGovernor` when `initializeGame` is called internally.
129
+
130
+ ### Edge cases
131
+
132
+ - `DefifaDeployer_InvalidGameConfiguration` -- `mintPeriodDuration == 0` or `start < block.timestamp + refundPeriodDuration + mintPeriodDuration`.
133
+ - `DefifaDeployer_InvalidGameConfiguration` -- JB project ID mismatch (front-run by another project creation).
134
+ - `DefifaDeployer_SplitsDontAddUp` -- User splits + protocol fees exceed 100%.
97
135
  - If `start == 0`: auto-calculated as `block.timestamp + mintPeriodDuration + refundPeriodDuration`.
98
136
  - If `start > 0` and `mintPeriodDuration == 0`: mint duration auto-fills to `start - block.timestamp - refundPeriodDuration`.
99
137
  - MINT ruleset `mustStartAtOrAfter = start - mintPeriodDuration - refundPeriodDuration`.
@@ -104,9 +142,23 @@ uint256 gameId = deployer.launchGameWith(launchProjectData);
104
142
 
105
143
  ## Journey 2: Play a Game (Buy NFTs)
106
144
 
145
+ **Entry point:** `JBMultiTerminal.pay{value: amount}(uint256 projectId, address token, uint256 amount, address beneficiary, uint256 minReturnedTokens, string memo, bytes metadata) external payable returns (uint256)`
146
+
147
+ **Who can call:** Anyone. The terminal forwards the call to `DefifaHook.afterPayRecordedWith()` which validates the caller is a registered terminal for the project.
148
+
107
149
  **Actor:** Player
108
150
  **Phase:** MINT
109
151
 
152
+ ### Parameters
153
+
154
+ - `projectId` -- The game ID.
155
+ - `token` -- Token address (e.g. `JBConstants.NATIVE_TOKEN` for ETH).
156
+ - `amount` -- Must equal `tierPrice * numberOfTiersMinted` exactly.
157
+ - `beneficiary` -- Address that receives the minted NFTs.
158
+ - `minReturnedTokens` -- Minimum tokens to receive (typically 0 for NFT mints).
159
+ - `memo` -- Optional memo string.
160
+ - `metadata` -- JBMetadataResolver-encoded bytes containing `(address attestationDelegate, uint16[] tierIds)`.
161
+
110
162
  ### Step 1: Prepare payment metadata
111
163
 
112
164
  Encode the tier IDs to mint and optional attestation delegate:
@@ -145,28 +197,50 @@ jbMultiTerminal.pay{value: 0.02 ether}({
145
197
  });
146
198
  ```
147
199
 
148
- **What happens internally:**
149
- 1. `JBMultiTerminal` processes payment, calls `DefifaHook.afterPayRecordedWith()`.
150
- 2. Hook verifies: caller is terminal, currency matches `pricingCurrency`.
151
- 3. Decodes `(attestationDelegate, tierIdsToMint)` from metadata.
152
- 4. If `attestationDelegate == address(0)`: uses `defaultAttestationDelegate` or `context.payer`.
153
- 5. Computes attestation units per unique tier via `DefifaHookLib.computeAttestationUnits()`.
154
- 6. For each unique tier: delegates attestation if payer has no delegate set, transfers attestation units.
155
- 7. Calls `_mintAll()`: records mint in store, increments `_totalMintCost`, mints ERC-721 tokens.
156
- 8. Reverts with `DefifaHook_Overspending` if payment exceeds exact tier prices.
200
+ ### State changes
201
+
202
+ 1. `DefifaHook._totalMintCost` -- Incremented by `context.amount.value` (the paid amount).
203
+ 2. `DefifaHook._tierDelegation[beneficiary][tierId]` -- Set to `attestationDelegate` for each minted tier (if different from the beneficiary's existing delegate for that tier). When no explicit delegate is provided and no `defaultAttestationDelegate` is configured, defaults to the beneficiary.
204
+ 3. `DefifaHook._delegateTierCheckpoints[delegate][tierId]` -- Checkpointed with new attestation units.
205
+ 4. `DefifaHook._totalTierCheckpoints[tierId]` -- Checkpointed with increased total attestation units.
206
+ 5. ERC-721 token ownership records updated (one token per tier mint).
207
+ 6. `JB721TiersHookStore` records the mint (supply, token IDs).
208
+
209
+ ### Events
210
+
211
+ - `Mint(uint256 indexed tokenId, uint256 indexed tierId, address indexed beneficiary, uint256 totalAmountContributed, address caller)` -- Emitted per token minted by `DefifaHook._mintAll()`.
212
+ - `DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate)` -- Emitted when attestation delegation is set for a tier.
213
+ - `TierDelegateAttestationsChanged(address indexed delegate, uint256 indexed tierId, uint256 previousBalance, uint256 newBalance, address caller)` -- Emitted when attestation units are transferred.
157
214
 
158
- **Player receives:**
159
- - NFT token(s) representing their chosen tier(s).
160
- - Attestation delegation set to their chosen delegate (or themselves).
161
- - Attestation units proportional to tier's `votingUnits` (used for scorecard governance).
215
+ ### Edge cases
216
+
217
+ - `DefifaHook_WrongCurrency` -- Payment currency does not match `pricingCurrency`.
218
+ - `DefifaHook_NothingToMint` -- No tier IDs in metadata, or metadata not found.
219
+ - `DefifaHook_Overspending` -- Payment amount exceeds exact cost of tiers minted (leftover != 0).
220
+ - `DefifaHook_BadTierOrder` -- Tier IDs in metadata not in ascending order (validated by `DefifaHookLib.computeAttestationUnits`).
221
+ - `JB721Hook_InvalidPay` -- Caller not a terminal, or wrong project ID, or ETH sent directly to hook.
162
222
 
163
223
  ---
164
224
 
165
225
  ## Journey 3: Refund During MINT Phase
166
226
 
227
+ **Entry point:** `JBMultiTerminal.cashOutTokensOf(address holder, uint256 projectId, uint256 cashOutCount, address tokenToReclaim, uint256 minTokensReclaimed, address payable beneficiary, bytes metadata) external returns (uint256)`
228
+
229
+ **Who can call:** Anyone can initiate, but the hook validates that `context.holder` owns the tokens being burned.
230
+
167
231
  **Actor:** Refunder
168
232
  **Phase:** MINT
169
233
 
234
+ ### Parameters
235
+
236
+ - `holder` -- Address that holds the NFTs being cashed out.
237
+ - `projectId` -- The game ID.
238
+ - `cashOutCount` -- Pass `0` for NFT cash-outs.
239
+ - `tokenToReclaim` -- Token to receive (e.g. `JBConstants.NATIVE_TOKEN`).
240
+ - `minTokensReclaimed` -- Minimum amount to receive (set to expected mint price).
241
+ - `beneficiary` -- Address that receives the reclaimed funds.
242
+ - `metadata` -- JBMetadataResolver-encoded bytes containing `(uint256[] tokenIds)`.
243
+
170
244
  ### Step 1: Prepare cash-out metadata
171
245
 
172
246
  ```solidity
@@ -193,30 +267,66 @@ jbMultiTerminal.cashOutTokensOf({
193
267
  });
194
268
  ```
195
269
 
196
- **What happens internally:**
197
- 1. `beforeCashOutRecordedWith` computes `cashOutCount = cumulativeMintPrice` (full refund during MINT).
198
- 2. Terminal computes reclaim amount from the bonding curve with `cashOutTaxRate = 0`.
199
- 3. `afterCashOutRecordedWith` burns the NFT(s), decrements `_totalMintCost`.
200
- 4. During MINT phase: `tokensRedeemedFrom` is NOT incremented (only during COMPLETE).
201
- 5. No fee tokens distributed (game not COMPLETE).
202
- 6. ETH returned to beneficiary at exact mint price.
270
+ ### State changes
271
+
272
+ 1. ERC-721 token burned via `DefifaHook._burn(tokenId)`.
273
+ 2. `DefifaHook._totalMintCost` -- Decremented by `cumulativeMintPrice` of burned tokens.
274
+ 3. `JB721TiersHookStore` records the burn.
275
+ 4. During MINT phase: `DefifaHook.tokensRedeemedFrom[tierId]` is NOT incremented (only during COMPLETE).
276
+ 5. `DefifaHook.amountRedeemed` -- NOT incremented (only during COMPLETE).
277
+
278
+ ### Events
279
+
280
+ No Defifa-specific events are emitted during MINT/REFUND phase cash-outs. Standard ERC-721 `Transfer(from, address(0), tokenId)` is emitted by the burn.
281
+
282
+ ### Edge cases
283
+
284
+ - `DefifaHook_Unauthorized(tokenId, owner, caller)` -- Token holder in context does not own the token.
285
+ - `DefifaHook_NothingToClaim` -- Reclaimed amount is 0 AND no fee tokens distributed.
286
+ - `JB721Hook_InvalidCashOut` -- Caller not a terminal, or wrong project ID.
287
+ - During MINT phase: `cashOutTaxRate = 0`, so full mint price is refunded.
203
288
 
204
289
  ---
205
290
 
206
291
  ## Journey 4: Refund During REFUND Phase
207
292
 
293
+ **Entry point:** Same as Journey 3: `JBMultiTerminal.cashOutTokensOf(...)`
294
+
295
+ **Who can call:** Anyone (same restrictions as Journey 3).
296
+
208
297
  **Actor:** Refunder
209
298
  **Phase:** REFUND
210
299
 
211
300
  Identical to Journey 3. The REFUND phase has `pausePay: true` (no new mints) but cash-outs still return full mint price. The `cashOutTaxRate = 0` and the hook returns `cashOutCount = cumulativeMintPrice`.
212
301
 
302
+ ### State changes
303
+
304
+ Same as Journey 3.
305
+
306
+ ### Events
307
+
308
+ Same as Journey 3 (no Defifa-specific events; standard ERC-721 burn `Transfer` event).
309
+
310
+ ### Edge cases
311
+
312
+ Same as Journey 3. Additionally, new payments are blocked (`pausePay: true`).
313
+
213
314
  ---
214
315
 
215
316
  ## Journey 5: Submit a Scorecard
216
317
 
318
+ **Entry point:** `DefifaGovernor.submitScorecardFor(uint256 gameId, DefifaTierCashOutWeight[] calldata tierWeights) external returns (uint256 scorecardId)`
319
+
320
+ **Who can call:** Anyone. No access control on submission. However, if `msg.sender == defaultAttestationDelegate`, the scorecard is stored as `defaultAttestationDelegateProposalOf[gameId]`.
321
+
217
322
  **Actor:** Scorer (anyone)
218
323
  **Phase:** SCORING
219
324
 
325
+ ### Parameters
326
+
327
+ - `gameId` -- The ID of the game.
328
+ - `tierWeights` -- Array of `DefifaTierCashOutWeight` structs. Each has `id` (tier ID) and `cashOutWeight` (weight). All weights must sum to exactly `TOTAL_CASHOUT_WEIGHT` (1e18). Tier IDs must be in ascending order.
329
+
220
330
  ### Step 1: Prepare tier weights
221
331
 
222
332
  All weights must sum to exactly `TOTAL_CASHOUT_WEIGHT` (1e18). Tier IDs must be in ascending order.
@@ -235,25 +345,41 @@ tierWeights[2] = DefifaTierCashOutWeight({id: 3, cashOutWeight: 200_000_000_000_
235
345
  uint256 scorecardId = governor.submitScorecardFor(gameId, tierWeights);
236
346
  ```
237
347
 
238
- **What happens internally:**
239
- 1. Verifies: game initialized, no ratified scorecard, game in SCORING phase.
240
- 2. For each weight > 0: verifies `currentSupplyOfTier(tierId) > 0` (cannot assign weight to unminted tiers).
241
- 3. Hashes the scorecard: `keccak256(abi.encode(dataHook, abi.encodeWithSelector(setTierCashOutWeightsTo.selector, tierWeights)))`.
242
- 4. Reverts with `DefifaGovernor_DuplicateScorecard` if this exact scorecard was already submitted.
243
- 5. Sets `attestationsBegin = max(block.timestamp, attestationStartTime)`.
244
- 6. Sets `gracePeriodEnds = attestationsBegin + attestationGracePeriod`.
245
- 7. If sender is `defaultAttestationDelegate`: stores as `defaultAttestationDelegateProposalOf`.
246
- 8. Emits `ScorecardSubmitted(gameId, scorecardId, tierWeights, isDefault, msg.sender)`.
348
+ ### State changes
349
+
350
+ 1. `DefifaGovernor._scorecardOf[gameId][scorecardId].attestationsBegin` -- Set to `max(block.timestamp, attestationStartTime)`.
351
+ 2. `DefifaGovernor._scorecardOf[gameId][scorecardId].gracePeriodEnds` -- Set to `attestationsBegin + attestationGracePeriod`.
352
+ 3. `DefifaGovernor.defaultAttestationDelegateProposalOf[gameId]` -- Set to `scorecardId` if sender is the default attestation delegate.
353
+
354
+ ### Events
247
355
 
248
- **Scorecard state:** PENDING (until `attestationsBegin`) or ACTIVE (if attestations start immediately).
356
+ - `ScorecardSubmitted(uint256 indexed gameId, uint256 indexed scorecardId, DefifaTierCashOutWeight[] tierWeights, bool isDefaultAttestationDelegate, address caller)` -- Emitted by `DefifaGovernor`.
357
+
358
+ ### Edge cases
359
+
360
+ - `DefifaGovernor_AlreadyRatified` -- A scorecard has already been ratified for this game.
361
+ - `DefifaGovernor_GameNotFound` -- Game not initialized (`_packedScorecardInfoOf[gameId] == 0`).
362
+ - `DefifaGovernor_NotAllowed` -- Game not in SCORING phase.
363
+ - `DefifaGovernor_UnownedProposedCashoutValue` -- Weight > 0 assigned to a tier with `currentSupplyOfTier == 0`.
364
+ - `DefifaGovernor_DuplicateScorecard` -- Identical scorecard (same hash) already submitted.
365
+ - Scorecard state starts as PENDING (until `attestationsBegin`) or ACTIVE (if attestations start immediately).
249
366
 
250
367
  ---
251
368
 
252
369
  ## Journey 6: Attest to a Scorecard
253
370
 
371
+ **Entry point:** `DefifaGovernor.attestToScorecardFrom(uint256 gameId, uint256 scorecardId) external returns (uint256 weight)`
372
+
373
+ **Who can call:** Anyone. However, attestation weight is zero unless the caller (or their delegate) held NFTs at the `attestationsBegin` snapshot timestamp.
374
+
254
375
  **Actor:** Attestor (NFT holder or delegate)
255
376
  **Phase:** SCORING
256
377
 
378
+ ### Parameters
379
+
380
+ - `gameId` -- The ID of the game.
381
+ - `scorecardId` -- The scorecard ID to attest to.
382
+
257
383
  ### Step 1: Get scorecard ID
258
384
 
259
385
  Either compute it or use the ID from the `ScorecardSubmitted` event:
@@ -268,28 +394,39 @@ uint256 scorecardId = governor.scorecardIdOf(hookAddress, tierWeights);
268
394
  uint256 weight = governor.attestToScorecardFrom(gameId, scorecardId);
269
395
  ```
270
396
 
271
- **What happens internally:**
272
- 1. Verifies: game in SCORING phase, scorecard is ACTIVE or SUCCEEDED.
273
- 2. Verifies: `!hasAttested[msg.sender]` for this scorecard. Reverts with `DefifaGovernor_AlreadyAttested` otherwise.
274
- 3. Computes attestation weight at `attestationsBegin` timestamp:
275
- - For each tier: `MAX_ATTESTATION_POWER_TIER * (account's checkpoint units / tier's total checkpoint units)`.
276
- - Uses `getPastTierAttestationUnitsOf()` (snapshot, not live).
277
- 4. Increments `_attestations.count += weight`.
278
- 5. Marks `hasAttested[msg.sender] = true`.
279
- 6. Emits `ScorecardAttested(gameId, scorecardId, weight, msg.sender)`.
397
+ ### State changes
398
+
399
+ 1. `DefifaGovernor._scorecardAttestationsOf[gameId][scorecardId].count` -- Incremented by `weight`.
400
+ 2. `DefifaGovernor._scorecardAttestationsOf[gameId][scorecardId].hasAttested[msg.sender]` -- Set to `true`.
401
+
402
+ ### Events
403
+
404
+ - `ScorecardAttested(uint256 indexed gameId, uint256 indexed scorecardId, uint256 weight, address caller)` -- Emitted by `DefifaGovernor`.
405
+
406
+ ### Edge cases
280
407
 
281
- **Attestation power depends on:**
282
- - How many NFTs the attestor (or their delegate) held at `attestationsBegin` timestamp.
283
- - What fraction of each tier's total supply those NFTs represent.
408
+ - `DefifaGovernor_NotAllowed` -- Game not in SCORING phase, or scorecard not in ACTIVE/SUCCEEDED state.
409
+ - `DefifaGovernor_AlreadyAttested` -- Account already attested to this scorecard.
410
+ - `DefifaGovernor_UnknownProposal` -- Scorecard ID has no submission record.
411
+ - Attestation weight is computed at `attestationsBegin` timestamp using checkpointed values (snapshot, not live).
284
412
  - Each tier caps at `MAX_ATTESTATION_POWER_TIER` (1e9) regardless of how many tokens exist in that tier.
285
413
 
286
414
  ---
287
415
 
288
416
  ## Journey 7: Ratify a Scorecard
289
417
 
418
+ **Entry point:** `DefifaGovernor.ratifyScorecardFrom(uint256 gameId, DefifaTierCashOutWeight[] calldata tierWeights) external returns (uint256 scorecardId)`
419
+
420
+ **Who can call:** Anyone. No access control -- the function validates that the scorecard is in SUCCEEDED state.
421
+
290
422
  **Actor:** Ratifier (anyone)
291
423
  **Phase:** SCORING (scorecard in SUCCEEDED state)
292
424
 
425
+ ### Parameters
426
+
427
+ - `gameId` -- The ID of the game.
428
+ - `tierWeights` -- The tier weights that match the scorecard being ratified (used to recompute the scorecard hash).
429
+
293
430
  ### Precondition
294
431
 
295
432
  A scorecard must be in SUCCEEDED state:
@@ -303,27 +440,45 @@ A scorecard must be in SUCCEEDED state:
303
440
  uint256 scorecardId = governor.ratifyScorecardFrom(gameId, tierWeights);
304
441
  ```
305
442
 
306
- **What happens internally:**
307
- 1. Verifies: no prior ratification (`ratifiedScorecardIdOf[gameId] == 0`).
308
- 2. Computes `scorecardId` from `tierWeights`, verifies it matches a SUCCEEDED scorecard.
309
- 3. Stores `ratifiedScorecardIdOf[gameId] = scorecardId`.
310
- 4. Executes scorecard via low-level call: `dataHook.call(abi.encodeWithSelector(setTierCashOutWeightsTo.selector, tierWeights))`.
311
- - This calls `DefifaHook.setTierCashOutWeightsTo()` which validates weights sum to `TOTAL_CASHOUT_WEIGHT` and sets `cashOutWeightIsSet = true`.
312
- 5. Calls `DefifaDeployer.fulfillCommitmentsOf(gameId)`:
313
- - Sends fee payouts via `terminal.sendPayoutsOf()` (try-catch: if payout fails, emits `CommitmentPayoutFailed` and sets sentinel).
314
- - Queues final ruleset with no payout limits.
315
- - Exceptional failures (e.g., `queueRulesetsOf` failure) propagate and revert ratification.
316
- 6. Emits `ScorecardRatified(gameId, scorecardId, msg.sender)`.
443
+ ### State changes
444
+
445
+ 1. `DefifaGovernor.ratifiedScorecardIdOf[gameId]` -- Set to `scorecardId`.
446
+ 2. `DefifaHook._tierCashOutWeights` -- Set via `setTierCashOutWeightsTo()` executed as a low-level call.
447
+ 3. `DefifaHook.cashOutWeightIsSet` -- Set to `true`.
448
+ 4. `DefifaDeployer.fulfilledCommitmentsOf[gameId]` -- Set to the fee amount (or sentinel value 1 if pot is 0 or payout fails).
449
+ 5. Final ruleset queued via `CONTROLLER.queueRulesetsOf()` with no payout limits.
317
450
 
318
- **Game state transitions to:** COMPLETE (because `cashOutWeightIsSet == true`).
451
+ ### Events
452
+
453
+ - `TierCashOutWeightsSet(DefifaTierCashOutWeight[] tierWeights, address caller)` -- Emitted by `DefifaHook.setTierCashOutWeightsTo()`.
454
+ - `FulfilledCommitments(uint256 indexed gameId, uint256 pot, address caller)` -- Emitted by `DefifaDeployer.fulfillCommitmentsOf()`.
455
+ - `CommitmentPayoutFailed(uint256 indexed gameId, uint256 amount, bytes reason)` -- Emitted if `sendPayoutsOf` fails (try-catch).
456
+ - `ScorecardRatified(uint256 indexed gameId, uint256 indexed scorecardId, address caller)` -- Emitted by `DefifaGovernor`.
457
+
458
+ ### Edge cases
459
+
460
+ - `DefifaGovernor_AlreadyRatified` -- Game already has a ratified scorecard.
461
+ - `DefifaGovernor_NotAllowed` -- Scorecard not in SUCCEEDED state.
462
+ - `DefifaGovernor_UnknownProposal` -- Scorecard ID has no submission record.
463
+ - If `sendPayoutsOf` fails: try-catch emits `CommitmentPayoutFailed`, fee stays in pot, but final ruleset is still queued.
464
+ - If `queueRulesetsOf` fails: the entire ratification reverts (no try-catch on that call).
465
+ - Game state transitions to COMPLETE because `cashOutWeightIsSet == true`.
319
466
 
320
467
  ---
321
468
 
322
469
  ## Journey 8: Cash Out as Winner
323
470
 
471
+ **Entry point:** `JBMultiTerminal.cashOutTokensOf(address holder, uint256 projectId, uint256 cashOutCount, address tokenToReclaim, uint256 minTokensReclaimed, address payable beneficiary, bytes metadata) external returns (uint256)`
472
+
473
+ **Who can call:** Anyone can initiate, but the hook validates that `context.holder` owns the tokens being burned.
474
+
324
475
  **Actor:** Winner
325
476
  **Phase:** COMPLETE
326
477
 
478
+ ### Parameters
479
+
480
+ Same as Journey 3 (Refund).
481
+
327
482
  ### Step 1: Check claimable amounts
328
483
 
329
484
  ```solidity
@@ -357,26 +512,37 @@ jbMultiTerminal.cashOutTokensOf({
357
512
  });
358
513
  ```
359
514
 
360
- **What happens internally:**
361
- 1. `beforeCashOutRecordedWith`: computes `cashOutCount = mulDiv(surplus + amountRedeemed, cumulativeCashOutWeight, TOTAL_CASHOUT_WEIGHT)`.
362
- 2. Terminal sends reclaimed ETH to beneficiary.
363
- 3. `afterCashOutRecordedWith`: burns NFTs, increments `tokensRedeemedFrom[tierId]`, increments `amountRedeemed`.
364
- 4. Distributes fee tokens: `_claimTokensFor(holder, cumulativeMintPrice, _totalMintCost)`.
365
- - Transfers proportional share of `$DEFIFA` and `$NANA` tokens held by the hook.
366
- 5. Decrements `_totalMintCost -= cumulativeMintPrice`.
515
+ ### State changes
367
516
 
368
- **Reclaim calculation:**
369
- ```
370
- perTokenWeight = tierCashOutWeight[tierId] / totalTokensForCashoutInTier
371
- reclaimAmount = mulDiv(surplus + amountRedeemed, perTokenWeight, TOTAL_CASHOUT_WEIGHT)
372
- ```
517
+ 1. ERC-721 tokens burned via `DefifaHook._burn(tokenId)`.
518
+ 2. `DefifaHook.tokensRedeemedFrom[tierId]` -- Incremented for each burned token (only during COMPLETE).
519
+ 3. `DefifaHook.amountRedeemed` -- Incremented by `context.reclaimedAmount.value`.
520
+ 4. `DefifaHook._totalMintCost` -- Decremented by `cumulativeMintPrice` of burned tokens.
521
+ 5. Fee tokens ($DEFIFA and $NANA) transferred to holder proportional to their mint cost share.
522
+ 6. `JB721TiersHookStore` records the burn.
373
523
 
374
- Where `totalTokensForCashoutInTier = initialSupply - remainingSupply - (burnedTokens - tokensRedeemedFrom[tierId])`.
524
+ ### Events
525
+
526
+ - `ClaimedTokens(address indexed beneficiary, uint256 defifaTokenAmount, uint256 baseProtocolTokenAmount, address caller)` -- Emitted by `DefifaHookLib.claimTokensFor()` when fee tokens are distributed.
527
+
528
+ Standard ERC-721 `Transfer(from, address(0), tokenId)` emitted by the burn. Standard ERC-20 `Transfer` events emitted by the token transfers.
529
+
530
+ ### Edge cases
531
+
532
+ - `DefifaHook_Unauthorized(tokenId, owner, caller)` -- Token holder in context does not own the token.
533
+ - `DefifaHook_NothingToClaim` -- Reclaimed amount is 0 AND no fee tokens distributed.
534
+ - `JB721Hook_InvalidCashOut` -- Caller not a terminal, or wrong project ID.
535
+ - Reclaim calculation: `perTokenWeight = tierCashOutWeight[tierId] / totalTokensForCashoutInTier`, then `reclaimAmount = mulDiv(surplus + amountRedeemed, perTokenWeight, TOTAL_CASHOUT_WEIGHT)`.
536
+ - `totalTokensForCashoutInTier = initialSupply - remainingSupply - (burnedTokens - tokensRedeemedFrom[tierId])`.
375
537
 
376
538
  ---
377
539
 
378
540
  ## Journey 9: Cash Out from Losing Tier
379
541
 
542
+ **Entry point:** Same as Journey 8: `JBMultiTerminal.cashOutTokensOf(...)`
543
+
544
+ **Who can call:** Anyone (same restrictions as Journey 8).
545
+
380
546
  **Actor:** Holder of a zero-weight tier
381
547
  **Phase:** COMPLETE
382
548
 
@@ -387,18 +553,37 @@ If a tier received `cashOutWeight = 0` in the ratified scorecard:
387
553
  // beforeCashOutRecordedWith returns cashOutCount = 0
388
554
  // afterCashOutRecordedWith: reclaimedAmount.value == 0
389
555
  // _claimTokensFor is called -- if fee tokens exist, they are distributed
390
- // If no fee tokens distributed either reverts with DefifaHook_NothingToClaim
556
+ // If no fee tokens distributed either -> reverts with DefifaHook_NothingToClaim
391
557
  ```
392
558
 
393
- **Result:** Holders of losing tiers can only cash out if fee tokens (`$DEFIFA`/`$NANA`) are available. They receive fee tokens proportional to their mint cost but zero ETH. If no fee tokens exist at all, the cash-out reverts.
559
+ ### State changes
560
+
561
+ Same as Journey 8, but `context.reclaimedAmount.value == 0`.
562
+
563
+ ### Events
564
+
565
+ - `ClaimedTokens(address indexed beneficiary, uint256 defifaTokenAmount, uint256 baseProtocolTokenAmount, address caller)` -- Emitted only if fee tokens are available to distribute.
566
+
567
+ ### Edge cases
568
+
569
+ - `DefifaHook_NothingToClaim` -- Reverts if both reclaimed ETH is 0 AND no fee tokens are distributed.
570
+ - Holders of losing tiers receive fee tokens proportional to their mint cost but zero ETH. If no fee tokens exist at all, the cash-out reverts.
394
571
 
395
572
  ---
396
573
 
397
574
  ## Journey 10: No-Contest via Minimum Participation
398
575
 
576
+ **Entry point:** `DefifaDeployer.triggerNoContestFor(uint256 gameId) external`
577
+
578
+ **Who can call:** Anyone. No access control -- the function validates that the game is in NO_CONTEST phase.
579
+
399
580
  **Actor:** Any user
400
581
  **Phase:** SCORING (when balance < minParticipation)
401
582
 
583
+ ### Parameters
584
+
585
+ - `gameId` -- The ID of the game to trigger no-contest for.
586
+
402
587
  ### Scenario
403
588
 
404
589
  Game had `minParticipation = 10 ether`. During MINT, 5 ETH was deposited but then refunded down to 3 ETH. When SCORING begins, `currentGamePhaseOf()` checks:
@@ -418,12 +603,20 @@ Since 3 ETH < 10 ETH, the game is NO_CONTEST.
418
603
  deployer.triggerNoContestFor(gameId);
419
604
  ```
420
605
 
421
- **What happens internally:**
422
- 1. Verifies `currentGamePhaseOf(gameId) == NO_CONTEST`.
423
- 2. Verifies `!noContestTriggeredFor[gameId]`.
424
- 3. Sets `noContestTriggeredFor[gameId] = true`.
425
- 4. Queues new ruleset: no `fundAccessLimitGroups`, making entire balance = surplus.
426
- 5. Emits `QueuedNoContest(gameId, msg.sender)`.
606
+ ### State changes
607
+
608
+ 1. `DefifaDeployer.noContestTriggeredFor[gameId]` -- Set to `true`.
609
+ 2. New ruleset queued via `CONTROLLER.queueRulesetsOf()` with no `fundAccessLimitGroups`, making entire balance = surplus. Has `pausePay: true` and `cashOutTaxRate: 0`.
610
+
611
+ ### Events
612
+
613
+ - `QueuedNoContest(uint256 indexed gameId, address caller)` -- Emitted by `DefifaDeployer`.
614
+
615
+ ### Edge cases
616
+
617
+ - `DefifaDeployer_NotNoContest` -- Game not in NO_CONTEST phase.
618
+ - `DefifaDeployer_NoContestAlreadyTriggered` -- Already triggered for this game.
619
+ - The queued ruleset does not take effect until the current ruleset's cycle ends. During this gap, the game reports NO_CONTEST but the on-chain ruleset still has payout limits. Callers should verify the active ruleset before cashing out.
427
620
 
428
621
  ### Step 2: Cash out (full refund)
429
622
 
@@ -433,9 +626,17 @@ After triggering, users can cash out at mint price (same as Journey 3/4). The ne
433
626
 
434
627
  ## Journey 11: No-Contest via Scorecard Timeout
435
628
 
629
+ **Entry point:** Same as Journey 10: `DefifaDeployer.triggerNoContestFor(uint256 gameId) external`
630
+
631
+ **Who can call:** Anyone. Same restrictions as Journey 10.
632
+
436
633
  **Actor:** Any user
437
634
  **Phase:** SCORING (when timeout elapsed without ratification)
438
635
 
636
+ ### Parameters
637
+
638
+ - `gameId` -- The ID of the game.
639
+
439
640
  ### Scenario
440
641
 
441
642
  Game had `scorecardTimeout = 7 days`. Scoring started 8 days ago. No scorecard was ratified.
@@ -446,19 +647,40 @@ if (_ops.scorecardTimeout > 0 && block.timestamp > _currentRuleset.start + _ops.
446
647
  }
447
648
  ```
448
649
 
449
- ### Steps
650
+ ### State changes
450
651
 
451
- Same as Journey 10: call `triggerNoContestFor()`, then cash out.
652
+ Same as Journey 10.
452
653
 
453
- **Important:** If a scorecard is ratified BEFORE the timeout, the game transitions to COMPLETE and the timeout becomes irrelevant. `cashOutWeightIsSet` is checked before the timeout condition in `currentGamePhaseOf()`.
654
+ ### Events
655
+
656
+ Same as Journey 10: `QueuedNoContest(uint256 indexed gameId, address caller)`.
657
+
658
+ ### Edge cases
659
+
660
+ Same as Journey 10. Additionally: if a scorecard is ratified BEFORE the timeout, the game transitions to COMPLETE and the timeout becomes irrelevant. `cashOutWeightIsSet` is checked before the timeout condition in `currentGamePhaseOf()`.
454
661
 
455
662
  ---
456
663
 
457
664
  ## Journey 12: Delegate Attestation Power
458
665
 
666
+ **Entry point (single tier):** `DefifaHook.setTierDelegateTo(address delegatee, uint256 tierId) public`
667
+
668
+ **Entry point (multiple tiers):** `DefifaHook.setTierDelegatesTo(DefifaDelegation[] memory delegations) external`
669
+
670
+ **Who can call:** Any NFT holder (`msg.sender` is the delegator). Only callable during MINT phase.
671
+
459
672
  **Actor:** Player (NFT holder)
460
673
  **Phase:** MINT only
461
674
 
675
+ ### Parameters (single)
676
+
677
+ - `delegatee` -- Address to delegate attestation power to. Cannot be `address(0)`.
678
+ - `tierId` -- The tier ID to delegate attestation units for.
679
+
680
+ ### Parameters (multiple)
681
+
682
+ - `delegations` -- Array of `DefifaDelegation` structs, each containing `delegatee` and `tierId`.
683
+
462
684
  ### Single tier delegation
463
685
 
464
686
  ```solidity
@@ -475,17 +697,44 @@ delegations[1] = DefifaDelegation({delegatee: anotherDelegate, tierId: 2});
475
697
  hook.setTierDelegatesTo(delegations);
476
698
  ```
477
699
 
478
- **Restrictions:**
479
- - Only during MINT phase. Reverts with `DefifaHook_DelegateChangesUnavailableInThisPhase` after MINT.
480
- - Cannot delegate to `address(0)`. Reverts with `DefifaHook_DelegateAddressZero`.
481
- - On NFT transfer after MINT: auto-delegates to recipient if recipient has no delegate (DefifaHook lines 1027-1031).
700
+ ### State changes
701
+
702
+ 1. `DefifaHook._tierDelegation[msg.sender][tierId]` -- Set to the new `delegatee`.
703
+ 2. `DefifaHook._delegateTierCheckpoints[oldDelegate][tierId]` -- Checkpointed with decreased attestation units.
704
+ 3. `DefifaHook._delegateTierCheckpoints[newDelegate][tierId]` -- Checkpointed with increased attestation units.
705
+
706
+ ### Events
707
+
708
+ - `DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate)` -- Emitted per tier delegation change.
709
+ - `TierDelegateAttestationsChanged(address indexed delegate, uint256 indexed tierId, uint256 previousBalance, uint256 newBalance, address caller)` -- Emitted for both the old delegate (units removed) and the new delegate (units added).
710
+
711
+ ### Edge cases
712
+
713
+ - `DefifaHook_DelegateAddressZero` -- Delegatee is `address(0)`.
714
+ - `DefifaHook_DelegateChangesUnavailableInThisPhase` -- Not in MINT phase.
715
+ - On NFT transfer after MINT: auto-delegates to recipient if recipient has no delegate.
482
716
 
483
717
  ---
484
718
 
485
719
  ## Journey 13: Mint Reserved Tokens
486
720
 
721
+ **Entry point (single tier):** `DefifaHook.mintReservesFor(uint256 tierId, uint256 count) public`
722
+
723
+ **Entry point (multiple tiers):** `DefifaHook.mintReservesFor(JB721TiersMintReservesConfig[] calldata mintReservesForTiersData) external`
724
+
725
+ **Who can call:** Anyone. No access control. Must not be paused (`pauseMintPendingReserves` must be false).
726
+
487
727
  **Actor:** Anyone
488
- **Phase:** After MINT (reserved minting is paused during MINT via `pauseMintPendingReserves: true`)
728
+ **Phase:** SCORING or later (reserved minting is paused during both MINT and REFUND via `pauseMintPendingReserves: true`)
729
+
730
+ ### Parameters (single)
731
+
732
+ - `tierId` -- The tier ID to mint reserved tokens for.
733
+ - `count` -- Number of reserved tokens to mint.
734
+
735
+ ### Parameters (multiple)
736
+
737
+ - `mintReservesForTiersData` -- Array of `JB721TiersMintReservesConfig` structs, each containing `tierId` and `count`.
489
738
 
490
739
  ### Single tier
491
740
 
@@ -502,61 +751,110 @@ configs[0] = JB721TiersMintReservesConfig({tierId: 1, count: 5});
502
751
  hook.mintReservesFor(configs);
503
752
  ```
504
753
 
505
- **What happens internally:**
506
- 1. Checks `pauseMintPendingReserves` is false in current ruleset metadata.
507
- 2. Gets `reserveBeneficiary` from store for the tier.
508
- 3. If beneficiary has no delegate: auto-delegates to `defaultAttestationDelegate` or self.
509
- 4. Records mint in store, increments `_totalMintCost += tier.price * count`.
510
- 5. Mints ERC-721 tokens to the reserve beneficiary.
511
- 6. Transfers attestation units to the beneficiary's delegate.
754
+ ### State changes
755
+
756
+ 1. `DefifaHook._totalMintCost` -- Incremented by `tier.price * count`.
757
+ 2. `DefifaHook._tierDelegation[beneficiary][tierId]` -- Set to `defaultAttestationDelegate` or self (if no delegate exists).
758
+ 3. `DefifaHook._delegateTierCheckpoints[delegate][tierId]` -- Checkpointed with new attestation units.
759
+ 4. `DefifaHook._totalTierCheckpoints[tierId]` -- Checkpointed with increased total attestation units.
760
+ 5. ERC-721 tokens minted to `reserveBeneficiary`.
761
+ 6. `JB721TiersHookStore` records the reserve mint.
762
+
763
+ ### Events
764
+
765
+ - `MintReservedToken(uint256 indexed tokenId, uint256 indexed tierId, address indexed beneficiary, address caller)` -- Emitted per reserved token minted.
766
+ - `DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate)` -- Emitted if delegation is set for the reserve beneficiary.
767
+ - `TierDelegateAttestationsChanged(address indexed delegate, uint256 indexed tierId, uint256 previousBalance, uint256 newBalance, address caller)` -- Emitted when attestation units are transferred to the delegate.
512
768
 
513
- **Note:** Reserved mints inflate `_totalMintCost` even though no ETH was paid. This dilutes paid minters' share of fee tokens. This is by design (see RISKS.md, RISK-4).
769
+ ### Edge cases
770
+
771
+ - `DefifaHook_ReservedTokenMintingPaused` -- `pauseMintPendingReserves` is true in current ruleset metadata.
772
+ - Reserved mints inflate `_totalMintCost` even though no ETH was paid. This dilutes paid minters' share of fee tokens. This is by design (see RISKS.md, RISK-4).
514
773
 
515
774
  ---
516
775
 
517
776
  ## Journey 14: Fulfill Commitments Separately
518
777
 
778
+ **Entry point:** `DefifaDeployer.fulfillCommitmentsOf(uint256 gameId) external`
779
+
780
+ **Who can call:** Anyone. No access control. Requires `cashOutWeightIsSet == true`.
781
+
519
782
  **Actor:** Anyone
520
783
  **Phase:** COMPLETE (after scorecard ratification)
521
784
 
785
+ ### Parameters
786
+
787
+ - `gameId` -- The ID of the game to fulfill commitments for.
788
+
522
789
  `fulfillCommitmentsOf()` is called automatically during ratification. If `sendPayoutsOf` fails internally, the try-catch in `fulfillCommitmentsOf` emits `CommitmentPayoutFailed`, sets the sentinel value, and still queues the final ruleset. The fee amount stays in the pot.
523
790
 
524
- If needed, `fulfillCommitmentsOf` can be called again manually but since the sentinel is already set and the final ruleset already queued, it returns immediately (idempotent):
791
+ If needed, `fulfillCommitmentsOf` can be called again manually -- but since the sentinel is already set and the final ruleset already queued, it returns immediately (idempotent):
525
792
 
526
793
  ```solidity
527
794
  deployer.fulfillCommitmentsOf(gameId);
528
795
  ```
529
796
 
530
- **What happens internally:**
531
- 1. If `fulfilledCommitmentsOf[gameId] != 0`: returns immediately (idempotent).
532
- 2. Requires `cashOutWeightIsSet == true`.
533
- 3. Computes fee from pot: `mulDiv(pot, _commitmentPercentOf[gameId], SPLITS_TOTAL_PERCENT)`.
534
- 4. Try-catch: calls `terminal.sendPayoutsOf()` to distribute fees to splits. On failure, emits `CommitmentPayoutFailed` and sets sentinel.
535
- 5. Queues final ruleset with no payout limits.
797
+ ### State changes
798
+
799
+ 1. `DefifaDeployer.fulfilledCommitmentsOf[gameId]` -- Set to fee amount (or sentinel value 1 if pot is 0 or payout fails).
800
+ 2. Fee payouts sent via `terminal.sendPayoutsOf()` (distributes to splits).
801
+ 3. Final ruleset queued via `CONTROLLER.queueRulesetsOf()` with no payout limits.
802
+
803
+ ### Events
804
+
805
+ - `FulfilledCommitments(uint256 indexed gameId, uint256 pot, address caller)` -- Emitted by `DefifaDeployer` on success.
806
+ - `CommitmentPayoutFailed(uint256 indexed gameId, uint256 amount, bytes reason)` -- Emitted if `sendPayoutsOf` fails (try-catch).
807
+
808
+ ### Edge cases
809
+
810
+ - `DefifaDeployer_CantFulfillYet` -- `cashOutWeightIsSet == false`.
811
+ - Idempotent: If `fulfilledCommitmentsOf[gameId] != 0`, returns immediately without reverting.
812
+ - Fee computation: `mulDiv(pot, _commitmentPercentOf[gameId], SPLITS_TOTAL_PERCENT)`.
536
813
 
537
814
  ---
538
815
 
539
816
  ## Journey 15: Transfer NFT to Another Player
540
817
 
818
+ **Entry point:** `DefifaHook.transferFrom(address from, address to, uint256 tokenId) external` or `DefifaHook.safeTransferFrom(address from, address to, uint256 tokenId) external`
819
+
820
+ **Who can call:** Token owner or approved operator (standard ERC-721 access control). Transfers may be paused if `transfersPausable` is set and paused in the current ruleset.
821
+
541
822
  **Actor:** NFT holder
542
823
  **Phase:** Any (unless `transfersPausable` is set and transfers are paused)
543
824
 
825
+ ### Parameters
826
+
827
+ - `from` -- Current token owner.
828
+ - `to` -- Recipient address.
829
+ - `tokenId` -- The token to transfer.
830
+
544
831
  ```solidity
545
832
  hook.transferFrom(from, to, tokenId);
546
833
  // or
547
834
  hook.safeTransferFrom(from, to, tokenId);
548
835
  ```
549
836
 
550
- **What happens internally (DefifaHook._update):**
551
- 1. Gets tier info from store.
552
- 2. Calls `super._update()` for standard ERC-721 transfer.
553
- 3. If `transfersPausable` and transfers paused in current ruleset: reverts.
554
- 4. If first transfer of this token: stores `_firstOwnerOf[tokenId] = from`.
555
- 5. Records transfer in store: `store.recordTransferForTier(tierId, from, to)`.
556
- 6. Skips attestation transfer on mint (handled separately in `_processPayment`).
557
- 7. On regular transfer: `_transferTierAttestationUnits(from, to, tierId, tier.votingUnits)`.
558
- - If recipient has no delegate set: auto-delegates to self.
559
- - Moves attestation units from sender's delegate to recipient's delegate.
837
+ ### State changes
838
+
839
+ 1. ERC-721 ownership updated from `from` to `to`.
840
+ 2. `DefifaHook._firstOwnerOf[tokenId]` -- Stored as `from` on first transfer of this token.
841
+ 3. `JB721TiersHookStore` records the transfer via `recordTransferForTier(tierId, from, to)`.
842
+ 4. `DefifaHook._tierDelegation[to][tierId]` -- Auto-set to `to` if recipient has no delegate.
843
+ 5. `DefifaHook._delegateTierCheckpoints[fromDelegate][tierId]` -- Checkpointed with decreased attestation units.
844
+ 6. `DefifaHook._delegateTierCheckpoints[toDelegate][tierId]` -- Checkpointed with increased attestation units.
845
+
846
+ ### Events
847
+
848
+ - `DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate)` -- Emitted if recipient has no delegate and auto-delegates to self.
849
+ - `TierDelegateAttestationsChanged(address indexed delegate, uint256 indexed tierId, uint256 previousBalance, uint256 newBalance, address caller)` -- Emitted for both sender's delegate (units removed) and recipient's delegate (units added).
850
+
851
+ Standard ERC-721 `Transfer(from, to, tokenId)` is also emitted.
852
+
853
+ ### Edge cases
854
+
855
+ - `DefifaHook_TransfersPaused` -- `transfersPausable` is set and transfers paused in current ruleset.
856
+ - On transfer after MINT phase: attestation units are transferred but delegation cannot be changed by the sender.
857
+ - Auto-delegation: if recipient has no delegate, they auto-delegate to themselves.
560
858
 
561
859
  ---
562
860
 
@@ -566,12 +864,20 @@ hook.safeTransferFrom(from, to, tokenId);
566
864
 
567
865
  ### Check game phase
568
866
 
867
+ **Entry point:** `DefifaDeployer.currentGamePhaseOf(uint256 gameId) public view returns (DefifaGamePhase)`
868
+
869
+ **Who can call:** Anyone (view function).
870
+
569
871
  ```solidity
570
872
  DefifaGamePhase phase = deployer.currentGamePhaseOf(gameId);
571
873
  ```
572
874
 
573
875
  ### Check game pot
574
876
 
877
+ **Entry point:** `DefifaDeployer.currentGamePotOf(uint256 gameId, bool includeCommitments) external view returns (uint256, address, uint256)`
878
+
879
+ **Who can call:** Anyone (view function).
880
+
575
881
  ```solidity
576
882
  (uint256 pot, address token, uint256 decimals) = deployer.currentGamePotOf(gameId, false);
577
883
  // includeCommitments = true adds fulfilled fee amounts back
@@ -579,25 +885,44 @@ DefifaGamePhase phase = deployer.currentGamePhaseOf(gameId);
579
885
 
580
886
  ### Check timing
581
887
 
888
+ **Entry point:** `DefifaDeployer.timesFor(uint256 gameId) external view returns (uint48, uint24, uint24)`
889
+
890
+ **Who can call:** Anyone (view function).
891
+
582
892
  ```solidity
583
893
  (uint48 start, uint24 mintDuration, uint24 refundDuration) = deployer.timesFor(gameId);
584
894
  ```
585
895
 
586
896
  ### Check safety params
587
897
 
898
+ **Entry point:** `DefifaDeployer.safetyParamsOf(uint256 gameId) external view returns (uint256 minParticipation, uint32 scorecardTimeout)`
899
+
900
+ **Who can call:** Anyone (view function).
901
+
588
902
  ```solidity
589
903
  (uint256 minParticipation, uint32 scorecardTimeout) = deployer.safetyParamsOf(gameId);
590
904
  ```
591
905
 
592
906
  ### Check scorecard state
593
907
 
908
+ **Entry point:** `DefifaGovernor.stateOf(uint256 gameId, uint256 scorecardId) public view returns (DefifaScorecardState)`
909
+
910
+ **Who can call:** Anyone (view function).
911
+
594
912
  ```solidity
595
913
  DefifaScorecardState state = governor.stateOf(gameId, scorecardId);
596
- // PENDING ACTIVE SUCCEEDED RATIFIED (or DEFEATED)
914
+ // PENDING -> ACTIVE -> SUCCEEDED -> RATIFIED (or DEFEATED)
597
915
  ```
598
916
 
599
917
  ### Check attestation status
600
918
 
919
+ **Entry points:**
920
+ - `DefifaGovernor.attestationCountOf(uint256 gameId, uint256 scorecardId) external view returns (uint256)`
921
+ - `DefifaGovernor.quorum(uint256 gameId) public view returns (uint256)`
922
+ - `DefifaGovernor.hasAttestedTo(uint256 gameId, uint256 scorecardId, address account) external view returns (bool)`
923
+
924
+ **Who can call:** Anyone (view functions).
925
+
601
926
  ```solidity
602
927
  uint256 count = governor.attestationCountOf(gameId, scorecardId);
603
928
  uint256 needed = governor.quorum(gameId);
@@ -606,6 +931,12 @@ bool hasAttested = governor.hasAttestedTo(gameId, scorecardId, account);
606
931
 
607
932
  ### Check cash-out value
608
933
 
934
+ **Entry points:**
935
+ - `DefifaHook.cashOutWeightOf(uint256 tokenId) external view returns (uint256)`
936
+ - `DefifaHook.cashOutWeightOf(uint256[] tokenIds) external view returns (uint256)` (aggregate)
937
+
938
+ **Who can call:** Anyone (view functions).
939
+
609
940
  ```solidity
610
941
  // Single token
611
942
  uint256 weight = hook.cashOutWeightOf(tokenId);
@@ -619,6 +950,12 @@ uint256 totalWeight = hook.cashOutWeightOf(ids);
619
950
 
620
951
  ### Check fee token claims
621
952
 
953
+ **Entry points:**
954
+ - `DefifaHook.tokensClaimableFor(uint256[] memory tokenIds) external view returns (uint256, uint256)`
955
+ - `DefifaHook.tokenAllocations() external view returns (uint256, uint256)`
956
+
957
+ **Who can call:** Anyone (view functions).
958
+
622
959
  ```solidity
623
960
  (uint256 defifaTokens, uint256 nanaTokens) = hook.tokensClaimableFor(tokenIds);
624
961
  (uint256 defifaBalance, uint256 nanaBalance) = hook.tokenAllocations();
@@ -648,19 +985,19 @@ uint256 totalWeight = hook.cashOutWeightOf(ids);
648
985
 
649
986
  ### Scorecard Errors (Journeys 5, 6, 7)
650
987
 
651
- | Error | Condition |
652
- |-------|-----------|
653
- | `DefifaGovernor_NotAllowed` | Game not in SCORING, or scorecard not in correct state |
654
- | `DefifaGovernor_UnownedProposedCashoutValue` | Weight > 0 assigned to tier with 0 supply |
655
- | `DefifaGovernor_DuplicateScorecard` | Identical scorecard already submitted |
656
- | `DefifaGovernor_AlreadyAttested` | Account already attested to this scorecard |
657
- | `DefifaGovernor_AlreadyRatified` | Game already has a ratified scorecard |
658
- | `DefifaGovernor_UnknownProposal` | Scorecard ID has no submission record |
659
- | `DefifaHook_InvalidCashoutWeights` | Weights do not sum to TOTAL_CASHOUT_WEIGHT |
660
- | `DefifaHook_BadTierOrder` | Tier IDs not in ascending order |
661
- | `DefifaHook_InvalidTierId` | Tier not in category 0, or tier ID > maxTierId |
662
- | `DefifaHook_GameIsntScoringYet` | Game not in SCORING phase when setting weights |
663
- | `DefifaHook_CashoutWeightsAlreadySet` | Weights already set (double-set attempt) |
988
+ | Error | Condition | Journey |
989
+ |-------|-----------|---------|
990
+ | `DefifaGovernor_NotAllowed` | Game not in SCORING, or scorecard not in correct state | 5, 6, 7 |
991
+ | `DefifaGovernor_UnownedProposedCashoutValue` | Weight > 0 assigned to tier with 0 supply | 5 |
992
+ | `DefifaGovernor_DuplicateScorecard` | Identical scorecard already submitted | 5 |
993
+ | `DefifaGovernor_AlreadyAttested` | Account already attested to this scorecard | 6 |
994
+ | `DefifaGovernor_AlreadyRatified` | Game already has a ratified scorecard | 5, 7 |
995
+ | `DefifaGovernor_UnknownProposal` | Scorecard ID has no submission record | 6, 7 |
996
+ | `DefifaHook_InvalidCashoutWeights` | Weights do not sum to TOTAL_CASHOUT_WEIGHT | 7 (ratification) |
997
+ | `DefifaHook_BadTierOrder` | Tier IDs not in ascending order | 7 (ratification) |
998
+ | `DefifaHook_InvalidTierId` | Tier not in category 0, or tier ID > maxTierId | 7 (ratification) |
999
+ | `DefifaHook_GameIsntScoringYet` | Game not in SCORING phase when setting weights | 7 (ratification) |
1000
+ | `DefifaHook_CashoutWeightsAlreadySet` | Weights already set (double-set attempt) | 7 (ratification) |
664
1001
 
665
1002
  ### No-Contest Errors (Journeys 10, 11)
666
1003
 
@@ -689,3 +1026,9 @@ uint256 totalWeight = hook.cashOutWeightOf(ids);
689
1026
  | `DefifaDeployer_InvalidGameConfiguration` | Timing constraints violated: `mintPeriodDuration == 0` or `start < block.timestamp + refund + mint` |
690
1027
  | `DefifaDeployer_SplitsDontAddUp` | User splits + protocol fees exceed 100% |
691
1028
  | `DefifaDeployer_InvalidGameConfiguration` | JB project ID mismatch (front-run) |
1029
+
1030
+ ### Transfer Errors (Journey 15)
1031
+
1032
+ | Error | Condition |
1033
+ |-------|-----------|
1034
+ | `DefifaHook_TransfersPaused` | `transfersPausable` is set and transfers paused in current ruleset |