BriVault

First Flight #52
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: medium
Valid

`cancelParticipation()` doesn't clean up joinEvent data, diluting prize pool

Description

The `cancelParticipation()` function allows users to cancel their participation and receive a refund of their staked assets. The function correctly clears the `stakedAsset` mapping and burns the user's shares. However, it does not clean up the data structures used by `joinEvent()`, specifically the `usersAddress` array and the `userSharesToCountry` mapping.

When users cancel their participation, they remain in the `usersAddress` array and their `userSharesToCountry` entries are not cleared. When the winner is determined, `_getWinnerShares()` iterates over all entries in `usersAddress` and counts shares for each user who bet on the winning country. Since cancelled users are still in the array, their shares (which no longer exist) are counted in `totalWinnerShares`, diluting the prize pool for winners.

function cancelParticipation () public {
if (block.timestamp >= eventStartDate){
revert eventStarted();
}
uint256 refundAmount = stakedAsset[msg.sender];
stakedAsset[msg.sender] = 0; // @> Clears stakedAsset
uint256 shares = balanceOf(msg.sender);
_burn(msg.sender, shares); // @> Burns shares
// @> VULN: Does NOT remove from usersAddress array
// @> VULN: Does NOT clear userSharesToCountry mapping
// @> VULN: Does NOT clear userToCountry mapping
IERC20(asset()).safeTransfer(msg.sender, refundAmount);
}

Risk

**Likelihood**:
* Users can cancel their participation before the event starts
* Cancelled users remain in the `usersAddress` array
* Winner calculation iterates over all entries including cancelled users
**Impact**:
* Prize pool dilution for legitimate winners
* Cancelled users' non-existent shares are counted in `totalWinnerShares`
* Legitimate winners receive less than their fair share
* Incorrect accounting of winner shares

Proof of Concept

In the following PoC test, user2 receives less than they should for winning due to user1's cancellation not having cleaned up the usersAddress array and the userSharesToCountry mapping.

function testCancelParticipationDoesntCleanup() public {
vm.prank(owner);
briVault.setCountry(countries);
vm.startPrank(user1);
mockToken.approve(address(briVault), 10 ether);
// User deposits and joins event
uint256 depositAmount = 5 ether;
uint256 shares = briVault.deposit(depositAmount, user1);
briVault.joinEvent(10);
// User cancels participation
briVault.cancelParticipation();
// Verify shares are burned
assertEq(briVault.balanceOf(user1), 0, "Shares should be burned");
assertEq(briVault.stakedAsset(user1), 0, "Staked asset should be cleared");
vm.stopPrank();
// VULNERABILITY: User is still in usersAddress array and userSharesToCountry mapping
// We can't directly check the array, but we can verify through winner calculation
// Add a legitimate user who will win
vm.startPrank(user2);
mockToken.approve(address(briVault), 10 ether);
uint256 user2Shares = briVault.deposit(5 ether, user2);
briVault.joinEvent(10);
vm.stopPrank();
// Set winner - cancelled user's shares should NOT be counted, but they are
vm.warp(eventEndDate + 1);
vm.startPrank(owner);
briVault.setWinner(10);
uint256 totalWinnerShares = briVault.totalWinnerShares();
console2.log("Total winner shares (includes cancelled user):", totalWinnerShares);
// Expected: only user2's shares (cancelled user's shares shouldn't count)
// Actual: includes cancelled user's shares
uint256 expectedWinnerShares = user2Shares;
uint256 actualWinnerShares = totalWinnerShares;
console2.log("Expected winner shares (only user2):", expectedWinnerShares);
console2.log("Actual winner shares (includes cancelled user1):", actualWinnerShares);
// Verify vulnerability: cancelled user's shares are counted
assertEq(actualWinnerShares, shares + user2Shares);
assertNotEq(actualWinnerShares, expectedWinnerShares);
vm.stopPrank();
// Verify prize pool dilution: user2 receives less than they should
uint256 balanceBefore = mockToken.balanceOf(user2);
vm.prank(user2);
briVault.withdraw();
uint256 balanceAfter = mockToken.balanceOf(user2);
uint256 received = balanceAfter - balanceBefore;
// Calculate expected amount if cancelled user's shares weren't counted
uint256 vaultAssets = briVault.finalizedVaultAsset();
uint256 expectedAmount = (user2Shares * vaultAssets) / expectedWinnerShares;
console2.log("User2 received:", received);
console2.log("User2 expected should have received:", expectedAmount);
assertLt(received, expectedAmount);
}

Recommended Mitigation

Clean up when `cancelParticipation()` is called.

+ mapping(address => uint256) public userIndexInArray; // Track user's index in usersAddress
function joinEvent(uint256 countryId) public {
// ... existing code ...
usersAddress.push(msg.sender);
+ userIndexInArray[msg.sender] = usersAddress.length - 1;
// ... rest of function ...
}
function cancelParticipation () public {
if (block.timestamp >= eventStartDate){
revert eventStarted();
}
uint256 refundAmount = stakedAsset[msg.sender];
stakedAsset[msg.sender] = 0;
uint256 shares = balanceOf(msg.sender);
_burn(msg.sender, shares);
+ // Clean up joinEvent data
+ uint256 userIndex = userIndexInArray[msg.sender];
+ if (userIndex < usersAddress.length) {
+ // Move last element to user's position and remove last
+ address lastUser = usersAddress[usersAddress.length - 1];
+ usersAddress[userIndex] = lastUser;
+ userIndexInArray[lastUser] = userIndex;
+ usersAddress.pop();
+ }
+
+ // Clear mappings
+ delete userSharesToCountry[msg.sender];
+ delete userToCountry[msg.sender];
+ delete userIndexInArray[msg.sender];
IERC20(asset()).safeTransfer(msg.sender, refundAmount);
}
Updates

Appeal created

bube Lead Judge 19 days ago
Submission Judgement Published
Validated
Assigned finding tags:

`cancelParticipation` Leaves Stale Winner Data

CancelParticipation burns shares but leaves the address inside usersAddress and keeps userSharesToCountry populated.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!