Status DataClose notification

Majority Games Disclosed Report

Lack of On-Chain Policy Verification Cause Permanent Reward Lock

Created date
Aug 30 2025

Target

hidden

Vulnerability Details

Summary

Contracts finalize oracle results without enforcing on-chain policy (e.g., the exact winners count) before a game is concluded. This allows a truthful oracle result to be recorded, and concludeGame to pass with a simple check for at least one winner. Subsequently, every claimRewards call reverts due to the policy mismatch, permanently locking the prize pool.

This vulnerability holds without any oracle error or missed challenge. If the locked funds meet or exceed 2% of TVL and 1% per-user deposit, this qualifies as a Critical finding under DualDefense criteria.

Vulnerability Details

Description of the Flaw

The protocol fails to validate the number of winners before concluding a game session, creating a mismatch between the recorded state and the reward policy.

  1. Result Recording without Validation: DefaultSession.recordResults is called upon a successful oracle resolution. It writes the winners to storage without checking if the number of winners complies with the on-chain policy for that session.

    // src/session/DefaultSession.sol:163
    function recordResults(uint256 sessionId, bytes32 assertionId) public {
        // ...
        // The only check is a one-time write guard. No policy validation.
        require(winners[sessionId].length == 0, WinnersAlreadyRecorded(sessionId));
        // ...
        winners[sessionId] = assertion.winners; // Winner count is not validated here
    }
    
  2. Incomplete Conclusion Check: SessionManager.concludeGame only verifies that there is at least one winner before changing the game state to Concluded. It does not perform a full validation against the session's specific winner-count policy.

    // src/SessionManager.sol:501
    function concludeGame(uint256 sessionId) external /* ... */ {
        // ...
        address[] memory winners = ISessionStrategy(game.sessionStrategy).getWinners(sessionId);
        require(winners.length > 0, NoWinnersYet(sessionId)); // Check is too permissive
        game.state = SessionState.Concluded; // No additional checks before concluding
    }
    
  3. Delayed Validation Causes Lock: The reward strategy contract (e.g., ProportionalToXPReward) finally enforces the winner count when a user calls claimRewards. By this point, the game is already Concluded. The failed check causes all claims to revert, with no on-chain path to correct the state.

    // src/reward/ProportionalToXPReward.sol:68
    function getReward(...) external view returns (uint256 reward) {
        require(numberOfWinners[sessionId] == winners.length, NumberOfWinnersMismatch(sessionId, winners.length));
        // ...
    }
    

Discussion

This finding is not an oracle or challenger issue; it is a protocol design flaw stemming from a misunderstanding of the oracle's role.

  • The Oracle's Role is to Attest to Truth, Not Enforce Policy: The UMA Optimistic Oracle correctly did its job. An assertion with a "top 2" list is factually truthful, even if the on-chain policy requires three winners. The oracle's purpose is to verify truthfulness, not to be aware of and enforce every dApp's specific local invariants.

  • On-Chain Rules Must Be Enforced On-Chain: A secure protocol cannot delegate the enforcement of its core, on-chain rules to off-chain actors like challengers. Relying on external vigilance is not a reliable security boundary. The protocol's own contracts are the only place where its own rules can and must be definitively enforced before a state transition becomes irreversible.

  • The Point of Failure is Internal: The vulnerability exists because the protocol accepts and finalizes a state (Concluded) that is fundamentally incompatible with its own reward logic. This is an internal contradiction that the contracts have the power to prevent but currently do not.

Severity

Likelihood

High. Once a session is Ended, any eligible asserter can trigger this vulnerability by submitting a truthful but policy-incompatible winners list. The oracle will resolve it as True, and the missing on-chain invariant checks allow recordResults and concludeGame to succeed. The cost and number of steps are minimal, and the scenario relies on normal protocol operation, not an oracle failure.

Impact

All claimRewards calls will revert (e.g., with NumberOfWinnersMismatch), resulting in a permanent lock of the entire prize pool. After the game state is set to Concluded, there is no on-chain repair path, such as cancellation or re-recording results, to fix the state. The funds are irrecoverably stuck.

Recommended Mitigation

Enforce all critical invariants on-chain inside recordResults, before winners are written to storage.

// In DefaultSession.recordResults
uint256 expectedWinners = IRewardStrategy(rewardStrategy).numberOfWinners(sessionId);
require(assertion.winners.length == expectedWinners, "Winner count mismatch");

// Additional checks can be added here for defense-in-depth

winners[sessionId] = assertion.winners;

Validation steps

Attack Scenario

  1. A game is configured with a policy requiring three winners (numberOfWinners = 3).
  2. The game ends. An eligible asserter calls assertResults with only two winners. This is still a factually truthful "top-2" list.
  3. No dispute is filed; the oracle resolves the assertion as True. The callback to recordResults records the two winners.
  4. concludeGame is called and succeeds because its check only requires some winners (winners.length > 0).
  5. Every subsequent claimRewards call reverts with NumberOfWinnersMismatch because winners.length (2) does not equal the policy of 3. The prize pool is permanently unclaimable.

Proof of Concept

The test file test/e2e/WinnersMismatchTest.t.sol provides an end-to-end demonstration of this attack. The test confirms that after a truthful oracle resolution (resolve(true)), recordResults and concludeGame succeed, but all subsequent claimRewards calls revert with NumberOfWinnersMismatch, proving the permanent lock.

// test/e2e/WinnersMismatchTest.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {Solarray} from "solarray/Solarray.sol";
import {OptimisticOracleV3Interface} from "@uma/optimistic-oracle-v3/interfaces/OptimisticOracleV3Interface.sol";
import {MajorityBaseTest} from "./MajorityBaseTest.sol";
import {ISessionStrategy} from "../../src/session/ISessionStrategy.sol";
import {SessionResultAsserter} from "../../src/offchain/uma/SessionResultAsserter.sol";
import {ProportionalToXPReward} from "../../src/reward/ProportionalToXPReward.sol";
import {DefaultSession} from "../../src/session/DefaultSession.sol";
import {SessionManager} from "../../src/SessionManager.sol";
import {TestUSDC} from "../TestUSDC.sol";

/// @notice E2E tests to demonstrate that if winners length recorded differs
///         from configured numberOfWinners, claims revert.
contract WinnersMismatchTest is MajorityBaseTest {
    uint256 internal gameId;
    address internal proportionalReward;

    bytes32[] internal questionHashes;
    address[] internal questionStrategies;
    uint256[] internal questionSalts;

    bytes internal qEncoded;
    bytes32 internal qHash;
    uint256 internal qSalt;

    function setUp() public {
        baseSetup();

        // Single SPBinary question is enough to drive start/end.
        questionHashes = new bytes32[](1);
        questionStrategies = new address[](1);
        questionSalts = new uint256[](1);

        (, qEncoded, qHash, qSalt) = createSPBinaryQuestion("Q1", 5);
        questionHashes[0] = qHash;
        questionStrategies[0] = spBinaryPromptStrategy;
        questionSalts[0] = qSalt;

        // Use ProportionalToXPReward with numberOfWinners = 3
        proportionalReward = address(new ProportionalToXPReward(address(sessionManager)));
        registry.registerRewardStrategy(proportionalReward, true);

        // Create game
        uint32 startTime = uint32(block.timestamp + 1 days);
        uint32 endTime = uint32(startTime + 30); // 30s duration
        vm.startPrank(creator);
        gameId = createGame(
            startTime,
            endTime,
            1e6, // 1 USDC (6 decimals in TestUSDC)
            0,   // creator fee
            questionHashes,
            questionStrategies,
            proportionalReward,
            false
        );
        // Set numberOfWinners = 3 while Created
        ProportionalToXPReward(proportionalReward).setNumberOfWinners(gameId, 3);
        vm.stopPrank();

        // 3 contestants join
        for (uint256 i = 0; i < 3; i++) {
            vm.startPrank(contestants[i]);
            TestUSDC(usdc).approve(address(sessionManager), 1e6);
            sessionManager.joinGame(gameId);
            vm.stopPrank();
        }

        // Move to start and reveal the only question, starting the game
        vm.warp(sessionManager.getStartTime(gameId));
        vm.prank(creator);
        sessionManager.startAndRevealGameQuestion(gameId, 0, qEncoded, qSalt);

        // Move to end and end the game
        vm.warp(sessionManager.getEndTime(gameId));
        vm.prank(creator);
        sessionManager.endGame(gameId);

        // Mock UMA minimal bond and assertTruth to allow assertResults
        vm.mockCall(
            address(optimisticOracle), abi.encodeCall(OptimisticOracleV3Interface.getMinimumBond, (usdc)), abi.encode(0)
        );
        vm.mockCall(
            address(optimisticOracle),
            abi.encodeWithSelector(OptimisticOracleV3Interface.assertTruth.selector),
            abi.encode(bytes32(0))
        );
    }

    function _assertAndConclude(address[] memory winners, uint256[] memory xps, uint256[] memory times)
        internal
        returns (bytes32 assertionId)
    {
        assertionId = ISessionStrategy(sessionStrategy).assertResults(
            gameId,
            "ipfs://dummy",
            winners,
            xps,
            times,
            // solutions for 1 question
            Solarray.bytess(abi.encode(true))
        );
        // OO resolves true -> records results
        vm.prank(optimisticOracle);
        SessionResultAsserter(sessionStrategy).assertionResolvedCallback(assertionId, true);

        // Conclude the game (winners exist but may mismatch the configured count)
        vm.prank(creator);
        sessionManager.concludeGame(gameId);
    }

    function test_ClaimReverts_WhenWinnersTooFew() public {
        // winners length = 2 while numberOfWinners = 3
        address[] memory winners = Solarray.addresses(contestants[0], contestants[1]);
        // Non-zero XPs and dummy times
        uint256[] memory xps = Solarray.uint256s(100, 50);
        uint256[] memory times = Solarray.uint256s(1, 2);

        _assertAndConclude(winners, xps, times);

        // Claim by an actual winner should revert due to NumberOfWinnersMismatch(3 vs 2)
        vm.startPrank(contestants[0]);
        vm.expectRevert(
            abi.encodeWithSelector(ProportionalToXPReward.NumberOfWinnersMismatch.selector, gameId, winners.length)
        );
        sessionManager.claimRewards(gameId, 0);
        vm.stopPrank();
    }

    function test_ClaimReverts_WhenWinnersTooMany() public {
        // winners length = 4 while numberOfWinners = 3 (include a non-joined address as 4th)
        address ghost = makeAddr("ghost");
        address[] memory winners = Solarray.addresses(contestants[0], contestants[1], contestants[2], ghost);
        uint256[] memory xps = Solarray.uint256s(100, 90, 80, 70);
        uint256[] memory times = Solarray.uint256s(1, 2, 3, 4);

        _assertAndConclude(winners, xps, times);

        // Claim by position 0 (a real winner) still reverts due to mismatch (4 vs 3)
        vm.startPrank(contestants[0]);
        vm.expectRevert(
            abi.encodeWithSelector(ProportionalToXPReward.NumberOfWinnersMismatch.selector, gameId, winners.length)
        );
        sessionManager.claimRewards(gameId, 0);
        vm.stopPrank();
    }
}

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$669
Visibilitypartially
VulnerabilityDoS with (Unexpected) revert
Participants
hidden