Status DataClose notification

Majority Games Disclosed Report

ProportionalToXPReward winners-count mismatch can permanently lock the prize pool (ties expand winners)

Created date
Aug 29 2025

Target

hidden

Vulnerability Details

summary

  • If the game creator configures ProportionalToXPReward with N winners but the session strategy later returns a winners array of length M != N (e.g., because ties/draw expand beyond top N), every winner claim reverts with NumberOfWinnersMismatch. The session has already been Concluded, so the post-fee prize pool cannot be withdrawn and remains locked on SessionManager indefinitely.

realistic scenario

  • Creator configures top-N winners (e.g., N=3) during Created state.
  • Offchain scoring sees ties/draw among users at the Nth position and includes N+1 winners in the UMA assertion.
  • Onchain, DefaultSession records all winners from UMA; at claim time ProportionalToXPReward reverts for every winner due to NumberOfWinnersMismatch.
  • Since there is no sweep/fallback, the prize pool stays stranded in SessionManager.

impact

  • Permanent lock of the post-fee prize pool on SessionManager.
  • Locked amount ≈ totalCollectedAmount * (BASIS_POINTS - (creatorFee + protocolFee + REFERRER_FEE)) / BASIS_POINTS (minus any fees already distributed). This can be the majority of sponsorship funds and/or entries.

likelihood

. It requires only normal creator configuration at Created time and an offchain winners computation that includes ties/draw of winers greater than configured `ProportionalToXPReward (or otherwise yields a different number of winners than the creator set). Onchain only checks that rewards are “configured” (>0), not that the configured count matches the final winners length.

affected components

  • Reward strategy: src/reward/ProportionalToXPReward.sol
  • Session lifecycle: src/SessionManager.sol
  • Default strategy (offchain UMA): src/session/DefaultSession.sol

root cause from code

  • Session start only checks rewardsConfigured(sessionId); for Proportional this means numberOfWinners > 0, not that it matches winners length:
require(
    IRewardStrategy(game.rewardStrategy).rewardsConfigured(sessionId),
    RewardsNotConfigured(sessionId)
);
game.state = SessionState.Ongoing;
function rewardsConfigured(uint256 sessionId) external view returns (bool) {
    return numberOfWinners[sessionId] > 0;
}
  • At claim time, ProportionalToXPReward requires exact equality between configured count and the strategy’s winners length; on mismatch it reverts for all claims:
require(numberOfWinners[sessionId] == winners.length, NumberOfWinnersMismatch(sessionId, winners.length));
ISessionStrategy sessionStrategy = ISessionStrategy(sessionManager.getSessionStrategy(sessionId));
uint256[] memory xps = new uint256[](winners.length);
// ...
rewards[i] = xps[i] * prizePool / totalXP;
require(numberOfWinners[sessionId] == winners.length, NumberOfWinnersMismatch(sessionId, winners.length));
ISessionStrategy sessionStrategy = ISessionStrategy(sessionManager.getSessionStrategy(sessionId));
// ...
reward = userXP * prizePool / totalXP;
  • The claim path in SessionManager delegates the calculation to the reward strategy; a mismatch thus DoS-es all claims:
address[] memory winners = ISessionStrategy(game.sessionStrategy).getWinners(sessionId);
require(winners[position] == msg.sender, NotWinner(winners[position], msg.sender));
uint256 prizePool = getRewards(sessionId);
uint256 reward = IRewardStrategy(game.rewardStrategy).getReward(sessionId, winners, position, prizePool);
_distributeRewards(sessionId, msg.sender, reward);
  • Winners are provided offchain via UMA and recorded onchain verbatim (no dedup/length checks enforcement):
function assertResults(
    uint256 sessionId,
    string calldata resultUri,
    address[] calldata proposedWinners,
    uint256[] calldata totalXPs,
    uint256[] calldata totalTimes,
    bytes[] calldata solutions
) external returns (bytes32 assertionId) {
    // ... forwards full winners data to UMA
}
function assertionResolvedCallback(bytes32 assertionId, bool assertedTruthfully) public override {
    require(msg.sender == address(optimisticOracle), NotOptimisticOracle(msg.sender));
    // ... on truthful resolution
    recordResults(assertions[assertionId].sessionId, assertionId);
}
function recordResults(uint256 sessionId, bytes32 assertionId) public {
    require(SessionManager(sessionManager).getSessionState(sessionId) == SessionState.Ended, GameNotEnded());
    // ... set per-user results
    winners[sessionId] = assertion.winners;
}

suggested remediation

  • Primary: Remove rigid equality check at claim time. Compute rewards based on winners.length and XP totals actually returned by the session strategy.
  • Or: Validate alignment at conclusion time and allow creator to synchronize the expected numberOfWinners to the actual winners length before any claim.
  • Defense-in-depth: Add a sweepUnclaimed(sessionId) after a grace period to transfer stranded balances to a deterministic address (e.g., treasury or sponsor).

proof of concept

  • Files added:

    • test/fuzz/TieExpansionWinnersMismatchFuzz.t.sol — strategy simulates ties between winners so winners = topN + 1; creator set topN, mismatch causes lock
  • Run:

    • `forge test -vv --match-path "test/fuzz/TieExpansionWinnersMismatchFuzz.t.sol"

RUNNABLE POC

sol file attached due to size limit

Validation steps

Compiling 1 files with Solc 0.8.30
Solc 0.8.30 finished in 4.20s
Compiler run successful!

Ran 1 test for test/fuzz/TieExpansionWinnersMismatchFuzz.t.sol:TieExpansionWinnersMismatchFuzz
[PASS] testFuzz_TiesExpandWinners_MismatchLocks(uint128) (runs: 257, μ: 7673002, ~: 7673002)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 182.99ms (182.25ms CPU time)

Ran 1 test suite in 184.69ms (182.99ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$669
Visibilitypartially
VulnerabilityBlockchain
Participants
hidden