Status DataClose notification

Majority Games Disclosed Report

Rescheduling enables contestants to leave and reduce below winners count, stranding rewards

Created date
Aug 30 2025

Target

hidden

Vulnerability Details

Critical: Rescheduling enables contestants to leave and reduce below winners count, stranding rewards

Summary

After rescheduling, contestants can leave and be refunded, reducing numContestants below the configured number of winners. Reward strategies that require exact winners length (e.g., ProportionalToXPReward) or ranks preallocation can then become unclaimable or misallocated, leading to funds stuck.

Impact

  • Partial lockout of prize funds: a portion remains undistributed due to fixed rank proportions when fewer winners exist than configured ranks.

Affected Code

Contestant can leave after reschedule and get refund while decrementing contestants:

function leaveRescheduledGame(uint256 sessionId)
    external
    onlyState(sessionId, SessionState.Created)
    onlyJoined(sessionId)
{
    Game storage game = games[sessionId];
    require(game.originalStartTime != 0, GameIsNotRescheduled(sessionId));
    require(game.originalStartTime + minimumRescheduleTime > block.timestamp, TooLateToLeave(sessionId));

    contestants[sessionId][msg.sender] = false;
    game.numContestants--;
    _refundEntryFee(sessionId, msg.sender);
    emit RefundRequested(sessionId, msg.sender);
}

Fixed ranks pay by configured slots; any non-existent rank’s share remains undistributed:

function getRewards(uint256 sessionId, address[] calldata winners, uint256 prizePool)
    external
    view
    returns (uint256[] memory rewards)
{
    require(rankedRewards[sessionId].length >= winners.length, GetRewardOutOfRange(sessionId));
    rewards = new uint256[](winners.length);
    for (uint256 i; i < winners.length; ++i) {
        rewards[i] = prizePool * rankedRewards[sessionId][i] / BASIS_POINTS;
    }
}

Note: With fewer winners than configured ranks, the leftover rank percentages have no recipient and remain in the contract.

Scenario

  • Creator sets numberOfWinners = 4 and configures fixed ranks: rankedRewards = [10, 10, 10, 10] (sum = 40, i.e., 40%).
  • Game is rescheduled; 1 contestant leaves via leaveRescheduledGame, leaving only 3 winners.
  • On conclusion with 3 winners, only the first three 10% slices are distributable. The last 10% slice (allocated to a non-existent 4th winner) remains undistributed in the contract, effectively locking 1/4 of the intended distribution portion of the prize pool.

Additional exploit path: Skewed ranks to harm most winners

  • Creator configures skewed fixed ranks that satisfy only the total sum constraint but allocate 0 to early positions and 100% to a far position, e.g., rankedRewards = [0, 0, 0, 0, 10000] while only 3 winners will realistically exist.
  • Constraints in rank setup only enforce creator/Created-state/one-time and total equals BASIS_POINTS:
function setRankedRewards(uint256 sessionId, uint256[] calldata _rankedRewards) external {
    require(sessionManager.getSessionState(sessionId) == SessionState.Created, NotCreated(sessionId));
    require(sessionManager.getCreator(sessionId) == msg.sender, NotCreator(sessionId, msg.sender));
    require(rankedRewards[sessionId].length == 0, AlreadySet(sessionId));
    require(_rankedRewards.length > 0, InvalidRanks(sessionId, _rankedRewards.length));
    require(_rankedRewards.length <= 20, InvalidRanks(sessionId, _rankedRewards.length));
    uint256 totalPoints;
    for (uint256 i; i < _rankedRewards.length; ++i) {
        totalPoints += _rankedRewards[i];
    }
    require(totalPoints == BASIS_POINTS, InvalidTotalPoints(sessionId, totalPoints));
    rankedRewards[sessionId] = _rankedRewards;
}
  • With 3 winners, getRewards iterates only first 3 slots (indices 0..2) and pays 0 to all of them; the 10000 bps allocated to index 4 has no recipient and remains undistributed.
require(rankedRewards[sessionId].length >= winners.length, GetRewardOutOfRange(sessionId));
rewards = new uint256[](winners.length);
for (uint256 i; i < winners.length; ++i) {
    rewards[i] = prizePool * rankedRewards[sessionId][i] / BASIS_POINTS;
}

NOTE

  • Participants who already joined cannot opt out unless the game is rescheduled (see leaveRescheduledGame), so the creator can set these ranks during Created state after players join, trapping them into a zero-payout configuration.
function leaveRescheduledGame(uint256 sessionId) external onlyState(sessionId, SessionState.Created) onlyJoined(sessionId) {
    Game storage game = games[sessionId];
    require(game.originalStartTime != 0, GameIsNotRescheduled(sessionId));
    require(game.originalStartTime + minimumRescheduleTime > block.timestamp, TooLateToLeave(sessionId));
    contestants[sessionId][msg.sender] = false;
    game.numContestants--;
    _refundEntryFee(sessionId, msg.sender);
}

Why Critical (Platform Criteria)

  • High likelihood: relies on normal reschedule + leave behavior, no privileges.
  • Limited conditions: simple mismatch between configured winners and actual participants after reschedule.
  • High impact: Permanent lockout of end-user prize pool funds.

Recommendations

  • Lock numberOfWinners to be <= current numContestants at start, and re-validate before conclusion.
  • Auto-adjust or cap winners to min(configured, numContestants).
  • Provide a safe-cancel path with refunds when winners.length < configured threshold.

Validation steps

Will be made available on demand.

Attachments

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