A 2% portion of sponsor-provided funds is intended for referral fees. However, the current implementation only calculates and allocates referral fees from participant entry fees (_payEntryFee), not from sponsor contributions (sponsorGame). As a result, 2% of all sponsor funds are never allocated to any referrer and are not accounted for in the prize pool calculation. These funds become permanently locked within the contract as "dead funds" after a session concludes, with no mechanism for withdrawal or sweeping.
The root cause is in getRewards, which preemptively subtracts REFERRER_FEE (2%) from the totalCollectedAmount (which includes sponsor funds) when calculating the prize pool.
// src/DepositManager.sol:121
function getRewards(uint256 sessionId) public view returns (uint256) {
GamePool storage pool = gamePools[sessionId];
// This calculation assumes 2% of the TOTAL pool is for referrals.
return pool.totalCollectedAmount * (BASIS_POINTS - (pool.creatorFee + pool.protocolFee + REFERRER_FEE))
/ BASIS_POINTS;
}
However, only entry fees actually contribute to the referralRewards mapping via _payEntryFee, while sponsorGame does not. This discrepancy creates a permanent surplus of funds equal to 2% of all sponsored amounts, which can never be claimed.
The difference is clear when comparing the two functions:
_payEntryFee credits the referral reward:
// src/DepositManager.sol:207
function _payEntryFee(...) internal {
// ...
pool.totalCollectedAmount += ticketPrice;
referralRewards[sessionId][Registry(registry).referrers(player)] += ticketPrice * REFERRER_FEE; // <-- Reward is added
// ...
}
sponsorGame does not:
// src/DepositManager.sol:132
function sponsorGame(...) external {
// ...
pool.totalCollectedAmount += amount;
// <-- No corresponding logic to add to referralRewards
// ...
}
The most direct solution is to align the sponsorGame function's logic with _payEntryFee by ensuring that a portion of sponsored funds is also allocated to referral rewards.
Modify sponsorGame to credit 2% of the sponsored amount to the referralRewards mapping. This would require identifying a designated referrer for sponsor funds (e.g., the game creator or a protocol-level address).
Example Implementation:
// In sponsorGame()
function sponsorGame(uint256 sessionId, uint256 amount) external {
// ...
pool.totalCollectedAmount += amount;
// Add this logic:
address sponsorReferrer = address(this); // Or another designated address
referralRewards[sessionId][sponsorReferrer] += amount * REFERRER_FEE;
sponsorAmounts[msg.sender][sessionId] += amount;
// ...
}
Alternatively, if sponsored funds are not intended to generate referral fees, then the getRewards function should be modified to only subtract referral fees from the portion of totalCollectedAmount that comes from entry fees, not the entire pool. However, the first approach is recommended for consistency.
The following test case demonstrates the dead funds vulnerability. After all prize claims, fee distributions, and referral claims are processed, the remaining balance in the SessionManager contract equals the 2% referral fee portion of the initial sponsor amount, which cannot be claimed.
// test/e2e/SponsorDeadFunds.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 {FixedRanksReward} from "../../src/reward/FixedRanksReward.sol";
import {TestUSDC} from "../TestUSDC.sol";
/// @notice PoC: 2% sponsor portion is never accrued as referral and remains stuck (dead funds)
contract SponsorDeadFundsTest is MajorityBaseTest {
uint256 internal gameId;
uint256 internal sponsorAmount;
bytes32[] internal questionHashes;
address[] internal questionStrategies;
uint256[] internal questionSalts;
bytes internal qEncoded;
bytes32 internal qHash;
uint256 internal qSalt;
function setUp() public {
baseSetup();
// All contestants share the same referrer so we can claim all entry-fee referrals.
for (uint256 i = 0; i < 3; i++) {
registry.setReferrer(contestants[i], referrers[0]);
}
// One SPBinary question is enough.
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;
// Create game with creator/protocol fee = 0 to isolate referral math.
uint32 startTime = uint32(block.timestamp + 1 days);
uint32 endTime = uint32(startTime + 30);
vm.startPrank(creator);
gameId = createGame(
startTime,
endTime,
10 ether, // ticket price (18 decimals test token)
0, // creator fee bps (0)
questionHashes,
questionStrategies,
rewardStrategy, // FixedRanksReward from MajorityBaseTest
false
);
// 100% to position 0
FixedRanksReward(rewardStrategy).setRankedRewards(gameId, Solarray.uint256s(10_000));
vm.stopPrank();
// Sponsor deposits
sponsorAmount = 100 ether; // 100 tokens
vm.startPrank(sponsor);
TestUSDC(usdc).approve(address(sessionManager), sponsorAmount);
sessionManager.sponsorGame(gameId, sponsorAmount);
vm.stopPrank();
// 3 contestants join (meets minimumContestants)
for (uint256 i = 0; i < 3; i++) {
vm.startPrank(contestants[i]);
TestUSDC(usdc).approve(address(sessionManager), 10 ether);
sessionManager.joinGame(gameId);
vm.stopPrank();
}
}
function test_SponsorDeadFunds_LeftoverSponsorReferral() public {
// Start and reveal the only question
vm.warp(sessionManager.getStartTime(gameId));
vm.prank(creator);
sessionManager.startAndRevealGameQuestion(gameId, 0, qEncoded, qSalt);
// End the game
vm.warp(sessionManager.getEndTime(gameId));
vm.prank(creator);
sessionManager.endGame(gameId);
// Mock UMA bond/assertTruth to assert results
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))
);
// One winner (contestants[0])
bytes32 assertionId = ISessionStrategy(sessionStrategy).assertResults(
gameId,
"ipfs://all-results",
Solarray.addresses(contestants[0]),
Solarray.uint256s(100),
Solarray.uint256s(1),
Solarray.bytess(abi.encode(true))
);
vm.prank(optimisticOracle);
SessionResultAsserter(sessionStrategy).assertionResolvedCallback(assertionId, true);
vm.prank(creator);
sessionManager.concludeGame(gameId);
// Winner claims 100% of prize pool (net of referral bps on total collected).
vm.prank(contestants[0]);
sessionManager.claimRewards(gameId, 0);
// No creator/protocol fees to distribute (set to 0), but call for completeness.
vm.prank(creator);
sessionManager.distributeFees(gameId);
// Referrer claims all entry-fee referral rewards.
vm.prank(referrers[0]);
sessionManager.claimReferralRewards(Solarray.uint256s(gameId));
// After all claims, only the sponsor portion of 2% remains in the contract (dead funds).
uint256 expectedLeftover = sponsorAmount * sessionManager.REFERRER_FEE() / sessionManager.BASIS_POINTS();
uint256 leftover = TestUSDC(usdc).balanceOf(address(sessionManager));
assertEq(leftover, expectedLeftover, "sponsor referral share remains unclaimed (dead funds)");
}
}