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

Future stakers get more `FjordPoints` than expected, due to a missing proper reward debt mechanism

Summary

Future stakers are always vested FjordPoints using the total pointsPerToken without properly deducting how much pointsPerToken has already been accumulated before they stake.

Any new staker will be rewarded with pointsPerToken that have been accumulated since the creation of the contract. This means that a user who hasn't been staking from the beginning will be rewarded the same as a user who has been staking since the beginning and even more.

Vulnerability Details

Every time a user stakes in the FjordStaking contract, the FjordPoints::onStaked(...) function gets executed, which starts accumulating FjordPoints. This function, on the other hand, updates the user's pending points and properly distributes them based on how much time has passed. The same things happens when users claim their FjordPoints:

function onStaked(address user, uint256 amount)
external
onlyStaking
@> checkDistribution // Checks how the pointsPerToken has changed
@> updatePendingPoints(user) // Updates user data based on the pointsPerTokan and user stake
{
UserInfo storage userInfo = users[user];
userInfo.stakedAmount = userInfo.stakedAmount.add(amount);
totalStaked = totalStaked.add(amount);
emit Staked(user, amount);
}
function claimPoints() external checkDistribution updatePendingPoints(msg.sender) {
UserInfo storage userInfo = users[msg.sender];
uint256 pointsToClaim = userInfo.pendingPoints;
if (pointsToClaim > 0) {
userInfo.pendingPoints = 0;
_mint(msg.sender, pointsToClaim);
emit PointsClaimed(msg.sender, pointsToClaim);
}
}

The problem arises when a current stake period ends and users unstake and claim their FjordPoints and FjordTokens, and new users start staking. When the new users come in the initial pointsPerToken they will get assigned will be equal to the accumulated pointsPerToken from the previous users (which will be the pointsPerToken from the beginning of the contract). When they go and claim their tokens, they won't have a proper reward debt, and the protocol will award them as if they have been staking from the very beginning:

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);
}

For the following explanation let's assume that two initial users have staked, unstaked, and claimed rewards and have set pointsPerToken to be 35e17 before the new user comes in. The new user stakes 1e18, a new epoch is then started and a reward of 1e18 has been added, after which he/she claims FjordPoints. The pointsPerEpoch are 1e18.

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);
}
weeksPending = (8467201 - 4233601) / 1 weeks = 7; // 6 weeks have passed since the initial users have unstaked, and 1 week for which the new user has staked
pointsPerToken = 35e17 + (7 * 1e18 * 1e18 / 1e18) = 105e17; // The new user's pointsPerToken is equal to 6 weeks worth of staking + 1 additional week
totalPoints = totalPoints + (7 * 1e18) = 140e17; // Total points for the initial users + the new points for the user as if he was staking from the beginning

And now when the points are updated:

modifier updatePendingPoints(address user) {
UserInfo storage userInfo = users[user];
uint256 owed = userInfo.stakedAmount.mul(pointsPerToken.sub(userInfo.lastPointsPerToken))
.div(PRECISION_18);
userInfo.pendingPoints = userInfo.pendingPoints.add(owed);
userInfo.lastPointsPerToken = pointsPerToken;
_;
}
owed = 1e18 * (105e17 - 35e17) / 1e18 = 7e18;
userInfo.pendingPoints = 0 + 7e18 = 7e18; // The new users gets the initial user's rewards combined after staking for just one week
Coded PoC

I have created my test suit, to use proper contract and no mock as in the original one.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;
import { Test, console } from "forge-std/Test.sol";
import { FjordStaking } from "../src/FjordStaking.sol";
import { FjordPoints } from "../src/FjordPoints.sol";
import { FjordToken } from "../src/FjordToken.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 { ud60x18 } from "@prb/math/src/UD60x18.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract FjordAuditTests is Test {
FjordStaking fjordStaking;
FjordToken token;
address minter = makeAddr("minter");
address newMinter = makeAddr("new_minter");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address naruto = makeAddr("naruto");
address internal constant SABLIER_ADDRESS = address(0xB10daee1FCF62243aE27776D7a92D39dC8740f95);
address points;
bool isMock = false;
ISablierV2LockupLinear SABLIER = ISablierV2LockupLinear(SABLIER_ADDRESS);
address authorizedSender = address(this);
function setUp() public {
token = new FjordToken();
points = address(new FjordPoints());
fjordStaking =
new FjordStaking(address(token), minter, SABLIER_ADDRESS, authorizedSender, points);
FjordPoints(points).setStakingContract(address(fjordStaking));
FjordPoints(points).setPointsPerEpoch(1 ether);
deal(address(token), address(this), 10_000 ether);
token.approve(address(fjordStaking), 10_000 ether);
deal(address(token), minter, 10_000 ether);
vm.prank(minter);
token.approve(address(fjordStaking), 10_000 ether);
deal(address(token), alice, 10_000 ether);
vm.prank(alice);
token.approve(address(fjordStaking), 10_000 ether);
deal(address(token), bob, 10_000 ether);
vm.prank(bob);
token.approve(address(fjordStaking), 10_000 ether);
deal(address(token), naruto, 10_000 ether);
vm.prank(naruto);
token.approve(address(fjordStaking), 10_000 ether);
}
function testFutureStakersGetsMorePoints() public {
console.log(
"Alice's points before claiming:", FjordPoints(points).balanceOf(address(alice))
);
console.log("Bob's points before claiming:", FjordPoints(points).balanceOf(address(bob)));
console.log(
"Naruto's points before claiming:", FjordPoints(points).balanceOf(address(naruto))
);
vm.prank(bob);
fjordStaking.stake(1 ether);
vm.prank(alice);
fjordStaking.stake(1 ether);
_addRewardAndEpochRollover(1 ether, 7);
vm.prank(bob);
fjordStaking.unstakeAll();
vm.prank(alice);
fjordStaking.unstakeAll();
vm.prank(alice);
fjordStaking.claimReward(false);
vm.prank(bob);
fjordStaking.claimReward(false);
_addRewardAndEpochRollover(0, 6);
vm.prank(alice);
fjordStaking.completeClaimRequest();
vm.prank(bob);
fjordStaking.completeClaimRequest();
vm.prank(alice);
FjordPoints(points).claimPoints();
vm.prank(bob);
FjordPoints(points).claimPoints();
assertEq(fjordStaking.totalStaked(), 0);
// Naruto comes in after bob and alic have claimed all their rewards after unstaking
vm.prank(naruto);
fjordStaking.stake(1 ether);
_addRewardAndEpochRollover(1 ether, 1);
// Naruto claims his rewards and gets alice and bob's rewards combined
vm.prank(naruto);
FjordPoints(points).claimPoints();
console.log("Alice's points after claiming:", FjordPoints(points).balanceOf(address(alice)));
console.log("Bob's points after claiming:", FjordPoints(points).balanceOf(address(bob)));
console.log(
"Naruto's points after claiming:", FjordPoints(points).balanceOf(address(naruto))
);
assertEq(
FjordPoints(points).balanceOf(address(naruto)),
FjordPoints(points).balanceOf(address(alice))
+ FjordPoints(points).balanceOf(address(bob))
);
}
function _addRewardAndEpochRollover(uint256 reward, uint256 times) internal returns (uint16) {
vm.startPrank(minter);
for (uint256 i = 0; i < times; i++) {
vm.warp(vm.getBlockTimestamp() + fjordStaking.epochDuration());
if (reward > 0) fjordStaking.addReward(reward);
}
vm.stopPrank();
return fjordStaking.currentEpoch();
}
}
Ran 1 test for test/FjordAuditTests.t.sol:FjordAuditTests
[PASS] testFutureStakersGetsMorePoints() (gas: 1440468)
Logs:
Alice's points before claiming: 0
Bob's points before claiming: 0
Naruto's points before claiming: 0
Alice's points after claiming: 3500000000000000000
Bob's points after claiming: 3500000000000000000
Naruto's points after claiming: 7000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 19.04ms (8.55ms CPU time)
Ran 1 test suite in 217.46ms (19.04ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

Stakers who join the protocol at a later stage get more rewards than the early adopters, making it unfair for anyone who wants to start early.

Tools Used

Manual review

Recommendations

Modify the point distribution in FjordPoints so that when a new user joins, his reward debt properly accounts for how much time has passed, and he/she does not get accredited for any reward accumulated before he/she joins. A good idea could be to track at which point a user enters the FjordPoints contract so that his/her pointsPerTokens do not get multiplied by the whole contract timespan.

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.