BriVault

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

Duplicate Join Entries Make Cancel Revert, Stranding Funds

Root + Impact

Description

joinEvent writes the caller’s current share balance to userSharesToCountry[msg.sender][countryId] and pushes the address onto usersAddress every time it is invoked (src/briVault.sol:260-266). If a user calls joinEvent twice before the tournament starts, the second snapshot can be zero (for example after transferring or burning shares). Later, cancelParticipation relies on balanceOf(msg.sender) when calling _burn (src/briVault.sol:280-288). With a stale snapshot the balance may be lower than the recorded stake, causing _burn to revert (ERC20: burn amount exceeds balance) and leaving the user unable to cancel or recover funds.

function joinEvent(uint256 countryId) public {
...
@> uint256 participantShares = balanceOf(msg.sender);
@> userSharesToCountry[msg.sender][countryId] = participantShares;
@> usersAddress.push(msg.sender);
}
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); // reverts if shares < refund snapshot
IERC20(asset()).safeTransfer(msg.sender, refundAmount);
}

Risk

Likelihood: Low – requires a duplicate join before the event starts, typically due to user impatience or UI glitches.

Impact:

  • Victims cannot cancel participation despite the event not starting, trapping funds in the vault.

  • A malicious actor could grief others by scripting duplicate joins for them via front-running (if UI auto-joins).

Proof of Concept

  1. User deposits the minimum stake.

  2. User calls joinEvent(team) twice.

  3. User attempts to call cancelParticipation().

  4. Transaction reverts with ERC20: burn amount exceeds balance; funds are stuck.
    (Reproduced in test/BriVaultAttack.t.sol:293 via testCancelAfterDuplicateJoinCausesRevert.)

function testCancelAfterDuplicateJoinCausesRevert() public {
briVault.deposit(FIRST_DEPOSIT, winner);
briVault.joinEvent(0);
briVault.joinEvent(0); // duplicate join
vm.expectRevert("ERC20: burn amount exceeds balance");
briVault.cancelParticipation();
}

Recommended Mitigation

  1. Reject duplicate joinEvent calls per address (require(userToCountry[msg.sender].length == 0, "already joined")) or overwrite rather than append.

  2. Ensure withdrawal/cancel routines reconcile against actual live share balances (e.g., recompute snapshots or prevent zero-balance joins).

  3. Add tests covering multi-join scenarios to confirm cancellation stays functional.

Proposed patch (Solidity-like pseudocode):

function joinEvent(uint256 countryId) public {
- userSharesToCountry[msg.sender][countryId] = participantShares;
- usersAddress.push(msg.sender);
+ require(!_hasJoined[msg.sender], "already joined");
+ userSharesToCountry[msg.sender][countryId] = participantShares;
+ usersAddress.push(msg.sender);
+ _hasJoined[msg.sender] = true;
}
function cancelParticipation() public {
...
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, refundAmount);
+ _hasJoined[msg.sender] = false;
}
Updates

Appeal created

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

Duplicate registration through `joinEvent`

Support

FAQs

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

Give us feedback!