Multiple joinEvent() Calls Create Duplicate Entries
Description
The normal behavior is that each user should only be able to join the tournament once by selecting a single team to bet on, and should be added to the participants array exactly once.
The issue is that joinEvent() has no check to prevent multiple calls by the same user, allowing them to call it repeatedly which adds their address to usersAddress array multiple times, increments numberOfParticipants for each call, and creates ghost entries that waste gas and corrupt accounting calculations.
function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
if (countryId >= teams.length) {
revert invalidCountry();
}
if (block.timestamp > eventStartDate) {
revert eventStarted();
}
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);
}
Risk
Likelihood:
Will occur whenever users accidentally double-click, retry failed transactions, or intentionally call function multiple times
No technical skill required - simple repeat function call
Likely to happen frequently in production due to UI issues or user error
No cost barrier - users already deposited so calling joinEvent again costs minimal gas
Impact:
numberOfParticipants becomes inflated (incorrect but not directly damaging)
Gas waste when looping through usersAddress array with duplicates
In _getWinnerShares(), same user counted multiple times causing incorrect totalWinnerShares
Incorrect totalWinnerShares leads to wrong withdrawal amounts for all winners
Does not directly steal funds but creates unfair distribution
Proof of Concept
pragma solidity ^0.8.24;
import "./BriVault.sol";
contract MultiJoinExploit {
BriVault public vault;
function demonstrateMultipleJoins() external {
vault.joinEvent(0);
vault.joinEvent(0);
vault.joinEvent(0);
}
function demonstrateWinnerCalculationImpact() external {
}
function demonstrateWithdrawalLoss() external {
}
}
contract MultiUserScenario {
Scenario:
- Alice deposits 1000 USDC, joins Brazil once
- Bob deposits 2000 USDC, joins Brazil 5 times (by accident)
- Carol deposits 1500 USDC, joins Brazil once
Total deposits: 4500 USDC
Total shares: 4500
When Brazil wins:
_getWinnerShares() loop:
- i=0: user=Alice, totalWinnerShares += 1000 = 1000
- i=1: user=Bob, totalWinnerShares += 2000 = 3000
- i=2: user=Bob, totalWinnerShares += 2000 = 5000
- i=3: user=Bob, totalWinnerShares += 2000 = 7000
- i=4: user=Bob, totalWinnerShares += 2000 = 9000
- i=5: user=Bob, totalWinnerShares += 2000 = 11000
- i=6: user=Carol, totalWinnerShares += 1500 = 12500
totalWinnerShares = 12500 (should be 4500!)
Withdrawals:
- Alice: (1000 * 4500) / 12500 = 360 USDC (should get 1000)
- Bob: (2000 * 4500) / 12500 = 720 USDC (should get 2000)
- Carol: (1500 * 4500) / 12500 = 540 USDC (should get 1500)
Total withdrawn: 1620 USDC
Locked in vault: 2880 USDC
Everyone loses money due to Bob's duplicate joins!
*/
}
contract GasWasteDemo {
function calculateGasWaste() external pure returns (uint256) {
return 1_000_000;
}
}
Recommended Mitigation
contract BriVault is ERC4626, Ownable {
+ mapping(address => bool) public hasJoinedEvent;
+ error AlreadyJoined();
function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
+ if (hasJoinedEvent[msg.sender]) {
+ revert AlreadyJoined();
+ }
if (countryId >= teams.length) {
revert invalidCountry();
}
if (block.timestamp > eventStartDate) {
revert eventStarted();
}
userToCountry[msg.sender] = teams[countryId];
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares;
usersAddress.push(msg.sender);
+ hasJoinedEvent[msg.sender] = true;
numberOfParticipants++;
totalParticipantShares += participantShares;
emit joinedEvent(msg.sender, countryId);
}
function cancelParticipation() public {
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
uint256 refundAmount = stakedAsset[msg.sender];
stakedAsset[msg.sender] = 0;
+ hasJoinedEvent[msg.sender] = false;
uint256 shares = balanceOf(msg.sender);
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, refundAmount);
}
}