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) {
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
stakedAsset[receiver] = stakeAsset;
_mint(msg.sender, participantShares);
return participantShares;
}
function cancelParticipation() public {
if (block.timestamp >= eventStartDate) {
revert eventStarted();
}
}
function withdraw() external winnerSet {
if (
keccak256(abi.encodePacked(userToCountry[msg.sender])) !=
keccak256(abi.encodePacked(winner))
) {
revert didNotWin();
}
}
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.deposit(1000e18, user);
user.cancelParticipation();
user.withdraw();
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);
+}