Status DataClose notification

Majority Games Disclosed Report

Locked 2% sponsor funds due to referral fee misapplication and prize-split rounding dust

Created date

Target

hidden

Vulnerability Details

summary

  • When ticketPrice = 0, the protocol still applies the global 2% REFERRER_FEE to the entire pool (including sponsor funds), but no referral balances accrue. That 2% remains stuck in SessionManager with no withdrawal path.
  • Fuzzing further shows the post-payout leftover is generally ≥ 2% because equal winner splits introduce integer division dust (remainder), causing the leftover to sometimes exceed the exact 2% haircut.
  • Repro logs saved under: fuzz-CTF_LockedReferralOnSponsorFuzz-*.log in the project root.

root cause from code

  • Prize pool subtracts the 2% referral fee from the entire collected amount (including sponsorships), irrespective of whether referral rewards were actually accrued from entries:
// src/DepositManager.sol
function getRewards(uint256 sessionId) public view returns (uint256) {
    GamePool storage pool = gamePools[sessionId];
    return pool.totalCollectedAmount * (BASIS_POINTS - (pool.creatorFee + pool.protocolFee + REFERRER_FEE))
        / BASIS_POINTS;
}
  • Referral balances accrue only from entry fees (not from sponsorships), tracked in basis points units:
// src/DepositManager.sol
pool.totalCollectedAmount += ticketPrice;
referralRewards[sessionId][Registry(registry).referrers(player)] += ticketPrice * REFERRER_FEE;
  • Claim divides the stored (bps) amount once by BASIS_POINTS:
// src/DepositManager.sol
uint256 referralReward = referralRewards[sessionId][claimedAddress] / BASIS_POINTS;
referralRewards[sessionId][claimedAddress] = 0;

Implications:

  • With ticketPrice = 0, referralRewards remain zero, yet getRewards removes 2% from the entire pool, locking sponsor funds.
  • After winners claim equal shares, integer division remainders cause dust, so the final leftover often becomes 2% + remainder.

Observed by fuzz (example counterexample):

  • Sponsor amount: 2951781377111
  • Expected 2% leftover: 59035627542
  • Actual leftover: 59035627544 (2 extra wei from prize-split remainder)

##fix

Primary logic fix (recommended):

  • Do not apply REFERRER_FEE to sponsor funds. Subtract only the actual referral reserve that accrued from entries when computing the winners' prize pool.

Concrete approach:

  • Track a per-session total of referral accruals in basis-points units (to remain consistent with current per-referrer accounting):
// New state
mapping(uint256 => uint256) public referralRewardsTotalBps; // sum of ticketPrice * REFERRER_FEE

// In _payEntryFee
referralRewardsTotalBps[sessionId] += ticketPrice * REFERRER_FEE;

// In _refundEntryFee
referralRewardsTotalBps[sessionId] -= ticketPrice * REFERRER_FEE;

// In _claimReferralReward
uint256 bps = referralRewards[sessionId][claimedAddress];
referralRewards[sessionId][claimedAddress] = 0;
referralRewardsTotalBps[sessionId] -= bps; // keep totals consistent
  • Update prize pool calculation to subtract only the reserved referral amount (from entries), not a blanket 200 bps on the entire pool:
function getRewards(uint256 sessionId) public view returns (uint256) {
    GamePool storage pool = gamePools[sessionId];
    uint256 total = pool.totalCollectedAmount;
    uint256 creator = (total * pool.creatorFee) / BASIS_POINTS;
    uint256 protocol = (total * pool.protocolFee) / BASIS_POINTS;
    uint256 referralReserve = referralRewardsTotalBps[sessionId] / BASIS_POINTS;
    return total - creator - protocol - referralReserve;
}

Validation steps

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {Test} from "forge-std/Test.sol";
import {SessionManager} from "../src/SessionManager.sol";
import {Registry} from "../src/Registry.sol";
import {TestUSDC} from "./TestUSDC.sol";
import {MockSessionStrategy, MockRewardStrategy, MockPromptStrategy} from "./MockStrategy.sol";

contract CTF_LockedReferralOnSponsor is Test {
    SessionManager private sessionManager;
    Registry private registry;
    TestUSDC private usdc;

    address private protocolTreasury = makeAddr("ProtocolTreasury");
    address private sponsor = makeAddr("Sponsor");

    // Winners as returned by MockSessionStrategy
    address private winner1 = address(0x1);
    address private winner2 = address(0x2);
    address private winner3 = address(0x3);

    function setUp() public {
        usdc = new TestUSDC();
        registry = new Registry(protocolTreasury);
        registry.registerPaymentToken(address(usdc), true);

        sessionManager = new SessionManager(address(registry));

        // Register mock strategies compatible with SessionManager
        address sessionStrategy = address(new MockSessionStrategy(address(sessionManager)));
        address rewardStrategy = address(new MockRewardStrategy(address(sessionManager)));
        address promptStrategy = address(new MockPromptStrategy(address(sessionManager)));
        registry.registerSessionStrategy(sessionStrategy, true);
        registry.registerRewardStrategy(rewardStrategy, true);
        registry.registerPromptStrategy(promptStrategy, true);

        // Create a game with zero ticket price to ensure no referral rewards accrue from entries
        uint32 startTime = uint32(block.timestamp + 1 days);
        uint32 endTime = uint32(startTime + sessionManager.maxGameDuration() - 1 seconds);
        bytes memory prompt = bytes("Q1");
        uint256 salt = 1;
        bytes32 promptHash = keccak256(abi.encodePacked(prompt, salt));
        bytes32[] memory promptHashes = new bytes32[](1);
        address[] memory promptStrategies = new address[](1);
        promptHashes[0] = promptHash;
        promptStrategies[0] = promptStrategy;

        sessionManager.createGame({
            _startTime: startTime,
            _endTime: endTime,
            _ticketPrice: 0, // critical: zero ticket price means no referral rewards accrue
            _creatorFee: 0,
            _token: address(usdc),
            _creatorFeeReceiver: address(0),
            _promptHashes: promptHashes,
            _promptStrategies: promptStrategies,
            _sessionStrategy: sessionStrategy,
            _rewardStrategy: rewardStrategy,
            _verificationRequired: false
        });

        // Have winners join so they can claim later
        vm.startPrank(winner1);
        sessionManager.joinGame(1);
        vm.stopPrank();
        vm.startPrank(winner2);
        sessionManager.joinGame(1);
        vm.stopPrank();
        vm.startPrank(winner3);
        sessionManager.joinGame(1);
        vm.stopPrank();

        // Sponsor the prize pool (this is the only source of funds)
        uint256 sponsorAmount = 1200 ether; // chosen so that 93% prize pool divides evenly among 3 winners
        usdc.mint(sponsor, sponsorAmount);
        vm.startPrank(sponsor);
        usdc.approve(address(sessionManager), sponsorAmount);
        sessionManager.sponsorGame(1, sponsorAmount);
        vm.stopPrank();

        // Start and reveal first question within grace period
        vm.warp(startTime + 1);
        uint256[] memory qids = sessionManager.getQuestionsForGame(1);
        sessionManager.startAndRevealGameQuestion(1, qids[0], prompt, salt);

        // End and conclude the game
        vm.warp(endTime + 1);
        sessionManager.endGame(1);
        sessionManager.concludeGame(1);

        // Distribute fees (protocol fee is 5%)
        sessionManager.distributeFees(1);

        // Winners claim rewards based on prizePool = total * (10000 - (creatorFee + protocolFee + referrerFee)) / 10000
        // Here: creatorFee=0, protocolFee=500, referrerFee=200 => prizePool = total * 9300 / 10000
        vm.startPrank(winner1);
        sessionManager.claimRewards(1, 0);
        vm.stopPrank();
        vm.startPrank(winner2);
        sessionManager.claimRewards(1, 1);
        vm.stopPrank();
        vm.startPrank(winner3);
        sessionManager.claimRewards(1, 2);
        vm.stopPrank();
    }

    function test_SponsorReferralFee_RemainsLockedAfterAllPayouts() public {
        // After paying protocol fees (5%) and all winner rewards (93%),
        // the remaining 2% (the global REFERRER_FEE) is left in the contract since no entry fees => no referral balances exist.
        // With sponsor-only funds, that 2% is permanently locked (no withdrawal function, and no referral claims possible).

        uint256 contractBalance = usdc.balanceOf(address(sessionManager));
        uint256 expectedLocked = (1200 ether * 200) / 10000; // 2% of sponsor funds

        assertEq(contractBalance, expectedLocked, "Locked funds should equal 2% of sponsor funds");

        // Even attempting to claim referral rewards yields zero (no referrers were set; ticket price was 0)
        uint256 pre = usdc.balanceOf(address(this));
        uint256[] memory sessionIds = new uint256[](1);
        sessionIds[0] = 1;
        sessionManager.claimReferralRewards(sessionIds); // claims for msg.sender (no CLAIMER_ROLE) => zero payout
        uint256 post = usdc.balanceOf(address(this));
        assertEq(post - pre, 0, "No referral rewards should be claimable");

        // Balance still stuck in the contract
        assertEq(usdc.balanceOf(address(sessionManager)), expectedLocked, "Funds remain permanently locked");
    }

  
}

Attachments

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