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.fuzz-CTF_LockedReferralOnSponsorFuzz-*.log in the project root.// 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;
}
// src/DepositManager.sol
pool.totalCollectedAmount += ticketPrice;
referralRewards[sessionId][Registry(registry).referrers(player)] += ticketPrice * REFERRER_FEE;
BASIS_POINTS:// src/DepositManager.sol
uint256 referralReward = referralRewards[sessionId][claimedAddress] / BASIS_POINTS;
referralRewards[sessionId][claimedAddress] = 0;
Implications:
ticketPrice = 0, referralRewards remain zero, yet getRewards removes 2% from the entire pool, locking sponsor funds.Observed by fuzz (example counterexample):
29517813771115903562754259035627544 (2 extra wei from prize-split remainder)##fix
Primary logic fix (recommended):
REFERRER_FEE to sponsor funds. Subtract only the actual referral reserve that accrued from entries when computing the winners' prize pool.Concrete approach:
// 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
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;
}
// 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");
}
}