Root + Impact
Description
-
A user should call joinEvent(countryId) once after depositing, to register their full vault shares (balanceOf(msg.sender)) to a single country. The contract should prevent duplicate entries and ensure totalWinnerShares accurately reflects the sum of shares from users who selected the winning country.
-
The joinEvent function allows unlimited repeated calls by the same user. Each call:
Pushes msg.sender to usersAddress (creating duplicates),
Increments numberOfParticipants and totalParticipantShares,
Updates userSharesToCountry[msg.sender][countryId] (overwriting or accumulating per country).
When _getWinnerShares() loops over usersAddress, duplicate entries cause the same user's shares to be counted multiple times if any of their selected countries win.
function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
userToCountry[msg.sender] = teams[countryId];
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares;
usersAddress.push(msg.sender);
numberOfParticipants++;
totalParticipantShares += participantShares;
emit joinedEvent(msg.sender, countryId);
}
function _getWinnerShares () internal returns (uint256) {
for (uint256 i = 0; i < usersAddress.length; ++i){
address user = usersAddress[i];
totalWinnerShares += userSharesToCountry[user][winnerCountryId];
}
return totalWinnerShares;
}
Risk
Likelihood:
-
Users may accidentally or intentionally call joinEvent multiple times (e.g., UI bug, retry logic, or griefing).
-
Nothing in the contract prevents re-joining. There is no duplicate check.
Impact:
-
If a user joins the winning country multiple times, their shares are counted multiple times in totalWinnerShares, diluting payouts for all other winners.
-
A malicious user can call joinEvent(winningCountry) 1000 times to inflate totalWinnerShares, reducing everyone’s payout to near zero.
Proof of Concept
Two users deposit 1 ETH each and join country 0; user1 then calls joinEvent(0) 9 more times, pushing 10 duplicate entries into usersAddress. When country 0 wins, _getWinnerShares() sums user1’s shares 10×, inflating totalWinnerShares to 11× the real value and enabling payout theft.
function test_joinEvent_multipleCalls_inflatesTotalWinnerShares() public {
vm.prank(owner);
briVault.setCountry(countries);
uint256 depositAmt = 1 ether;
uint256 fee = (depositAmt * 150) / 10_000;
uint256 stake = depositAmt - fee;
vm.startPrank(user1);
mockToken.approve(address(briVault), type(uint256).max);
briVault.deposit(depositAmt, user1);
vm.stopPrank();
vm.startPrank(user2);
mockToken.approve(address(briVault), type(uint256).max);
briVault.deposit(depositAmt, user2);
vm.stopPrank();
vm.prank(user1);
briVault.joinEvent(0);
vm.prank(user2);
briVault.joinEvent(0);
vm.startPrank(user1);
for (uint i = 0; i < 9; i++) {
briVault.joinEvent(0);
}
vm.stopPrank();
vm.warp(eventEndDate + 1);
vm.prank(owner);
briVault.setWinner(0);
uint256 expectedInflated = stake * 11;
assertEq(
briVault.totalWinnerShares(),
expectedInflated,
"totalWinnerShares must be inflated by duplicate joinEvent calls"
);
assertEq(briVault.numberOfParticipants(), 11);
}
Recommended Mitigation
Add a mapping(address => bool) public hasJoined and revert if hasJoined[msg.sender] is true, ensuring each user can call joinEvent only once. This prevents duplicates in usersAddress, keeps totalWinnerShares accurate, and guarantees fair, proportional payouts to winners.
+ mapping(address => bool) public hasJoined;
function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) revert noDeposit();
if (countryId >= teams.length) revert invalidCountry();
+ if (hasJoined[msg.sender]) revert AlreadyJoined();
userToCountry[msg.sender] = teams[countryId];
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares;
- usersAddress.push(msg.sender);
+ usersAddress.push(msg.sender);
numberOfParticipants++;
totalParticipantShares += participantShares;
+ hasJoined[msg.sender] = true;
emit joinedEvent(msg.sender, countryId);
}