Missing profer join-once validation check, allows users to call BriVault::joinEvent() multiple times.
Description
-
From the project's documentation, a user can join the event only once.
-
Due to the lack of proper “join-once” validation check, a user can join the event multiple times before eventStartDate.
-
Relevant vulnerable code section:
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:
Impact:
-
The same user may be counted multiple times in the usersAddress array, inflating numberOfParticipants and totalParticipantShares.
-
Reward distribution logic will fail since share accounting no longer represents true participation.
-
The event’s competitive integrity and outcome fairness are compromised.
Proof of Concept
Add the following to briVault.t.sol and run forge test --mt testNocheckToEnsureUsersShouldOnlyJoinOnce
function testNocheckToEnsureUsersShouldOnlyJoinOnce() public {
vm.prank(owner);
briVault.setCountry(countries);
vm.startPrank(user1);
mockToken.approve(address(briVault), 5 ether);
briVault.deposit(5 ether, user1);
briVault.joinEvent(10);
briVault.joinEvent(20);
briVault.joinEvent(30);
briVault.joinEvent(40);
vm.stopPrank();
string memory user1Country = briVault.userToCountry(user1);
string memory expectedCountry = countries[40];
assert(keccak256(bytes(user1Country)) == keccak256(bytes(expectedCountry)));
}
Observed behavior:
-
user1 is able to call joinEvent() multiple times with different countryIds.
-
Only the last selected country (countries[40]) remains stored in userToCountry[user1].
-
No revert occurs, and the same deposit covers all join attempts.
Expected behavior
Recommended Mitigation
Introduce a boolean flag to enforce single participation per event:
+ error AlreadyJoined();
+ mapping(address => bool) public hasJoined;
function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) revert noDeposit();
if (countryId >= teams.length) revert invalidCountry();
if (block.timestamp > eventStartDate) revert eventStarted();
+ if (hasJoined[msg.sender]) revert AlreadyJoined(); // ✅ Prevents rejoining
+ hasJoined[msg.sender] = true;
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 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);
IERC20(asset()).safeTransfer(msg.sender, refundAmount);
+ hasJoined[msg.sender] = false;
}