@ballkidz/defifa 0.0.31 → 0.0.34

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.
@@ -4,7 +4,9 @@ Defifa is a staged prediction-game system built on Juicebox and the tiered 721 s
4
4
 
5
5
  ## Audit Objective
6
6
 
7
- Find issues that:
7
+ There is a billion dollars of well-meaning projects' money in the Juicebox Money Engine, growing exponentially. Your job is to hack it before anyone else. Whoever hacks it first saves/steals the money, and you are obsessed with being this winner, while also being a steward of the protocol and wanting it to keep growing safely.
8
+
9
+ Suggestions of where to look:
8
10
 
9
11
  - let players extract more than their fair share of the game pot
10
12
  - break the game-phase lifecycle or allow actions in the wrong phase
package/CHANGELOG.md CHANGED
@@ -23,6 +23,8 @@ This file instead describes the current v6 repo at a high level and the broad mi
23
23
  ## Local review remediations
24
24
 
25
25
  - Reserve-minted NFTs are now excluded from refund calculations during MINT, REFUND, and NO_CONTEST phases. A public `isReserveMint` mapping tracks which tokens were created via tier reserve frequency rather than paid for. `beforeCashOutRecordedWith` subtracts their tier price from `cumulativeMintPrice`, preventing reserve beneficiaries from withdrawing funds they never contributed.
26
+ - One-tier games now revert at launch with `DefifaDeployer_InvalidGameConfiguration` if `scorecardTimeout == 0`. A one-tier game cannot reach quorum (the BWA multiplier reduces the sole tier's power to zero), so a zero timeout would leave funds permanently locked with no exit path. Enforcement moves this from a launcher-side responsibility (previously documented in `RISKS.md §8.6`) to a contract-level guarantee.
27
+ - `DefifaHook.mintReservesFor` now reverts with `DefifaHook_ReservedTokenMintingBlockedInNoContest` while the game is in `NO_CONTEST`. Reserve mints inflate `totalMintCost` so reserved recipients can claim fee tokens; without the block, a game that failed `minParticipation` could be revived back to SCORING via free notional face value before `triggerNoContestFor` latches the failure.
26
28
 
27
29
  ## Migration notes
28
30
 
package/CRYPTO_ECON.md CHANGED
@@ -777,11 +777,11 @@ Defifa includes a comprehensive safety system — the **NO_CONTEST** mechanism
777
777
 
778
778
  #### 9.1.1 Trigger 1: Minimum Participation Threshold
779
779
 
780
- **Mechanism.** At game creation, the organizer sets `minParticipation` — a minimum token supply required for the game to proceed to scoring. The `currentGamePhaseOf()` function checks the total token supply (via `CONTROLLER.TOKENS().totalSupplyOf(gameId)`) against this threshold before returning SCORING. If the supply is below the threshold, it returns NO_CONTEST.
780
+ **Mechanism.** At game creation, the organizer sets `minParticipation` — a minimum cumulative NFT mint cost required for the game to proceed to scoring. The `currentGamePhaseOf()` function checks the hook's `totalMintCost()` against this threshold before returning SCORING. If the mint cost is below the threshold, it returns NO_CONTEST. `totalMintCost` tracks only actual paid mint value and is decremented on refunds/burns — it cannot be inflated by `addToBalanceOf` top-ups.
781
781
 
782
782
  **What it solves.** Ghost games with negligible participation skip directly to refundability without requiring any governance action. A 32-team World Cup game with `minParticipation = 1 ETH` won't enter scoring if only 50 people mint (0.5 ETH pot).
783
783
 
784
- **Attack surface.** An adversary who wants to force no-contest can cash out enough tokens during the refund phase to push the supply below the threshold. Note that direct balance top-ups (via `addToBalanceOf`) cannot inflate participation since the check uses token supply, not treasury balance. Mitigation: set the threshold conservatively low relative to expected participation.
784
+ **Attack surface.** An adversary who wants to force no-contest can refund enough NFTs during the refund phase to push `totalMintCost` below the threshold. Direct balance top-ups (via `addToBalanceOf`) cannot inflate participation since the check uses `totalMintCost`, not treasury balance. Mitigation: set the threshold conservatively low relative to expected participation.
785
785
 
786
786
  **Configuration.** Set to 0 to disable. The threshold is set at launch before any minting occurs, so calibration depends on organizer judgment.
787
787
 
@@ -820,7 +820,7 @@ The phase resolution follows strict priority:
820
820
 
821
821
  2. **Explicit trigger is sticky.** Once `noContestTriggeredFor[gameId]` is set, the game stays in NO_CONTEST permanently (cannot transition to SCORING even if conditions change).
822
822
 
823
- 3. **Both thresholds are checked independently.** A game can enter NO_CONTEST from either `minParticipation` (token supply too low) or `scorecardTimeout` (time elapsed) — whichever condition is met first.
823
+ 3. **Both thresholds are checked independently.** A game can enter NO_CONTEST from either `minParticipation` (cumulative mint cost too low) or `scorecardTimeout` (time elapsed) — whichever condition is met first.
824
824
 
825
825
  #### 9.1.5 The Default Attestation Delegate
826
826
 
package/RISKS.md CHANGED
@@ -22,6 +22,7 @@ This file focuses on the game-theoretic, governance, and settlement risks in Def
22
22
  - **Deployer as project owner.** The deployer owns game projects and controls ruleset queuing and commitment fulfillment.
23
23
  - **DefifaProjectOwner irrecoverability.** Once the project NFT is transferred there, it cannot be recovered.
24
24
  - **External dependencies.** Core protocol and shared 721-store behavior remain upstream trust boundaries.
25
+ - **Terminal provenance is the launcher's responsibility.** The hook authenticates terminals via directory registration, not implementation identity. A game is only as trustworthy as the terminal its launcher chose. Users must verify a game's terminal before participating.
25
26
  - **Default attestation delegate.** If set, it can accumulate meaningful governance power across new minters.
26
27
 
27
28
  ## 2. Economic Risks
@@ -86,6 +87,22 @@ Completion and ratification paths use one-way state to prevent replay or double-
86
87
 
87
88
  This is conservative, but it prevents users from front-running reserve dilution out of governance power or fee-token distribution.
88
89
 
89
- ### 8.5 One-tier games always resolve via no-contest
90
+ ### 8.5 Launcher-selected terminals are trusted per game
90
91
 
91
- A single-tier game cannot complete normal governance because the governance attestation model gives zero weight to holders of a tier that receives 100% of the scorecard, making quorum unreachable. This is expected: the game falls through to `NO_CONTEST` once `scorecardTimeout` elapses, and players recover their mint price via the permissionless `triggerNoContestFor()` refund path that queues a refund ruleset. This only works when `scorecardTimeout > 0`. A one-tier game launched with `scorecardTimeout = 0` disables the timeout path entirely, and funds become permanently locked with no exit. Game deployers must ensure `scorecardTimeout > 0` for single-tier configurations.
92
+ `DefifaDeployer.launchGameWith(...)` is permissionless and allows the launcher to choose the terminal registered for the game. `DefifaHook` trusts any registered terminal as the source of pay and cash-out callbacks. This means a malicious launcher can register a callback-forging terminal that fabricates hook contexts without recording real payments. Users and integrators must verify a game's registered terminal before trusting or participating in it. The same applies to scorecard timing parameters and tier configuration a game's safety depends on the launcher choosing sane inputs. Frontends and aggregators should cross-reference a game's terminal against the canonical `JBMultiTerminal` for the chain before displaying it as trustworthy.
93
+
94
+ ### 8.6 One-tier games always resolve via no-contest
95
+
96
+ A single-tier game cannot complete normal governance because the governance attestation model gives zero weight to holders of a tier that receives 100% of the scorecard, making quorum unreachable. This is expected: the game falls through to `NO_CONTEST` once `scorecardTimeout` elapses, and players recover their mint price via the permissionless `triggerNoContestFor()` refund path that queues a refund ruleset. This only works when `scorecardTimeout > 0` — a one-tier game launched with `scorecardTimeout = 0` would disable the timeout path entirely and leave funds permanently locked. `DefifaDeployer.launchGameWith` now enforces this at the contract level: a one-tier game with `scorecardTimeout == 0` reverts with `DefifaDeployer_InvalidGameConfiguration`. Two-tier games are still rounding-fragile in the same direction and should also be launched with a nonzero `scorecardTimeout`, but this is not enforced because some two-tier configurations can still reach quorum.
97
+
98
+ ### 8.7 Commitment splits are responsible for never reverting
99
+
100
+ `DefifaDeployer.fulfillCommitmentsOf` calls `terminal.sendPayoutsOf` to distribute the commitment portion of the pot. Core processes splits inside a try/catch — when an individual split reverts (rejecting split hook, recipient terminal that does not accept the game token, fee project with a missing currency feed) the failed amount is silently re-credited to the game's terminal balance and the outer call succeeds. The unpaid commitment funds then remain available to game players via the cash-out path.
101
+
102
+ This means **a single bad split cannot block the others from being paid**, and the unpaid funds are never lost — but it also means the recipient configured behind the failing split does not get paid through `fulfillCommitmentsOf` and must be settled separately if the game launcher cares. Game launchers are responsible for configuring commitment splits that will not revert under the game's terminal/currency setup. Recipients of commitment splits should treat split delivery as best-effort; the canonical post-game pot accounting still holds.
103
+
104
+ `fulfilledCommitmentsOf[gameId]` always equals the requested commitment amount whenever any portion was attempted, regardless of whether every split succeeded. `currentGamePotOf(gameId, true)` adds this back to the remaining balance so the displayed pot represents the original pre-commitment value, but the actual on-terminal balance may be higher than the displayed pot when splits silently failed — by exactly the failed split amounts, which remain redeemable by game players. The bound therefore stays one-sided: real balance ≥ reported pot.
105
+
106
+ ### 8.8 Reserve minting is blocked during NO_CONTEST
107
+
108
+ `DefifaHook.mintReservesFor` reverts with `DefifaHook_ReservedTokenMintingBlockedInNoContest` once `currentGamePhaseOf` reports `NO_CONTEST`. Reserve mints inflate `totalMintCost` so reserved recipients can claim a proportional share of fee tokens. Without this block, a game that failed `minParticipation` could be revived back into a SCORING-eligible state via free notional face value (reserves bump `totalMintCost` over the threshold) before `triggerNoContestFor()` latches the failure into a refund ruleset. The block tightens the §8.4 trust assumption that pending reserves are folded into governance and fee accounting: that remains true while the game is live, but once a game has already failed participation the reserve channel is closed so the no-contest outcome is final.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ballkidz/defifa",
3
- "version": "0.0.31",
3
+ "version": "0.0.34",
4
4
  "license": "MIT",
5
5
  "engines": {
6
6
  "node": "25.9.0"
@@ -31,10 +31,10 @@
31
31
  "remappings.txt"
32
32
  ],
33
33
  "dependencies": {
34
- "@bananapus/721-hook-v6": "0.0.46",
35
- "@bananapus/address-registry-v6": "0.0.25",
36
- "@bananapus/core-v6": "0.0.44",
37
- "@bananapus/permission-ids-v6": "0.0.22",
34
+ "@bananapus/721-hook-v6": "^0.0.46",
35
+ "@bananapus/address-registry-v6": "^0.0.25",
36
+ "@bananapus/core-v6": "^0.0.48",
37
+ "@bananapus/permission-ids-v6": "^0.0.22",
38
38
  "@openzeppelin/contracts": "5.6.1",
39
39
  "@prb/math": "4.1.1",
40
40
  "scripty.sol": "2.1.1"
@@ -186,7 +186,7 @@ contract DefifaDeployer is IDefifaDeployer, IDefifaGamePhaseReporter, IDefifaGam
186
186
 
187
187
  /// @notice The safety mechanism parameters of a game.
188
188
  /// @param gameId The ID of the game to get the safety params of.
189
- /// @return minParticipation The minimum treasury balance for the game to proceed to scoring.
189
+ /// @return minParticipation The minimum cumulative NFT mint cost for the game to proceed to scoring.
190
190
  /// @return scorecardTimeout The maximum time after scoring begins for a scorecard to be ratified.
191
191
  function safetyParamsOf(uint256 gameId)
192
192
  external
@@ -249,12 +249,13 @@ contract DefifaDeployer is IDefifaDeployer, IDefifaGamePhaseReporter, IDefifaGam
249
249
  // Get the game's ops data for the safety mechanism checks. Cache to avoid repeated SLOAD.
250
250
  DefifaOpsData memory ops = _opsOf[gameId];
251
251
 
252
- // Check minimum participation threshold using token supply (not terminal balance).
253
- // Token supply reflects actual minted participation — direct `addToBalanceOf` top-ups
254
- // don't mint tokens and therefore can't bypass this check.
252
+ // Check minimum participation threshold using cumulative NFT mint cost from the hook.
253
+ // Terminal balance is NOT used because `addToBalanceOf` can inflate it without minting NFTs.
254
+ // `totalMintCost` tracks only actual paid mint value and is decremented on refunds/burns.
255
255
  if (ops.minParticipation > 0) {
256
- uint256 totalTokenSupply = CONTROLLER.TOKENS().totalSupplyOf(gameId);
257
- if (totalTokenSupply < ops.minParticipation) return DefifaGamePhase.NO_CONTEST;
256
+ if (IDefifaHook(metadata.dataHook).totalMintCost() < ops.minParticipation) {
257
+ return DefifaGamePhase.NO_CONTEST;
258
+ }
258
259
  }
259
260
 
260
261
  // Check scorecard ratification timeout: if enough time has passed without a ratified scorecard, the game is
@@ -424,6 +425,20 @@ contract DefifaDeployer is IDefifaDeployer, IDefifaGamePhaseReporter, IDefifaGam
424
425
  });
425
426
  }
426
427
 
428
+ // One-tier games cannot reach quorum (the BWA multiplier reduces the sole beneficiary tier's power to zero),
429
+ // so they must have a nonzero scorecardTimeout to resolve via NO_CONTEST. Without a timeout they would lock
430
+ // forever with no exit. Two-tier games are also rounding-fragile (see RISKS.md §8.6) and should set a
431
+ // scorecardTimeout, but this is not enforced at the contract level because some configurations can still
432
+ // reach quorum with luck-of-the-draw holder distributions.
433
+ if (launchProjectData.tiers.length == 1 && launchProjectData.scorecardTimeout == 0) {
434
+ revert DefifaDeployer_InvalidGameConfiguration({
435
+ start: launchProjectData.start,
436
+ mintPeriodDuration: launchProjectData.mintPeriodDuration,
437
+ refundPeriodDuration: launchProjectData.refundPeriodDuration,
438
+ tierCount: launchProjectData.tiers.length
439
+ });
440
+ }
441
+
427
442
  // Reject ERC-20 games with a zero currency. A zero baseCurrency would cause payout limit lookups
428
443
  // in fulfillCommitmentsOf to silently fail, skipping all commitment payouts.
429
444
  if (launchProjectData.token.token != JBConstants.NATIVE_TOKEN && launchProjectData.token.currency == 0) {
@@ -57,6 +57,7 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
57
57
  error DefifaHook_Overspending(uint256 leftoverAmount);
58
58
  error DefifaHook_CashoutWeightsAlreadySet(uint256 projectId);
59
59
  error DefifaHook_ReservedTokenMintingPaused(uint256 projectId, uint256 tierId);
60
+ error DefifaHook_ReservedTokenMintingBlockedInNoContest(uint256 projectId, uint256 tierId);
60
61
  error DefifaHook_TransfersPaused(uint256 projectId, uint256 tokenId, address from, address to);
61
62
  error DefifaHook_Unauthorized(uint256 tokenId, address owner, address caller);
62
63
 
@@ -97,10 +98,6 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
97
98
  /// @dev _tierId The ID of the tier to get a name for.
98
99
  mapping(uint256 => string) internal _tierNameOf;
99
100
 
100
- /// @notice The cumulative mint price of all tokens (paid and reserved). Used as the denominator for fee token
101
- /// ($DEFIFA/$NANA) distribution.
102
- uint256 internal _totalMintCost;
103
-
104
101
  //*********************************************************************//
105
102
  // ---------------- public immutable stored properties --------------- //
106
103
  //*********************************************************************//
@@ -115,6 +112,10 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
115
112
  // --------------------- public stored properties -------------------- //
116
113
  //*********************************************************************//
117
114
 
115
+ /// @notice The cumulative mint price of all paid and reserved NFTs. Decremented on burns/refunds. Used as the
116
+ /// participation metric — immune to `addToBalanceOf` inflation because only actual mints increment it.
117
+ uint256 public override totalMintCost;
118
+
118
119
  /// @notice The amount that has been redeemed from this game, refunds are not counted.
119
120
  uint256 public override amountRedeemed;
120
121
 
@@ -411,7 +412,7 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
411
412
  tokenIds: tokenIds,
412
413
  hookStore: store,
413
414
  hook: address(this),
414
- totalMintCost: _totalMintCost + _pendingReserveMintCost(),
415
+ totalMintCost: totalMintCost + _pendingReserveMintCost(),
415
416
  defifaBalance: DEFIFA_TOKEN.balanceOf(address(this)),
416
417
  baseProtocolBalance: BASE_PROTOCOL_TOKEN.balanceOf(address(this))
417
418
  });
@@ -568,6 +569,13 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
568
569
  revert DefifaHook_ReservedTokenMintingPaused({projectId: PROJECT_ID, tierId: tierId});
569
570
  }
570
571
 
572
+ // Block reserve minting while the game is in NO_CONTEST. Reserve mints inflate `totalMintCost` (so reserved
573
+ // recipients can claim fee tokens), which would otherwise let a game that failed `minParticipation` revive
574
+ // back to SCORING via free notional face value before `triggerNoContestFor` latches the failure.
575
+ if (_currentGamePhaseOf(PROJECT_ID) == DefifaGamePhase.NO_CONTEST) {
576
+ revert DefifaHook_ReservedTokenMintingBlockedInNoContest({projectId: PROJECT_ID, tierId: tierId});
577
+ }
578
+
571
579
  // Cache the store reference in a local variable to avoid repeated SLOAD.
572
580
  IJB721TiersHookStore hookStore = store;
573
581
 
@@ -597,11 +605,11 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
597
605
  // Fetch the tier details (needed for votingUnits below).
598
606
  JB721Tier memory tier = hookStore.tierOf({hook: address(this), id: tierId, includeResolvedUri: false});
599
607
 
600
- // Increment _totalMintCost so reserved recipients can claim their share of fee tokens ($DEFIFA/$NANA).
608
+ // Increment totalMintCost so reserved recipients can claim their share of fee tokens ($DEFIFA/$NANA).
601
609
  // Note: reserved mints dilute existing fee token claimants because they increase the total mint cost
602
610
  // denominator without contributing new funds to the fee token balances. This is the intended design —
603
611
  // reserved recipients receive a proportional claim on fee tokens as if they had paid to mint.
604
- _totalMintCost += tier.price * count;
612
+ totalMintCost += tier.price * count;
605
613
 
606
614
  for (uint256 i; i < count;) {
607
615
  // Set the token ID.
@@ -724,7 +732,7 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
724
732
  beneficiaryReceivedTokens = _claimTokensFor({
725
733
  beneficiary: context.beneficiary,
726
734
  shareToBeneficiary: cumulativeMintPrice,
727
- outOfTotal: _totalMintCost + _pendingReserveMintCost()
735
+ outOfTotal: totalMintCost + _pendingReserveMintCost()
728
736
  });
729
737
  }
730
738
 
@@ -739,7 +747,7 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
739
747
  }
740
748
 
741
749
  // Decrement the paid mint cost by the cumulative mint price of the tokens being burned.
742
- _totalMintCost -= cumulativeMintPrice;
750
+ totalMintCost -= cumulativeMintPrice;
743
751
  }
744
752
 
745
753
  /// @notice Mint reserved tokens within the tier for the provided value.
@@ -917,7 +925,7 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
917
925
  uint256 tokenId;
918
926
 
919
927
  // Increment the paid mint cost.
920
- _totalMintCost += amount;
928
+ totalMintCost += amount;
921
929
 
922
930
  // Loop through each token ID and mint.
923
931
  for (uint256 i; i < mintsLength;) {
@@ -117,6 +117,11 @@ interface IDefifaHook is IJB721Hook {
117
117
  /// @return The Defifa ERC-20 token.
118
118
  function DEFIFA_TOKEN() external view returns (IERC20);
119
119
 
120
+ /// @notice The cumulative mint price of all paid and reserved NFTs. Decremented on burns/refunds. Used as the
121
+ /// participation metric — immune to `addToBalanceOf` inflation because only actual mints increment it.
122
+ /// @return The total mint cost in the game's payment token.
123
+ function totalMintCost() external view returns (uint256);
124
+
120
125
  /// @notice The first owner of a given token ID.
121
126
  /// @param tokenId The token ID.
122
127
  /// @return The address of the first owner.
@@ -30,8 +30,9 @@ import {DefifaTierParams} from "./DefifaTierParams.sol";
30
30
  /// @custom:member defaultAttestationDelegate The address that'll be set as the attestation delegate by default.
31
31
  /// @custom:member defaultTokenUriResolver The contract used to resolve token URIs if not provided by a tier
32
32
  /// specifically. @custom:member terminal The payment terminal where the project will accept funds through.
33
- /// @custom:member minParticipation The minimum treasury balance required for the game to proceed to scoring. If the
34
- /// balance is below this when scoring would begin, the game enters NO_CONTEST. Set to 0 to disable. @custom:member
33
+ /// @custom:member minParticipation The minimum cumulative NFT mint cost required for the game to proceed to scoring.
34
+ /// Compared against the hook's `totalMintCost` (immune to `addToBalanceOf` inflation). If below this when scoring
35
+ /// would begin, the game enters NO_CONTEST. Set to 0 to disable. @custom:member
35
36
  /// scorecardTimeout The maximum time (in seconds) after the scoring phase begins for a scorecard to be ratified. If
36
37
  /// exceeded, the game enters NO_CONTEST. Set to 0 to disable.
37
38
  struct DefifaLaunchProjectData {
@@ -6,9 +6,10 @@ pragma solidity ^0.8.0;
6
6
  /// @custom:member start The time at which the game should start, measured in seconds.
7
7
  /// @custom:member mintPeriodDuration The duration of the game's mint phase, measured in seconds.
8
8
  /// @custom:member refundPeriodDuration The time between the mint phase and the start time when mint's are no longer
9
- /// open but refunds are still allowed, measured in seconds. @custom:member minParticipation The minimum treasury
10
- /// balance required for the game to proceed to scoring. If the balance is below this when scoring would begin, the game
11
- /// enters NO_CONTEST. Set to 0 to disable.
9
+ /// open but refunds are still allowed, measured in seconds. @custom:member minParticipation The minimum cumulative NFT
10
+ /// mint cost required for the game to proceed to scoring. Compared against the hook's `totalMintCost` (immune to
11
+ /// `addToBalanceOf` inflation). If below this when scoring would begin, the game enters NO_CONTEST. Set to 0 to
12
+ /// disable.
12
13
  /// @custom:member scorecardTimeout The maximum time (in seconds) after the scoring phase begins for a scorecard to be
13
14
  /// ratified. If exceeded, the game enters NO_CONTEST. Set to 0 to disable.
14
15
  struct DefifaOpsData {