The protocol's prize pool calculation in DepositManager.sol incorrectly applies a referral fee to all collected funds, including direct sponsorships, while only generating referral rewards from player entry fees. This mismatch causes a portion of every sponsorship to be permanently locked in the contract.
The core of the issue lies in three functions:
sponsorGame(sessionId, amount): This function increases pool.totalCollectedAmount by the sponsored amount._payEntryFee(sessionId, player): This is the only function that increases a referrer's balance in the referralRewards mapping, and it does so based on the ticketPrice.getRewards(sessionId): This function calculates the prize pool for winners by subtracting the creatorFee, protocolFee, and REFERRER_FEE from the entire totalCollectedAmount.The formula used is:
prizePool = totalCollectedAmount * (BASIS_POINTS - (creatorFee + protocolFee + REFERRER_FEE)) / BASIS_POINTS
This creates a deficit. The REFERRER_FEE (2%) is subtracted from sponsored funds when calculating the winner's prize pool, but since sponsorGame does not credit any address in the referralRewards mapping, this reserved amount has no recipient and can never be claimed.
The primary impact is a permanent and unavoidable loss of funds for game winners and an irreversible accumulation of value locked in the contract. For every dollar sponsored, two cents are deducted from the winners' prize pool and permanently trapped. This undermines the economic integrity and fairness of the protocol.
This flaw does not require a malicious actor to exploit; it is triggered by the standard, intended use of the sponsorship feature.
Attack Scenario:
referralRewards are correctly accrued based on these entry fees.totalCollectedAmount is now 600 USDC.getRewards is called to determine the prize pool. It subtracts fees from the full 600 USDC. This includes subtracting a 2% referral fee from the 500 USDC sponsorship (10 USDC).SessionManager contract. It was taken from the winners' share but was never allocated to any referrer, making it impossible to withdraw.To fix this vulnerability, the prize pool calculation in getRewards must be modified to apply the REFERRER_FEE only to the portion of funds generated from player entry fees. Sponsored funds should not be subject to a referral fee.
A robust solution is to calculate each fee component's absolute value separately instead of applying a combined fee percentage.
Suggested Remediation in src/DepositManager.sol:
function getRewards(uint256 sessionId) public view returns (uint256) {
GamePool storage pool = gamePools[sessionId];
Game storage game = games[sessionId]; // The contract has access to the 'games' mapping
// Calculate creator and protocol fees based on the total collected amount
uint256 creatorFeeAmount = pool.totalCollectedAmount * pool.creatorFee / BASIS_POINTS;
uint256 protocolFeeAmount = pool.totalCollectedAmount * pool.protocolFee / BASIS_POINTS;
// Calculate referral fees based *only* on the sum of entry fees
// This requires accessing the number of contestants for the session.
uint256 totalEntryFees = game.numContestants * pool.ticketPrice;
uint256 referralFeeAmount = totalEntryFees * REFERRER_FEE / BASIS_POINTS;
// The final prize pool is the total collected minus each calculated fee
return pool.totalCollectedAmount - creatorFeeAmount - protocolFeeAmount - referralFeeAmount;
}
This revised implementation correctly separates the accounting. Creator and protocol fees apply to the entire pool as intended, while the referral fee is now correctly scoped to entry fees, ensuring that no funds from sponsorships are diverted and locked.
I attached here a simple PoC to prove the issue, you can attach this in DepositManager.t.sol. Basically the 2% will be stuck in the contract permanently
function test_sponsorReferralSlice_StuckSimple() public {
// Deploy and register mock strategies
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 simple game: ticket = 10 USDC, 1 question with known (prompt, salt)
uint32 start = uint32(block.timestamp + uint32(sessionManager.minimumStartDelay()) + 100);
uint32 end = uint32(start + uint32(sessionManager.maxGameDuration()) - 1);
bytes memory question = bytes("Q");
uint256 salt = 777;
bytes32[] memory promptHashes = new bytes32[](1);
address[] memory promptStrategies = new address[](1);
promptHashes[0] = keccak256(abi.encodePacked(question, salt));
promptStrategies[0] = promptStrategy;
uint256 gid = sessionManager.createGame({
_startTime: start,
_endTime: end,
_ticketPrice: 10 ether,
_creatorFee: 10,
_token: usdc,
_creatorFeeReceiver: address(0),
_promptHashes: promptHashes,
_promptStrategies: promptStrategies,
_sessionStrategy: sessionStrategy,
_rewardStrategy: rewardStrategy,
_verificationRequired: false
});
// Winners in MockSessionStrategy are 0x1, 0x2, 0x3. Use them as contestants.
address w1 = address(uint160(0x1));
address w2 = address(uint160(0x2));
address w3 = address(uint160(0x3));
// Fund and join each winner
address[3] memory winners = [w1, w2, w3];
for (uint256 i = 0; i < winners.length; i++) {
TestUSDC(usdc).mint(winners[i], 1000 ether);
vm.startPrank(winners[i]);
TestUSDC(usdc).approve(address(sessionManager), type(uint256).max);
sessionManager.joinGame(gid);
vm.stopPrank();
}
// Sponsor 500 USDC
uint256 sponsorAmount = 500 ether;
TestUSDC(usdc).mint(sponsor, sponsorAmount);
vm.startPrank(sponsor);
TestUSDC(usdc).approve(address(sessionManager), sponsorAmount);
sessionManager.sponsorGame(gid, sponsorAmount);
vm.stopPrank();
// Start and reveal first (only) question
vm.warp(start);
uint256[] memory qIds = sessionManager.getQuestionsForGame(gid);
sessionManager.startAndRevealGameQuestion(gid, qIds[0], question, salt);
// End and conclude
vm.warp(end);
sessionManager.endGame(gid);
sessionManager.concludeGame(gid);
// Winners claim (equal split in MockRewardStrategy)
vm.startPrank(w1); sessionManager.claimRewards(gid, 0); vm.stopPrank();
vm.startPrank(w2); sessionManager.claimRewards(gid, 1); vm.stopPrank();
vm.startPrank(w3); sessionManager.claimRewards(gid, 2); vm.stopPrank();
// Fees distributed (creator = msg.sender of createGame, protocol = registry.treasury)
sessionManager.distributeFees(gid);
// Referral rewards from entries: none set → credited to address(0), claimable by CLAIMER_ROLE (this contract)
uint256[] memory sids = new uint256[](1); sids[0] = gid;
sessionManager.claimReferralRewards(sids);
// After draining prize + fees + entry referrals, contract should still hold ~2% of sponsor
uint256 leftover = TestUSDC(usdc).balanceOf(address(sessionManager));
uint256 sponsorReferral = sponsorAmount * sessionManager.REFERRER_FEE() / sessionManager.BASIS_POINTS();
// Prove the sponsor slice remains (allowing small dust from integer division)
assertGe(leftover, sponsorReferral, "sponsor referral slice remains stuck");
assertLe(leftover, sponsorReferral + 3, "only negligible rounding above sponsor slice");
}