Description:
When calculating withdrawal amounts for winners, the contract uses:
uint256 assetToWithdraw = Math.mulDiv(shares, vaultAsset, totalWinnerShares);
However, totalWinnerShares only includes shares of users who bet on the winning team. The problem is that vaultAsset (the total balance) includes deposits from ALL participants (winners and losers), but the calculation divides by only the winner shares.
This seems correct at first glance - winners should split the entire pool. However, there's a critical issue: non-winners still hold shares in the vault (they were minted shares but cannot withdraw). These shares remain in totalSupply() and if non-winners haven't burned their shares, it creates accounting inconsistencies.
More critically, if the vault inherits standard ERC4626 functions like totalAssets() or other view functions, they may report incorrect values because they consider all shares, not just winner shares.
Impact:
Non-winner shares remain in circulation
ERC4626 view functions may return incorrect values
If any integration relies on share price or total assets calculations, they will be wrong
After winners withdraw, remaining dust amounts in vault with orphaned non-winner shares
Vault state becomes inconsistent with actual claimable amounts
Proof of Concept:
function testNonWinnerSharesDilution() public {
vm.startPrank(attacker);
asset.approve(address(vault), 10000 * 10**18);
vault.deposit(10000 * 10**18, attacker);
vault.joinEvent(0);
vm.stopPrank();
vm.startPrank(victim);
asset.approve(address(vault), 10000 * 10**18);
vault.deposit(10000 * 10**18, victim);
vault.joinEvent(1);
vm.stopPrank();
uint256 totalSupplyBeforeWinner = vault.totalSupply();
vm.warp(block.timestamp + 31 days);
vault.setWinner(0);
vm.prank(attacker);
vault.withdraw();
uint256 victimShares = vault.balanceOf(victim);
assertTrue(victimShares > 0, "Victim still has shares");
uint256 totalSupplyAfter = vault.totalSupply();
assertEq(totalSupplyAfter, victimShares, "Non-winner shares remain");
uint256 vaultBalance = asset.balanceOf(address(vault));
console.log("Vault balance after winner withdrawal:", vaultBalance);
console.log("Non-winner shares still exist:", victimShares);
}
Mitigation:
Option 1: Burn non-winner shares when winner is set:
function setWinner(uint256 countryIndex) public onlyOwner returns (string memory) {
// ...
_getWinnerShares();
_setFinallizedVaultBalance();
// Burn all non-winner shares
+ _burnNonWinnerShares();
emit WinnerSet(winner);
return winner;
}
Add function:
function _burnNonWinnerShares() internal {
for (uint256 i = 0; i < usersAddress.length; ++i) {
address user = usersAddress[i];
if (keccak256(abi.encodePacked(userToCountry[user])) !=
keccak256(abi.encodePacked(winner))) {
uint256 shares = balanceOf(user);
if (shares > 0) {
_burn(user, shares);
}
}
}
}
Option 2: Add a claim function for non-winners to explicitly forfeit their shares:
function forfeitShares() external {
require(_setWinner, "Winner not set yet");
require(
keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
keccak256(abi.encodePacked(winner)),
"Winners cannot forfeit"
);
uint256 shares = balanceOf(msg.sender);
if (shares > 0) {
_burn(msg.sender, shares);
}
}