BriVault

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

Shares miscalculation on cancel

Shares miscalculation on cancel

Description

  • Normal behavior: Cancelling participation before the event starts should fully revert the participant’s state (address removed from participant lists, country selection cleared, recorded shares cleared, and counters/totals updated) so future winner calculations exclude the cancelled user.

  • Issue: cancelParticipation() burns the user’s current shares and refunds their staked assets, but it does not clean up the participant accounting set during joinEvent. As a result, the cancelled user remains counted in usersAddress, numberOfParticipants, totalParticipantShares, and userSharesToCountry, causing inflated totalWinnerShares and underpayment to legitimate winners.

function joinEvent(uint256 countryId) public {
...
// @> userAddresses gets updated
usersAddress.push(msg.sender);
// @> numberOfParticipants gets updated
numberOfParticipants++;
// @> totalParticipantShares gets updated
totalParticipantShares += participantShares;
...
}
function cancelParticipation () public {
uint256 refundAmount = stakedAsset[msg.sender];
// @> only stakedAsset gets updated
stakedAsset[msg.sender] = 0;
uint256 shares = balanceOf(msg.sender);
_burn(msg.sender, shares);
}
// @> winner shares are computed from usersAddress and userSharesToCountry
function _getWinnerShares () internal returns (uint256) {
totalWinnerShares = 0;
for (uint256 i = 0; i < usersAddress.length; ++i){
address user = usersAddress[i];
totalWinnerShares += userSharesToCountry[user][winnerCountryId];
}
return totalWinnerShares;
}

Risk

Likelihood: High

  • Happens whenever a joined user cancels before start; the stale state persists into winner selection.

  • Common user action, no safeguards to clean state make this likely in practice.

Impact: High

  • Inflated totalWinnerShares reduces per-winner payouts; winners get underpaid.

  • Funds remain stuck in the vault due to incorrect calculations; prize distribution breaks.

Proof of Concept

Description:

  • user1 deposits and joins a country

  • user1 cancels participation before start.

  • Contract still counts user1 in all participant metrics and userSharesToCountry.

  • user2 enters and joins a country

  • After event ends and winner is set, user2 (legitimate winner) withdraws less than expected; tokens remain in the vault.

  • user2 should have all winner shares and get the full prize pool

  • user2 actually only has a portion of the total win shares counted, and only gets back a portion

  • the rest of the funds remain locked in the vault forever

function testSharesMiscalculationOnCancel() public {
vm.prank(owner);
briVault.setCountry(countries);
// User 1 deposits and joins
vm.startPrank(user1);
mockToken.approve(address(briVault), 1 ether);
briVault.deposit(1 ether, user1);
briVault.joinEvent(7);
vm.stopPrank();
// User 1 cancels
vm.startPrank(user1);
briVault.cancelParticipation();
vm.stopPrank();
// State is still set, resulting in incorrect values for the payout for winners
vm.assertEq(briVault.numberOfParticipants(), 1);
vm.assertEq(briVault.totalParticipantShares(), 985000000000000000);
vm.assertEq(briVault.usersAddress(0), user1);
vm.assertEq(briVault.userSharesToCountry(user1, 7), 985000000000000000);
// User 2 enters and wins the event
vm.startPrank(user2);
mockToken.approve(address(briVault), 1 ether);
briVault.deposit(1 ether, user2);
briVault.joinEvent(7);
vm.stopPrank();
vm.warp(eventEndDate + 1);
vm.startPrank(owner);
briVault.setWinner(7);
vm.stopPrank();
// Then user 2 withdraws but gets incorrect payout, as he should receive all his funds
vm.startPrank(user2);
console.log("total winner shares", briVault.totalWinnerShares());
// 1 970000000 000000000
console.log("total user2 shares", briVault.balanceOf(user2));
// 985000000 000000000
briVault.withdraw();
vm.stopPrank();
// Assertions
console.log("balance user 2", mockToken.balanceOf(user2));
// 19 492500000 000000000 (should be 20 ether minus the fee)
console.log("tokens left in the vault", mockToken.balanceOf(address(briVault)));
// 492500000 000000000 (should be 0)
}

Recommended Mitigation

  • Clean up all participant accounting on cancel if the user had joined:

    • Clear the user’s recorded winning shares for their selected country.

    • Decrement numberOfParticipants, subtract from totalParticipantShares, remove the address from usersAddress.

    • Clear userToCountry and a new userCountryId used to track the selection efficiently.

  • Reset totalWinnerShares to 0 at the start of _getWinnerShares() before summing (defensive).

  • Add userCountryId and userIndexInArray mappings to support efficient cleanup.

+ mapping(address => uint256) public userCountryId;
+ mapping(address => uint256) private userIndexInArray;
function joinEvent(uint256 countryId) public {
...
// track chosen country and index for O(1) removal
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares;
+ userCountryId[msg.sender] = countryId;
+ userIndexInArray[msg.sender] = usersAddress.length - 1;
usersAddress.push(msg.sender);
numberOfParticipants++;
totalParticipantShares += participantShares;
...
}
function cancelParticipation () public {
if (block.timestamp >= eventStartDate){
revert eventStarted();
}
uint256 refundAmount = stakedAsset[msg.sender];
stakedAsset[msg.sender] = 0;
+ // remove accounting if user had joined
+ uint256 countryId = userCountryId[msg.sender];
+ if (bytes(userToCountry[msg.sender]).length != 0) {
+ uint256 recordedShares = userSharesToCountry[msg.sender][countryId];
+ if (recordedShares != 0) {
+ totalParticipantShares -= recordedShares;
+ userSharesToCountry[msg.sender][countryId] = 0;
+ }
+ numberOfParticipants--;
+ // swap & pop from usersAddress
+ uint256 idx = userIndexInArray[msg.sender];
+ uint256 lastIdx = usersAddress.length - 1;
+ if (idx != lastIdx) {
+ address lastUser = usersAddress[lastIdx];
+ usersAddress[idx] = lastUser;
+ userIndexInArray[lastUser] = idx;
+ }
+ usersAddress.pop();
+ delete userIndexInArray[msg.sender];
+ delete userToCountry[msg.sender];
+ delete userCountryId[msg.sender];
+ }
uint256 shares = balanceOf(msg.sender);
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, refundAmount);
}
function _getWinnerShares () internal returns (uint256) {
+ totalWinnerShares = 0;
for (uint256 i = 0; i < usersAddress.length; ++i){
address user = usersAddress[i];
totalWinnerShares += userSharesToCountry[user][winnerCountryId];
}
return totalWinnerShares;
}

Optionally, prevent duplicate joins per address (this would simplify cleanup and accounting a bit):

function joinEvent(uint256 countryId) public {
+ require(bytes(userToCountry[msg.sender]).length == 0, "already joined");
...
}
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!