Description
The owner is expected to call setCountry immediately after the contract has been deployed to set up the teams that users can join.
However, if the owner does not set the countries, users can still join the event using any country ID from 0 to 47. If users join the event and the countries are never set, then all the selected teams of users will be empty strings (""). This allows any user to withdraw assets from the vault after the event ends, since all teams, including the winning team, would be represented by the empty string.
In another scenario, if a user joins the event before the owner calls setCountry, that user will not be able to win because their recorded country will be an empty string. They would need to call joinEvent again after the countries are set to have a valid chance of winning.
string[48] public teams;
function withdraw() external winnerSet {
if (block.timestamp < eventEndDate) {
revert eventNotEnded();
}
@> if (
keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
keccak256(abi.encodePacked(winner))
) {
revert didNotWin();
}
uint256 shares = balanceOf(msg.sender);
uint256 vaultAsset = finalizedVaultAsset;
uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
emit Withdraw(msg.sender, assetToWithdraw);
}
Risk
Likelihood:
This will occur only if the owner fails to call setCountry immediately after deployment. It would generally be good practice to include this step in a deployment script.
Impact:
Scenario 1: If the countries are never set, any user can withdraw vault assets after the event since every country name is an empty string. This would allow losing users to withdraw funds and could deplete the vault.
Scenario 2: If the countries are set after users have already joined, those early users will be unable to win because their recorded country remains an empty string. They would need to rejoin before the event starts to have a valid team.
Scenario 3: If the owner sets the countries after the event has already started, no users will correspond to these countries, meaning no one will be able to withdraw vault assets even if they selected the correct team index.
Proof of Concept
Add this test of the first scenario to the test suite in test/briVault.t.sol.
function testAnyoneCanWithdrawIfOwnerForgetsToSetCountries() public {
uint256 depositAmount = 5e18;
uint256 base = 10000;
uint256 depositFee = (depositAmount * participationFeeBsp) / base;
uint256 user1StartingAmount = mockToken.balanceOf(user1);
assert(keccak256(abi.encodePacked(briVault.teams(0))) == keccak256(abi.encodePacked("")));
vm.expectRevert(BriVault.invalidCountry.selector);
briVault.getCountry(0);
vm.startPrank(user1);
mockToken.approve(address(briVault), depositAmount);
briVault.deposit(depositAmount, user1);
briVault.joinEvent(0);
vm.stopPrank();
vm.startPrank(user2);
mockToken.approve(address(briVault), depositAmount);
briVault.deposit(depositAmount, user2);
briVault.joinEvent(1);
vm.stopPrank();
vm.warp(eventEndDate + 1);
vm.prank(owner);
briVault.setWinner(1);
vm.prank(user1);
briVault.withdraw();
vm.expectRevert();
vm.prank(user2);
briVault.withdraw();
assert(mockToken.balanceOf(user1) == user1StartingAmount - depositFee + 2 * (depositAmount - depositFee));
}
This test shows that a user on a losing team (country ID 0) can still withdraw after the event ends since both teams are represented by the empty string. This also prevents the user who selected the winning index (user2) from withdrawing their rightful winnings.
Next, add the test of the second scenario.
function testJoinEventBeforeSetCountriesWillMakeItImpossibleToWin() public {
uint256 depositAmount = 5e18;
vm.startPrank(user1);
mockToken.approve(address(briVault), depositAmount);
briVault.deposit(depositAmount, user1);
briVault.joinEvent(0);
vm.stopPrank();
vm.prank(owner);
briVault.setCountry(countries);
vm.startPrank(user2);
mockToken.approve(address(briVault), depositAmount);
briVault.deposit(depositAmount, user2);
briVault.joinEvent(0);
vm.stopPrank();
vm.warp(eventEndDate + 1);
vm.prank(owner);
briVault.setWinner(0);
vm.expectRevert(BriVault.didNotWin.selector);
vm.prank(user1);
briVault.withdraw();
}
This test shows that a user who joined before the countries were set cannot withdraw their winnings, even if they selected the correct team index.
Recommended Mitigation
This potential issue can be mitigated in multiple ways:
-
Including setCountry in the deploy script to ensure countries are set alongside contract deployment.
-
Set a flag to mark when the countries are set. When this flag has not been marked, user facing functions like deposit and joinEvent should be inaccessible.
-
Set the countries array in the constructor. This will mitigate the risk by setting up the array during deployment. This is the best option.
- constructor (IERC20 _asset, uint256 _participationFeeBsp, uint256 _eventStartDate, address _participationFeeAddress, uint256 _minimumAmount, uint256 _eventEndDate) ERC4626 (_asset) ERC20("BriTechLabs", "BTT") Ownable(msg.sender) {
+ constructor (IERC20 _asset, uint256 _participationFeeBsp, uint256 _eventStartDate, address _participationFeeAddress, uint256 _minimumAmount, uint256 _eventEndDate, string[48] memory _countries) ERC4626 (_asset) ERC20 ("BriTechLabs", "BTT") Ownable(msg.sender) {
if (_participationFeeBsp > PARTICIPATIONFEEBSPMAX){
revert limiteExceede();
}
participationFeeBsp = _participationFeeBsp;
eventStartDate = _eventStartDate;
eventEndDate = _eventEndDate;
participationFeeAddress = _participationFeeAddress;
minimumAmount = _minimumAmount;
_setWinner = false;
+ setCountry(_countries)
}
- function setCountry(string[48] memory countries) public onlyOwner {
+ function setCountry(string[48] memory countries) internal {
for (uint256 i = 0; i < countries.length; ++i) {
teams[i] = countries[i];
}
emit CountriesSet(countries);
}