Description
When the eventEndDate has passed, the owner is expected to call setWinner, which calculates the total number of shares for the winning team.
However, _getWinnerShares, which is called within setWinner, iterates over the entire userAddresses array to compute totalWinnerShares. This loop is unbounded, meaning that gas usage grows linearly with the number of users. As the array size increases, the cost of executing setWinner also increases.
If userAddresses becomes too large, the gas cost may exceed the block gas limit, causing the transaction to revert and preventing the owner from finalizing the event.
function _getWinnerShares () internal returns (uint256) {
@> for (uint256 i = 0; i < usersAddress.length; ++i){
address user = usersAddress[i];
totalWinnerShares += userSharesToCountry[user][winnerCountryId];
}
return totalWinnerShares;
}
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:
Every call to joinEvent appends the user’s address to userAddresses, even if the same user calls joinEvent multiple times. As a result, the array can grow very large with a sufficient amount of users.
Impact:
The owner may be unable to execute setWinner due to excessive gas costs, effectively freezing all vault assets and preventing users from claiming rewards. This results in a complete denial of service for the protocol’s core functionality.
Proof of Concept
Add this test to your test suite in test/briVault.t.sol.
function testSetWinnerDOSWhenThereAreManyUsers() public {
vm.prank(owner);
briVault.setCountry(countries);
uint256 depositAmount = 0.005 ether;
vm.startPrank(user1);
mockToken.approve(address(briVault), depositAmount);
briVault.deposit(depositAmount, user1);
for (uint256 i = 0; i < 30_000; i++) { briVault.joinEvent(i % countries.length); }
vm.stopPrank();
vm.warp(eventEndDate + 1);
bytes memory data = abi.encodeWithSelector(BriVault.setWinner.selector, 0);
vm.prank(owner);
uint256 g0 = gasleft();
(bool ok, ) = address(briVault).call{gas: 30_000_000}(data);
uint256 used = g0 - gasleft();
assert(!ok);
console.log("setWinner gas used for 30k entries in userAddresses:", used);
}
This test shows that a malicious user can inflate userAddresses to 30,000 entries. At this size, setWinner consumes over 30 million gas, which is around Ethereum’s block gas limit, causing it to revert or become very expensive for the owner.
Recommended Mitigation
Use a mapping to track total team shares instead of iterating through an array of user addresses.
- mapping(address => mapping(uint256 => uint256)) public userSharesToCountry;
+ mapping(uint256 => uint256) public countryIdToShares;
function joinEvent(uint256 countryId) public {
if (stakedAsset[msg.sender] == 0) {
revert noDeposit();
}
// Ensure countryId is a valid index in the `teams` array
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;
+ countryIdToShares[countryId] += participantShares;
- usersAddress.push(msg.sender);
- numberOfParticipants++;
- totalParticipantShares += participantShares;
emit joinedEvent(msg.sender, countryId);
}
- function _getWinnerShares () internal returns (uint256) {
+ function _getWinnerShares (uint256 countryIndex) internal returns (uint256) {
- for (uint256 i = 0; i < usersAddress.length; ++i){
- address user = usersAddress[i];
- totalWinnerShares += userSharesToCountry[user][winnerCountryId];
- }
+ totalWinnerShares = countryIdToShares[countryIndex];
return totalWinnerShares;
}
This change removes the need for an unbounded loop and ensures that the gas cost of setWinner remains constant, regardless of the number of participants.
Additional changes would need to be made to prevent multiple calls to join a team by the same user and account for multiple deposits.