Skip to content

[Audit] [MEDIUM] Zero-timeout Defifa games can lock if only one tier participates #148

Description

@mejango

Audit seed

Nemesis fresh round / defifa all src/**/*.sol and script/**/*.sol / lifecycle-state coupling, governance, settlement.

Repos involved

  • defifa
  • Boundary context: nana-core-v6, nana-721-hook-v6

Root cause

DefifaDeployer.launchGameWith only rejects tiers.length == 1 && scorecardTimeout == 0, but governance reachability is determined later by runtime participated tiers, not configured tier count. A multi-tier game with scorecardTimeout == 0 can enter scoring with only one tier having supply. At that point the only valid scorecard assigns 100% cash-out weight to that tier, which gives its holders zero BWA power, while quorum() still returns a nonzero threshold for the one participated tier.

Relevant code:

  • defifa/src/DefifaDeployer.sol:428-440 rejects only one configured tier with zero timeout.
  • defifa/src/DefifaDeployer.sol:261-267 only enters NO_CONTEST on timeout when scorecardTimeout > 0.
  • defifa/src/DefifaGovernor.sol:301-309 rejects nonzero scorecard weights for unowned tiers.
  • defifa/src/libraries/DefifaHookLib.sol:444-448 requires weights to sum to TOTAL_CASHOUT_WEIGHT.
  • defifa/src/DefifaGovernor.sol:703-707 sets BWA multiplier to zero for a 100%-weighted tier.
  • defifa/src/DefifaGovernor.sol:173-177 rejects zero-weight attestations.
  • defifa/src/DefifaGovernor.sol:739-759 returns a nonzero quorum for one participated tier.
  • defifa/src/DefifaDeployer.sol:646-650 prevents no-contest triggering unless the phase already reports NO_CONTEST.
  • defifa/src/DefifaHook.sol:744-751 reverts cash-out when no reclaim/fee-token claim exists.

Impact

Funds paid into a validly launched multi-tier game can be stuck with no on-chain final settlement or no-contest refund path if only one tier participates before scoring. This can happen naturally in one-sided markets, and a malicious launcher can increase the likelihood by disabling the timeout and configuring unattractive alternative tiers.

Severity: MEDIUM. The impact is a permanent liveness/settlement failure for affected game participants, but it depends on a zero-timeout configuration and one-sided participation.

Proof of concept

A temporary Foundry test was run locally:

forge test --match-path 'test/audit/CodexNemesisSingleParticipatedTierTimeout.t.sol' --match-test test_codexNemesis_singleParticipatedTierZeroTimeoutLocksGame -vvv

[PASS] test_codexNemesis_singleParticipatedTierZeroTimeoutLocksGame() (gas: 2793150)
1 tests passed, 0 failed

PoC sequence:

  1. Launch a four-tier game with scorecardTimeout = 0.
  2. Mint only tier 1 before scoring.
  3. Move to SCORING.
  4. Submit the only valid scorecard, assigning all cash-out weight to tier 1.
  5. Tier 1 holder attests. The call reverts because BWA weight is zero.
  6. Warp one year. The phase remains SCORING because timeout is disabled.
  7. triggerNoContestFor reverts DefifaDeployer_NotNoContest.
  8. Cashing out the player token reverts DefifaHook_NothingToClaim.

Why this survived self-review

Strongest counterargument: the launch guard intentionally allows some two-plus-tier zero-timeout games because they may still reach quorum depending on holder distribution.

Why it fails: the contract cannot enforce that distribution at launch, and the later governance path explicitly switches to runtime supply. Once runtime participation collapses to one tier, neither ratification nor timeout recovery remains reachable.

Additional mitigations checked:

  • No hidden no-contest transition exists when scorecardTimeout == 0.
  • Nonzero weights for unowned tiers cannot be used to create nonzero BWA opposition.
  • Cash-out without final weights reverts instead of providing a fallback refund.

Recommended fix

Require a nonzero scorecardTimeout for every game unless the contract enforces a stronger runtime participation invariant:

if (launchProjectData.scorecardTimeout == 0) {
    revert DefifaDeployer_InvalidGameConfiguration({
        start: launchProjectData.start,
        mintPeriodDuration: launchProjectData.mintPeriodDuration,
        refundPeriodDuration: launchProjectData.refundPeriodDuration,
        tierCount: launchProjectData.tiers.length
    });
}

Alternatively, make currentGamePhaseOf return NO_CONTEST when scoring has fewer than two eligible participated tiers and no scorecard has been ratified, using the same eligible-tier definition as quorum().

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions