BriVault

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

Ghost Participant Due to Incomplete State Cleanup After `briVault::cancelParticipation()`

Root + Impact

Missing Share Balance Check Allows Users to Stay Registered Without Stake Burn

Description

  • Normal behavior: Users can deposit assets and join an event, where their stake is tracked via stakedAsset, their shares are minted, and their participation is recorded in usersAddress and related mappings. When a user calls cancelParticipation(), their staked assets should be refunded, their shares burned, and they should be fully removed from the event’s participant tracking.

  • Issue: The cancelParticipation() function only refunds assets and burns shares but does not remove the user from participant arrays or mappings (usersAddress, userToCountry, userSharesToCountry, totalParticipantShares). This allows users to remain registered as active participants even after canceling, causing their shares to incorrectly influence winner calculations and event logic.

// Root cause in the codebase with @> marks to highlight the relevant section
unction cancelParticipation() public {
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
uint256 refundAmount = stakedAsset[msg.sender];
@> // update total shares and participant tracking
stakedAsset[msg.sender] = 0;
uint256 shares = balanceOf(msg.sender);
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, refundAmount);
}

Risk

Likelihood:

  • Every time a user deposits and joins an event, then calls cancelParticipation() before the event starts, the participant remains in the usersAddress array and related mappings.

  • Any event with multiple participants has a high chance of accumulating “ghost participants” if multiple users cancel, because the function does not clean up logical participation state.

Impact:

  • Users who have canceled can still affect winner calculations, potentially skewing rewards or outcomes unfairly.

  • The event’s totalParticipantShares becomes inaccurate, undermining integrity of share-based logic and any dependent financial distributions.

Proof of Concept

  1. User deposits tokens and joins an event successfully (joinEvent() emits joinedEvent).

  2. The user then cancels their participation (cancelParticipation()), triggering refund of their deposit.

  3. After cancelation:

  • stakedAsset(user) returns 0 (as expected).

  • totalParticipantShares remains unchanged (unexpected).

  • usersAddress still contains the user address.

Below is PoC test that confirms ghost participants after they cancel participation:

function test_cancelParticipation_leaves_ghost_participant() public {
// user1 deposits and joins event
vm.startPrank(user1);
mockToken.approve(address(briVault), 5 ether);
briVault.deposit(5 ether, user1);
briVault.joinEvent(20);
vm.stopPrank();
// user2 deposits and joins event
vm.startPrank(user2);
mockToken.approve(address(briVault), 3 ether);
briVault.deposit(3 ether, user2);
briVault.joinEvent(30);
vm.stopPrank();
// record total shares BEFORE cancel
uint256 beforeTotalShares = briVault.totalParticipantShares();
assertGt(beforeTotalShares, 0);
console.log("Total participant shares before cancel:", beforeTotalShares);
// ensure user1 is present in usersAddress
bool presentInitially = false;
for (uint256 i = 0; i < 10; ++i) {
try briVault.usersAddress(i) returns (address a) {
if (a == address(0)) break;
if (a == user1) {
presentInitially = true;
break;
}
} catch {
break;
}
}
assertTrue(presentInitially, "user1 should be in usersAddress after deposit");
// user1 cancels participation
vm.startPrank(user1);
briVault.cancelParticipation();
vm.stopPrank();
// check stakedAsset is zeroed
assertEq(briVault.stakedAsset(user1), 0 ether, "stakedAsset should be zero after cancel");
// check totalParticipantShares decremented
uint256 afterTotalShares = briVault.totalParticipantShares();
console.log("Total participant shares after cancel:", afterTotalShares);
assertEq(afterTotalShares, beforeTotalShares, "totalParticipantShares should have been decremented but was not");
// check user1 still appears in usersAddress (ghost participant)
bool foundAfterCancel = false;
for (uint256 i = 0; i < 10; ++i) {
try briVault.usersAddress(i) returns (address a) {
if (a == user1) {
foundAfterCancel = true;
break;
}
} catch {
break;
}
}
assertTrue(foundAfterCancel, "user1 still present in usersAddress after cancel -> ghost participant");
}

Recommended Mitigation

  • Remove user from participant list upon cancelation:
    When cancelParticipation() executes, the user’s address should be removed from the usersAddress array or marked as inactive in a mapping such as isParticipant[user] = false.

  • Adjust total shares accurately:
    Decrease totalParticipantShares by the user’s current shares before zeroing them.

function cancelParticipation() public {
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
uint256 refundAmount = stakedAsset[msg.sender];
uint256 shares = balanceOf(msg.sender);
// Update total shares and participant tracking
+ totalParticipantShares -= shares;
+ isParticipant[msg.sender] = false;
stakedAsset[msg.sender] = 0;
_burn(msg.sender, shares);
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!