DeFiFoundry
20,000 USDC
View results
Submission Details
Severity: medium
Invalid

Users can bypass early claim penalty in `FjordStaking::claimReward()` and `FjordStaking::completeClaimRequest()`

Summary

The FjordStaking contract allows users to bypass the early claim penalty by creating a claim receipt and waiting for the claim cycle to complete, undermining the intended penalty mechanism.

Vulnerability Details

The FjordStaking contract implements a staking system where users can stake FJORD tokens and earn rewards over time. To discourage early withdrawal of rewards, the contract includes an early claim penalty mechanism. However, the current implementation allows users to circumvent this penalty.

The contract provides two main functions for claiming rewards:

  1. claimReward(): This function allows users to claim rewards immediately with a penalty if _isClaimEarly is set to true, or create a claim receipt if _isClaimEarly is false.

  2. completeClaimRequest(): This function allows users to claim their rewards after the claim cycle has completed, without incurring any penalty.

The vulnerability lies in the interaction between these two functions. Users can exploit this by:

  1. Staking their tokens and accumulating rewards.

  2. Unstaking their tokens early.

  3. Instead of claiming rewards immediately (which would incur a penalty), they create a claim receipt using claimReward(false).

  4. Waiting for the claim cycle to complete.

  5. Using completeClaimRequest() to claim their full rewards without any penalty.

This process effectively bypasses the early claim penalty, as the contract does not track whether the user has unstaked early when processing the completed claim request.

Here's the relevant code from the claimReward() function:

function claimReward(bool _isClaimEarly)
external
checkEpochRollover
redeemPendingRewards
returns (uint256 rewardAmount, uint256 penaltyAmount)
{
// ... (omitted for brevity)
if (!_isClaimEarly) {
claimReceipts[msg.sender] =
ClaimReceipt({ requestEpoch: currentEpoch, amount: ud.unclaimedRewards });
emit ClaimReceiptCreated(msg.sender, currentEpoch);
return (0, 0);
}
// ... (penalty calculation and application)
}

And from the completeClaimRequest() function:

function completeClaimRequest()
external
checkEpochRollover
redeemPendingRewards
returns (uint256 rewardAmount)
{
ClaimReceipt memory cr = claimReceipts[msg.sender];
if (cr.requestEpoch < 1) revert ClaimReceiptNotFound();
if (currentEpoch - cr.requestEpoch <= claimCycle) revert CompleteRequestTooEarly();
rewardAmount = cr.amount;
// ... (reward distribution without penalty check)
}

The completeClaimRequest() function does not check whether the user has unstaked early, allowing them to claim their full rewards without any penalty.

Impact

Users can bypass the early claim penalty by creating a claim receipt and waiting for the claim cycle to complete. This undermines the intended penalty mechanism and allows users to receive their full rewards without any deductions, potentially leading to an uneven distribution of rewards among users.

Proof of Concept

Here's a step-by-step scenario demonstrating the exploit:

  1. Alice stakes 1000 FJORD tokens using the stake() function.

  2. Over time, Alice accumulates 100 FJORD tokens as rewards.

  3. Alice decides to unstake her tokens early using the unstake() function.

  4. Instead of claiming rewards immediately (which would incur a 50% penalty), Alice calls claimReward(false) to create a claim receipt.

  5. Alice waits for the claim cycle (e.g., 3 epochs) to complete.

  6. After the claim cycle, Alice calls completeClaimRequest().

  7. Alice receives the full 100 FJORD tokens as rewards, bypassing the early claim penalty.

Tools Used

Manual review

Recommendation

To address this issue, It's recommend to implement a mechanism to track whether a user has unstaked early and apply the penalty accordingly, even when completing a claim request. Here's a suggested fix:

diff --git a/src/FjordStaking.sol b/src/FjordStaking.sol
index a1b2c3d..e4f5g6h 100644
--- a/src/FjordStaking.sol
+++ b/src/FjordStaking.sol
@@ -15,6 +15,7 @@ struct UserData {
uint256 unclaimedRewards;
uint16 unredeemedEpoch;
uint16 lastClaimedEpoch;
+ bool unstakedEarly; // New flag to track early unstaking
}
struct ClaimReceipt {
uint16 requestEpoch;
uint256 amount;
+ bool unstakedEarly; // New field to store unstaking status
}
@@ -150,6 +151,7 @@ contract FjordStaking {
if (currentEpoch != _epoch) {
// _epoch less than current epoch then user can unstake after at complete lockCycle
if (currentEpoch - _epoch <= lockCycle) revert UnstakeEarly();
+ userData[msg.sender].unstakedEarly = true; // Set flag when unstaking early
}
//EFFECT
@@ -200,7 +202,8 @@ contract FjordStaking {
if (!_isClaimEarly) {
claimReceipts[msg.sender] =
ClaimReceipt({ requestEpoch: currentEpoch, amount: ud.unclaimedRewards,
- unstakedEarly: false });
+ unstakedEarly: ud.unstakedEarly }); // Store unstaking status
+ ud.unstakedEarly = false; // Reset the flag after storing in claim receipt
emit ClaimReceiptCreated(msg.sender, currentEpoch);
@@ -240,6 +243,13 @@ contract FjordStaking {
rewardAmount = cr.amount;
+ if (cr.unstakedEarly) {
+ uint256 penaltyAmount = rewardAmount / 2;
+ rewardAmount -= penaltyAmount;
+ totalRewards -= penaltyAmount;
+ emit PenaltyApplied(msg.sender, penaltyAmount);
+ }
+
userData[msg.sender].unclaimedRewards -= rewardAmount;
totalRewards -= rewardAmount;

This modification ensures that the early unstaking penalty is applied even when users complete their claim requests after the claim cycle, maintaining the integrity of the penalty mechanism. The changes include:

  1. Adding an unstakedEarly flag to the UserData struct to track early unstaking.

  2. Setting this flag in the unstake() function when a user unstakes early.

  3. Including the unstakedEarly status in the ClaimReceipt struct.

  4. Storing the unstaking status when creating a claim receipt in claimReward().

  5. Checking the unstaking status and applying the penalty if necessary in completeClaimRequest().

Updates

Lead Judging Commences

inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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