[H-3] The briVault::withdraw() Division by Zero Causes Permanent Fund Lock When No Users Predict Winning Country
Description
The `briVault::withdraw()` function calculates winner payouts using `briVault::Math.mulDiv()`
When no users have predicted the winning country `winnerCountryId`, `totalWinnerShares` remains `0`, causing `Math.mulDiv()` to perform division by zero. This makes all withdrawal attempts revert, permanently locking all deposited funds in the contract.
```solidity
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);
}
```solidity
function _getWinnerShares() internal returns (uint256) {
for (uint256 i = 0; i < usersAddress.length; ++i){
address user = usersAddress[i];
totalWinnerShares += userSharesToCountry[user][winnerCountryId];
}
return totalWinnerShares;
}
```
Risk
Likelihood:
The issue can easily occur in normal operation if no users predict the winning country. It doesn’t require malicious action, just a specific outcome, but once triggered, it permanently locks funds — making it a realistic and impactful risk.
Impact:
**Complete and permanent loss of all user funds** with no recovery mechanism:
Guaranteed scenario: With 48 participating countries, it's statistically likely that an underdog wins with zero correct predictions
No admin function: No rescue mechanism exists to recover locked funds
Affects all users: Even users who participated legitimately cannot retrieve their deposits
No workaround: Division by zero is a fatal error that cannot be bypassed
Proof of Concept
Add this test to your test file:
<details>
<summary>Proof Of Code</summary>
```solidity
```solidity
function test_DivisionByZero_FundsLockedForever() public {
vm.prank(owner);
briVault.setCountry(countries);
vm.warp(1 days);
vm.startPrank(user1);
mockToken.mint(user1, 1000e18);
mockToken.approve(address(briVault), 1000e18);
briVault.deposit(1000e18, user1);
briVault.joinEvent(5);
vm.stopPrank();
console.log("User1 deposited 1000 tokens, predicted Country 5");
vm.startPrank(user2);
mockToken.mint(user2, 500e18);
mockToken.approve(address(briVault), 500e18);
briVault.deposit(500e18, user2);
briVault.joinEvent(10);
vm.stopPrank();
console.log("User2 deposited 500 tokens, predicted Country 10");
vm.startPrank(user3);
mockToken.mint(user3, 800e18);
mockToken.approve(address(briVault), 800e18);
briVault.deposit(800e18, user3);
briVault.joinEvent(15);
vm.stopPrank();
console.log("User3 deposited 800 tokens, predicted Country 15");
uint256 totalDeposited = mockToken.balanceOf(address(briVault));
console.log("\nTotal tokens in vault:", totalDeposited / 1e18);
vm.warp(briVault.eventEndDate() + 1 days);
vm.prank(owner);
briVault.setWinner(0);
console.log("Winner: Country 0 (United States)");
console.log("Problem: NOBODY predicted Country 0!");
uint256 totalShares = briVault.totalWinnerShares();
console.log("\nTotal winner shares:", totalShares);
console.log("*** CRITICAL: totalWinnerShares = 0 ***");
vm.prank(user1);
vm.expectRevert();
briVault.withdraw();
console.log("User1 withdrawal: FAILED (division by zero)");
vm.prank(user2);
vm.expectRevert();
briVault.withdraw();
console.log("User2 withdrawal: FAILED (division by zero)");
vm.prank(user3);
vm.expectRevert();
briVault.withdraw();
console.log("User3 withdrawal: FAILED (division by zero)");
console.log("Funds locked in vault:", mockToken.balanceOf(address(briVault)) / 1e18, "tokens");
console.log("User1 balance:", mockToken.balanceOf(user1) / 1e18);
console.log("User2 balance:", mockToken.balanceOf(user2) / 1e18);
console.log("User3 balance:", mockToken.balanceOf(user3) / 1e18);
console.log("\n*** ALL FUNDS PERMANENTLY LOCKED ***");
console.log("*** NO RECOVERY MECHANISM EXISTS ***");
assertEq(totalShares, 0, "No winner shares exist");
assertGt(mockToken.balanceOf(address(briVault)), 0, "Funds locked in vault");
}
```
**Test Output:**
```
SCENARIO: Users Predict Wrong Countries
User1 deposited 1000 tokens, predicted Country 5
User2 deposited 500 tokens, predicted Country 10
User3 deposited 800 tokens, predicted Country 15
Total tokens in vault: 2265
Tournament Ends
Winner: Country 0 (United States)
Problem: NOBODY predicted Country 0!
Total winner shares: 0
*** CRITICAL: totalWinnerShares = 0 ***
Attempting Withdrawals
User1 withdrawal: FAILED (division by zero)
User2 withdrawal: FAILED (division by zero)
User3 withdrawal: FAILED (division by zero)
RESULT: TOTAL LOSS
Funds locked in vault: 2265 tokens
User1 balance: 20
User2 balance: 20
User3 balance: 20
*** ALL FUNDS PERMANENTLY LOCKED ***
*** NO RECOVERY MECHANISM EXISTS ***
```
Test passes, confirming the vulnerability exists.
</details>
Recommended Mitigation
- remove this code
+ add this code
Implement a fallback mechanism to handle the zero-winner scenario
```diff
function withdraw() external winnerSet {
if (block.timestamp < eventEndDate) {
revert eventNotEnded();
}
// Handle case when no one predicted the winner
+ if (totalWinnerShares == 0) {
// Proportional refund to all participants
+ uint256 userShares = balanceOf(msg.sender);
+ require(userShares > 0, "No shares to withdraw");
+ uint256 totalShares = totalSupply();
+ uint256 refundAmount = Math.mulDiv(userShares, finalizedVaultAsset, totalShares);
+ _burn(msg.sender, userShares);
+ IERC20(asset()).safeTransfer(msg.sender, refundAmount);
+ emit Refund(msg.sender, refundAmount);
+ return;
}
// Normal winner withdrawal logic
if (
keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
keccak256(abi.encodePacked(winner))
) {
revert didNotWin();
}
uint256 shares = balanceOf(msg.sender);
uint256 assetToWithdraw = Math.mulDiv(shares, finalizedVaultAsset, totalWinnerShares);
_burn(msg.sender, shares);
IERC20(asset()).safeTransfer(msg.sender, assetToWithdraw);
emit Withdraw(msg.sender, assetToWithdraw);
}
```
**Alternative solution:** Add an admin emergency rescue function (less preferred due to centralization):
```diff
+ function emergencyRescueFunds() external onlyOwner winnerSet {
+ require(totalWinnerShares == 0, "Winners exist");
+ require(block.timestamp > eventEndDate + 30 days, "Wait period not elapsed");
+ uint256 balance = IERC20(asset()).balanceOf(address(this));
+ IERC20(asset()).safeTransfer(owner(), balance);
+ emit EmergencyRescue(balance);
}
```