BriVault

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

CRITICAL-06: Funds Locked for Depositors Who Don't Call joinEvent

Root + Impact

Users who deposit funds but never call joinEvent() have their funds permanently locked in the contract with no mechanism to withdraw after the event starts or ends.

Description

Normal behavior in a betting/tournament vault expects that all deposited funds can be withdrawn under some conditions - either before event starts via cancellation, or after event ends if the user wins.

The current implementation separates deposit() and joinEvent() but provides no withdrawal path for users who deposit but don't join. Once eventStartDate passes, cancelParticipation() reverts and withdraw() requires the user to have joined a winning team.

function deposit(
uint256 assets,
address receiver
) public override returns (uint256) {
// ... validation ...
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
// @> User deposits successfully and receives shares
stakedAsset[receiver] = stakeAsset;
_mint(msg.sender, participantShares);
// @> But is NOT required to call joinEvent
return participantShares;
}
function cancelParticipation() public {
// @> After event starts, cancellation is impossible
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
// ... refund logic ...
}
function withdraw() external winnerSet {
// @> Requires user to have joined winning team
if (
keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
keccak256(abi.encodePacked(winner))
) {
revert didNotWin();
}
// ... withdrawal logic ...
}
// @> No function allows withdrawal for non-participants
// @> Users who deposit but don't join are locked out permanently

Risk

Likelihood:

  • Users who deposit and forget to call joinEvent before event starts

  • Users who deposit planning to join later but miss the deadline

  • UI bugs or transaction failures that prevent joinEvent from executing

  • User confusion about the two-step process

  • No warnings in code that deposit alone is insufficient

Impact:

  • Complete permanent loss of deposited funds for affected users

  • Funds remain locked in contract forever with no recovery mechanism

  • These locked funds dilute winners' payouts (included in vault balance but not claimable)

  • No emergency withdrawal function for owner to return funds

  • Breaks user expectations from standard vault/pool interfaces

  • Creates financial risk for users who may not understand the two-step requirement

  • Protocol accumulates "dead" funds that benefit no one

Proof of Concept

// User deposits 1000 tokens successfully
user.deposit(1000e18, user);
// User's state:
// - stakedAsset[user] = 990
// - balanceOf(user) = 990 shares
// - Tokens transferred to vault
// User forgets to call joinEvent()
// OR user's joinEvent transaction fails
// OR user doesn't understand they need to join
// Time passes, event starts
// block.timestamp >= eventStartDate
// User tries to cancel: REVERTS (eventStarted)
user.cancelParticipation(); // ❌ Fails
// Event ends, winner is set
// User tries to withdraw: REVERTS (didNotWin - never joined)
user.withdraw(); // ❌ Fails
// User's 1000 tokens are permanently locked
// No function exists to recover them
// Funds are lost forever

Recommended Mitigation

Option 1: Merge deposit and joinEvent

-function deposit(
- uint256 assets,
- address receiver
-) public override returns (uint256) {
+function depositAndJoin(
+ uint256 assets,
+ uint256 countryId
+) public returns (uint256) {
// ... validation ...
+ // Validate countryId
+ if (countryId >= teams.length) {
+ revert invalidCountry();
+ }
stakedAsset[msg.sender] = stakeAsset;
uint256 participantShares = _convertToShares(stakeAsset);
// ... token transfers ...
_mint(msg.sender, participantShares);
+ // Automatically join event
+ userToCountry[msg.sender] = teams[countryId];
+ userSharesToCountry[msg.sender][countryId] = participantShares;
+ usersAddress.push(msg.sender);
+ numberOfParticipants++;
+ totalParticipantShares += participantShares;
+
+ emit deposited(msg.sender, stakeAsset);
+ emit joinedEvent(msg.sender, countryId);
return participantShares;
}

Option 2: Allow emergency withdrawal for non-participants

+function emergencyWithdrawNonParticipant() external {
+ // Only allow if user deposited but never joined
+ require(stakedAsset[msg.sender] > 0, "No deposit");
+ require(bytes(userToCountry[msg.sender]).length == 0, "Already joined event");
+
+ // Can withdraw anytime if not participating
+ uint256 refundAmount = stakedAsset[msg.sender];
+ uint256 shares = balanceOf(msg.sender);
+
+ stakedAsset[msg.sender] = 0;
+ _burn(msg.sender, shares);
+
+ IERC20(asset()).safeTransfer(msg.sender, refundAmount);
+
+ emit EmergencyWithdraw(msg.sender, refundAmount);
+}
Updates

Appeal created

bube Lead Judge 19 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
mostafapahlevani93 Submitter
19 days ago
bube Lead Judge
15 days ago
bube Lead Judge 15 days ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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

Give us feedback!