BriVault

First Flight #52
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Impact: medium
Likelihood: high
Invalid

Users can deposit after joining event causing share accounting mismatch

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences

Root Cause
The contract allows users to deposit additional tokens after calling joinEvent(). However, joinEvent() takes a snapshot of shares at that moment and stores it in userSharesToCountry, which never gets updated if the user deposits more tokens.
Vulnerable code:
function deposit(uint256 assets, address receiver) public override returns (uint256) {
require(receiver != address(0));
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
// @audit No check if user already joined event
_mint(msg.sender, participantShares); // User gets NEW shares
}
function joinEvent(uint256 countryId) public {
uint256 participantShares = balanceOf(msg.sender); // Snapshot
userSharesToCountry[msg.sender][countryId] = participantShares;
// @audit This value is NEVER updated if user deposits more
totalParticipantShares += participantShares;
}

Risk

Likelihood:

Risk (Likelihood + Impact):

Likelihood:

Users will deposit after joining because there is no validation preventing it. The protocol rules imply a single deposit-then-join flow, but nothing enforces this constraint technically.

Any user can call deposit() multiple times before the event starts, and nothing stops them from calling it after joinEvent().

Impact:

This creates state inconsistency between actual shares and recorded shares:

Scenario 1: Undercounted shares

  • Alice deposits 500 tokens → receives 500 shares

  • Alice calls joinEvent(3) → userSharesToCountry[alice][3] = 500

  • Alice deposits another 500 tokens → now has 1000 total shares

  • userSharesToCountry[alice][3] still shows only 500 shares

  • totalParticipantShares only counted the initial 500

Scenario 2: Winner calculation errors

  • When _getWinnerShares() runs, it uses userSharesToCountry values

  • These values are stale and don't reflect additional deposits

  • totalWinnerShares is calculated incorrectly

  • All winners receive wrong proportions

Scenario 3: State corruption

  • balanceOf(user) shows 1000 shares

  • userSharesToCountry[user][country] shows 500 shares

  • totalParticipantShares doesn't match sum of balanceOf() for all users

  • System state becomes internally inconsistent

This violates accounting integrity and can lead to incorrect fund distributions.


Impact:

  • Impact 1

  • Impact 2

Proof of Concept

---
### **Proof of Concept:**
```solidity
function testDepositAfterJoinCreatesInconsistency() public {
uint256 firstDeposit = 500e18;
uint256 secondDeposit = 500e18;
// Alice deposits first time
vm.startPrank(alice);
token.approve(address(vault), firstDeposit + secondDeposit);
vault.deposit(firstDeposit, alice);
uint256 sharesAfterFirst = vault.balanceOf(alice);
// Alice joins event
vault.joinEvent(5);
// Check recorded shares
uint256 recordedShares = vault.userSharesToCountry(alice, 5);
assertEq(recordedShares, sharesAfterFirst);
// Alice deposits MORE before event starts
vault.deposit(secondDeposit, alice);
uint256 totalSharesNow = vault.balanceOf(alice);
vm.stopPrank();
// Alice now has DOUBLE the shares
assertGt(totalSharesNow, recordedShares);
// But userSharesToCountry STILL shows old value
uint256 stillRecorded = vault.userSharesToCountry(alice, 5);
assertEq(stillRecorded, recordedShares);
// The discrepancy
console.log("Actual shares:", totalSharesNow);
console.log("Recorded shares:", stillRecorded);
console.log("Untracked shares:", totalSharesNow - stillRecorded);
}
function testWinnerSharesUseStaleData() public {
// Alice deposits twice
vm.startPrank(alice);
token.approve(address(vault), 1000e18);
vault.deposit(500e18, alice);
vault.joinEvent(0);
vault.deposit(500e18, alice); // Second deposit after join
vm.stopPrank();
// Bob deposits once
vm.startPrank(bob);
token.approve(address(vault), 500e18);
vault.deposit(500e18, bob);
vault.joinEvent(0);
vm.stopPrank();
// Set winner
vm.warp(vault.eventEndDate() + 1);
vm.prank(owner);
vault.setWinner(0);
uint256 totalWinner = vault.totalWinnerShares();
uint256 aliceActual = vault.balanceOf(alice);
uint256 bobActual = vault.balanceOf(bob);
// totalWinnerShares uses stale values, not actual balances
assertLt(totalWinner, aliceActual + bobActual);
}
```
Expected: Shares tracked accurately reflect all deposits
Actual: Shares tracked at join time, never updated

Recommended Mitigation

- remove this code
+ add this code
- Users can deposit multiple times after joining
- userSharesToCountry stores stale snapshot values
+ Enforce single deposit before join OR use actual balances
Option 1: Prevent deposits after joining
mapping(address => bool) public hasJoined;
function deposit(uint256 assets, address receiver) public override returns (uint256) {
require(receiver != address(0));
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
+ require(!hasJoined[msg.sender], "Cannot deposit after joining");
// ... rest of function
}
function joinEvent(uint256 countryId) public {
// ... validation ...
+ hasJoined[msg.sender] = true;
// ... rest of function
}
Option 2: Always use actual balanceOf() instead of cached values
function _getWinnerShares() internal returns (uint256) {
for (uint256 i = 0; i < usersAddress.length; ++i){
address user = usersAddress[i];
- totalWinnerShares += userSharesToCountry[user][winnerCountryId];
+ if (keccak256(abi.encodePacked(userToCountry[user])) ==
+ keccak256(abi.encodePacked(winner))) {
+ totalWinnerShares += balanceOf(user); // Use actual balance
+ }
}
return totalWinnerShares;
}
This ensures calculations always use current share balances, not stale snapshots.
Updates

Appeal created

bube Lead Judge 21 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!