BriVault

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

cancelParticipation() leaves ghost participants causing state inconsistencies

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences

## Root Cause
The `cancelParticipation()` function (lines 264-276) burns shares and refunds tokens but fails to clean critical state mappings:
```solidity
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);
IERC20(asset()).safeTransfer(msg.sender, refundAmount);
// @audit Missing: cleanup of userSharesToCountry, userToCountry
// @audit Missing: removal from usersAddress array
// @audit Missing: decrement of numberOfParticipants
}
```
After cancellation:
- `userSharesToCountry[user][countryId]` retains old share value
- `userToCountry[user]` still maps to the team
- User's address remains in `usersAddress[]`
- `numberOfParticipants` is not decremented

Risk

Likelihood:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)

  • Reason 2

Impact:


This creates "ghost participants" that corrupt the tournament state:

Scenario 1: Inflated winner calculations

  • Alice deposits 1000 tokens, joins country 5, then cancels (gets refund)

  • Her address stays in usersAddress[] with userSharesToCountry[alice][5] = 1000

  • When _getWinnerShares() runs, it iterates through usersAddress[] and adds her shares: totalWinnerShares += 1000

  • But Alice has 0 actual shares (they were burned)

  • This inflates totalWinnerShares, reducing payouts for legitimate winners

Scenario 2: Multiple participation

  • Bob deposits, joins country 3, cancels

  • Bob deposits again, joins country 3 again

  • Bob now has TWO entries in usersAddress[] both with potentially different share values

  • Combined with the multiple joinEvent() vulnerability, this amplifies the exploit

Scenario 3: Incorrect participant count

  • numberOfParticipants never decreases

  • This metric becomes unreliable for any logic depending on it

Proof of Concept

## Proof of Concept
```solidity
function testGhostParticipant() public {
// Alice deposits and joins
vm.startPrank(alice);
token.approve(address(vault), 1000e18);
uint256 shares = vault.deposit(1000e18, alice);
vault.joinEvent(5); // Country ID 5
vm.stopPrank();
// Verify initial state
assertEq(vault.balanceOf(alice), shares);
assertEq(vault.userSharesToCountry(alice, 5), shares);
assertEq(vault.numberOfParticipants(), 1);
// Alice cancels - gets refund, shares burned
vm.prank(alice);
vault.cancelParticipation();
// Alice has no shares anymore
assertEq(vault.balanceOf(alice), 0);
// BUT ghost data remains:
assertEq(vault.userSharesToCountry(alice, 5), shares); // Still holds old value!
assertEq(vault.numberOfParticipants(), 1); // Not decremented!
assertEq(vault.userToCountry(alice), "Country5"); // Still mapped!
// Fast forward and set winner
vm.warp(vault.eventEndDate() + 1);
vm.prank(owner);
vault.setWinner(5);
// totalWinnerShares includes Alice's ghost shares
uint256 totalWinner = vault.totalWinnerShares();
assertEq(totalWinner, shares); // Counts Alice's burned shares!
}
function testMultipleEntriesAfterCancel() public {
// Bob deposits, joins, cancels
vm.startPrank(bob);
token.approve(address(vault), 2000e18);
vault.deposit(1000e18, bob);
vault.joinEvent(3);
vault.cancelParticipation();
// Bob deposits again
vault.deposit(1000e18, bob);
vault.joinEvent(3); // Joins same country again
vm.stopPrank();
// Bob now appears twice in usersAddress[]
assertEq(vault.numberOfParticipants(), 2); // Should be 1
}
```
**Expected vs Actual:**
- Expected: After cancel, all user state is cleaned
- Actual: Ghost entries remain, corrupting calculations

Recommended Mitigation

- remove this code
+ add this code
## Recommended Mitigation
Track participation explicitly and clean all state on cancellation:
```solidity
// Add new state variables
mapping(address => bool) public hasJoinedEvent;
mapping(address => uint256) public userCountryId;
// Modify joinEvent to track participation
function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
require(!hasJoinedEvent[msg.sender], "Already joined");
hasJoinedEvent[msg.sender] = true;
userCountryId[msg.sender] = countryId;
// ... rest of existing code
}
// Fix cancelParticipation to clean state
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 participation state
if (hasJoinedEvent[msg.sender]) {
uint256 countryId = userCountryId[msg.sender];
// Clear mappings
delete userSharesToCountry[msg.sender][countryId];
delete userToCountry[msg.sender];
delete userCountryId[msg.sender];
hasJoinedEvent[msg.sender] = false;
// Decrement counter
numberOfParticipants--;
}
IERC20(asset()).safeTransfer(msg.sender, refundAmount);
}
```
**Alternative approach - Use actual balance instead of stale state:**
```solidity
function _getWinnerShares() internal returns (uint256) {
for (uint256 i = 0; i < usersAddress.length; i++) {
address user = usersAddress[i];
// Check if user actually has shares
uint256 userShares = balanceOf(user);
if (userShares > 0 &&
keccak256(abi.encodePacked(userToCountry[user])) ==
keccak256(abi.encodePacked(winner))) {
totalWinnerShares += userShares;
}
}
return totalWinnerShares;
}
```
This approach uses actual share balances instead of relying on potentially stale `userSharesToCountry` mapping.
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!