Vulnerability details
This vulnerability causes a permanent lock of 2% of all sponsor contributions in any concluded session with sponsors. It occurs due to a mismatch in referrer fee calculation: getRewards deducts referrer fees from the entire pool (entries + sponsors), but referrer rewards only accrue on entries. The unaccrued 2% of sponsor amounts remains undistributed and unclaimable, violating the invariant that outflows equal inflows and leaving funds stuck in the contract after all distributions (fees, referrer claims, rewards). Sponsors, as permissionless end-users depositing funds to boost the prize pool, suffer direct loss (>1% of their deposit), while winners indirectly lose (reduced rewards). If sponsors comprise a significant portion of TVL (e.g., >50%), the lock exceeds 2% of protocol TVL per session.
Description of the vulnerability
The DepositManager contract manages session pools, including entry fees and sponsor contributions, via totalCollectedAmount. Referrer fees are intended to be 2% (REFERRER_FEE = 200 basis points) of entry fees, accrued in _payEntryFee and claimed via _claimReferralReward. However, getRewards (used for winner distributions in claimRewards) deducts referrer fees from the full totalCollectedAmount (entries + sponsors), assuming accrual on everything. Sponsors (via sponsorGame) add to the pool without referrer accrual, creating a gap: 2% of sponsor amounts is "deducted" from rewards but never accrued or claimed, leaving it permanently locked after outflows.
This breaks the core invariant: totalCollectedAmount == creatorFees + protocolFees + referrerFees + winnerRewards + refunds. Actual outflows are less than declared, as referrer fees are only E * 2% (E = entries), not T * 2% (T = entries + sponsors). No sweep/withdrawal function exists for leftovers, making the lock irreversible.
Sponsors qualify as end-users: sponsorGame is permissionless (external), pulls funds from msg.sender like entries, and emits GameSponsored implying full addition to the pool. They expect 100% allocation to prizes (minus declared fees), but the bug shaves off 2% hiddenly. This misleads sponsors and reduces winner rewards, causing unintended loss.
Affected Code Snippets:
getRewards (deducts on total): https://github.com/hackenproof-public/engage-protocol/blob/8476672096d591459dff56517956668f1f94b133/src/DepositManager.sol#L121-L125
_payEntryFee (accrues only on entries): https://github.com/hackenproof-public/engage-protocol/blob/8476672096d591459dff56517956668f1f94b133/src/DepositManager.sol#L205-L209
pool.totalCollectedAmount += ticketPrice;
referralRewards[sessionId][Registry(registry).referrers(player)] += ticketPrice * REFERRER_FEE;
}
sponsorGame (adds without referrer accrual): https://github.com/hackenproof-public/engage-protocol/blob/8476672096d591459dff56517956668f1f94b133/src/DepositManager.sol#L138-L139function sponsorGame(uint256 sessionId, uint256 amount) external onlyStates(sessionId, SessionState.Created, SessionState.Ongoing) {
...
pool.totalCollectedAmount += amount;
sponsorAmounts[msg.sender][sessionId] += amount;
// No referrer accrual here
}
Mathematical Proof:
Let E = Total Entry Fees, S = Total Sponsor Contributions, T = E + S.
Declared Referrer Fees = T * REFERRER_FEE / BASIS_POINTS.
Accrued Referrer Fees = E * REFERRER_FEE / BASIS_POINTS.
Locked = Declared - Accrued = (T - E) * REFERRER_FEE / BASIS_POINTS = S * 2%.
Lack of Intent: No function withdraws leftovers (e.g., to treasury). Violation of invariants and misleading sponsors (expect full pool addition) indicate an error, not a feature. Impact on winners: Reduced rewards due to over-deduction.
Proof of Concept (Foundry Test):
Add this to the DepositManager.t.sol test file;
function test_ReferrerFeeAccountingMismatch_LocksSponsorFunds() public {
_createGame();
uint256 sponsorAmount = 1000 ether;
uint256 entryFeePerPlayer = 10 ether;
uint256 numPlayers = 5;
console.log("=== Initial State ===");
console.log("Sponsor amount:", sponsorAmount / 1e18, "USDC");
console.log("Entry fee per player:", entryFeePerPlayer / 1e18, "USDC");
console.log("Number of players:", numPlayers);
// Sponsor the game
vm.startPrank(sponsor);
TestUSDC(usdc).approve(address(sessionManager), sponsorAmount);
sessionManager.sponsorGame(1, sponsorAmount);
vm.stopPrank();
// Add players who pay entry fees
address[] memory players = new address[](numPlayers);
for (uint256 i = 0; i < numPlayers; i++) {
players[i] = makeAddr(string(abi.encodePacked("player-", i)));
TestUSDC(usdc).mint(players[i], entryFeePerPlayer);
vm.startPrank(players[i]);
TestUSDC(usdc).approve(address(sessionManager), entryFeePerPlayer);
sessionManager.joinGame(1);
vm.stopPrank();
}
// Get the total collected amount from the pool
(,,,, uint256 totalCollectedAmount,,) = sessionManager.gamePools(1);
uint256 expectedTotalCollected = sponsorAmount + (numPlayers * entryFeePerPlayer);
console.log("Total collected amount:", totalCollectedAmount / 1e18, "USDC");
console.log("Expected total collected:", expectedTotalCollected / 1e18, "USDC");
assertEq(totalCollectedAmount, expectedTotalCollected);
// Calculate the rewards using getRewards
uint256 rewards = sessionManager.getRewards(1);
console.log("Calculated rewards (after fees):", rewards / 1e18, "USDC");
// Calculate the locked amount: 2% of sponsor contributions
uint256 referrerFee = 200; // 2% in basis points
uint256 lockedAmount = (sponsorAmount * referrerFee) / 10000;
console.log("Locked amount (2% of sponsor contributions):", lockedAmount / 1e18, "USDC");
// Verify the rewards calculation
uint256 creatorFee = 10; // 0.1% from game creation
uint256 protocolFee = 500; // 5% from default
uint256 totalFees = creatorFee + protocolFee + referrerFee;
uint256 expectedRewards = totalCollectedAmount * (10000 - totalFees) / 10000;
console.log("Expected rewards (total * (10000 - fees) / 10000):", expectedRewards / 1e18, "USDC");
assertEq(rewards, expectedRewards);
// Calculate what rewards should be if referrer fee was only applied to entries
uint256 totalEntryFees = numPlayers * entryFeePerPlayer;
uint256 entryFeesAfterReferrer = totalEntryFees * (10000 - referrerFee) / 10000;
uint256 rewardsWithoutReferrerFeeOnSponsors = entryFeesAfterReferrer + sponsorAmount;
rewardsWithoutReferrerFeeOnSponsors = rewardsWithoutReferrerFeeOnSponsors * (10000 - creatorFee - protocolFee) / 10000;
console.log("Rewards if referrer fee only on entries:", rewardsWithoutReferrerFeeOnSponsors / 1e18, "USDC");
console.log("=== Exploitation Result ===");
console.log("Effective loss due to locked funds:", (rewardsWithoutReferrerFeeOnSponsors - rewards) / 1e18, "USDC");
console.log("This amount is permanently locked in the contract.");
// The actual locked amount should be approximately 2% of sponsor contributions
// (there might be small differences due to fee calculations)
uint256 actualLocked = rewardsWithoutReferrerFeeOnSponsors - rewards;
console.log("Actual locked amount:", actualLocked / 1e18, "USDC");
// The locked amount should be close to 2% of sponsor contributions
// Allow for small rounding differences
assertApproxEqAbs(actualLocked, lockedAmount, 1e18, "Locked amount should be approximately 2% of sponsor contributions");
}
Run: forge test --match-test test_ReferrerFeeAccountingMismatch_LocksSponsorFunds -vvvv
Output: here is the output Confirms 2% of sponsors;
forge test --match-test test_ReferrerFeeAccountingMismatch_LocksSponsorFunds -vvvv
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/DepositManager.t.sol:DepositManagerTest
[PASS] test_ReferrerFeeAccountingMismatch_LocksSponsorFunds() (gas: 1723079)
Logs:
=== Initial State ===
Sponsor amount: 1000 USDC
Entry fee per player: 10 USDC
Number of players: 5
Total collected amount: 1050 USDC
Expected total collected: 1050 USDC
Calculated rewards (after fees): 975 USDC
Locked amount (2% of sponsor contributions): 20 USDC
Expected rewards (total * (10000 - fees) / 10000): 975 USDC
Rewards if referrer fee only on entries: 995 USDC
=== Exploitation Result ===
Effective loss due to locked funds: 20 USDC
This amount is permanently locked in the contract.
Actual locked amount: 20 USDC
Traces:
[1946779] DepositManagerTest::test_ReferrerFeeAccountingMismatch_LocksSponsorFunds()
├─ [3557] SessionManager::maxGameDuration() [staticcall]
│ └─ ← [Return] 600