Part 2

Zaros
PerpetualsDEXFoundrySolidity
70,000 USDC
View results
Submission Details
Severity: high
Valid

After an LP provider's initial stake, if they stake again in the same vault they lose all accumulated value from previous staked amount

Summary

In VaultRouterBranch- the stakefunction allows LP providers to stake the vault shares that they received from depositing collateral specific to the vaultId.

The vault periodically, positively updates its valuePerShare amount when value is generated and distributed to the vault - this value is used to determine LP providers value earned per share that they staked.

The Distributioncontract is responsible for accounting for those values along with the specific LP providers share amount and valuePerShare.

We will go through a 2 step process in the scenario when an LP Provider stakes into the same vault twice, in which they will lose all value that their inital staked amount earned them.

Vulnerability Details

  • The LP provider initially stakes 20 shares to a vault by calling stake.

function stake(uint128 vaultId, uint128 shares) external {
  • recalculateVaultsCreditCapacitydistributes value to the vault and updates the valuePerShareparamater in Distributioncontract.

    • lets assume the valuePerShareis updated to 2.

/// VaultRouterBranch stake function
Vault.recalculateVaultsCreditCapacity(vaultsIds);
/// Distribution contract
function distributeValue(Data storage self, SD59x18 value) internal {
if (value.eq(SD59x18_ZERO)) {
return;
}
UD60x18 totalShares = ud60x18(self.totalShares);
if (totalShares.eq(UD60x18_ZERO)) {
revert Errors.EmptyDistribution();
}
SD59x18 deltaValuePerShare = value.div(totalShares.intoSD59x18());
self.valuePerShare = sd59x18(self.valuePerShare).add(deltaValuePerShare).intoInt256();
  • In the Distributioncontract this will be the state of the LP provider:

    • shares = 20

    • actor.lastValuePerShare= 2

    • Global valuePerShare =2

SECOND STAKE

For this second stake, lets assume these values for the stake (for simplicity):

New shares to stake = 10

The valuePerSharegets value distributed to it and increases by 2. (from 2 to 4).

  • The LP provider calls stakeagain with the same VaultID.

  • recalculateVaultsCreditCapacity - distributes value to the Distributioncontract and the global variable valuePerShareis increased by 2. (from 2 -> 4)

The follwoign will be the flow from staketo Distributor- showing how stakeupdates the values in Distributor- which causes the LP Provider to lose funds

  1. stakecalls accumulateActorin the Distributecontract

// accumulate the actor's pending reward before staking
wethRewardDistribution.accumulateActor(actorId);

** Distribution contract flow at that point:**

function accumulateActor(Data storage self, bytes32 actorId) internal returns (SD59x18 valueChange) {
Actor storage actor = self.actor[actorId];
return _updateLastValuePerShare(self, actor, ud60x18(actor.shares));
// actor.lastValuePerShare is now equal to 4 (the updated global valuePerShare)
function _updateLastValuePerShare(
Data storage self,
Actor storage actor,
UD60x18 newActorShares
)
private
returns (SD59x18 valueChange)
{
valueChange = _getActorValueChange(self, actor);
actor.lastValuePerShare = newActorShares.eq(UD60x18_ZERO) ? int256(0) : self.valuePerShare;
// deltaValuePerShare = 2 (4 - 2)
// valueChange = 40 (2 * 20 shares)
function _getActorValueChange(
Data storage self,
Actor storage actor
)
private
view
returns (SD59x18 valueChange)
{
SD59x18 deltaValuePerShare = sd59x18(self.valuePerShare).sub(sd59x18(actor.lastValuePerShare));
valueChange = deltaValuePerShare.mul(ud60x18(actor.shares).intoSD59x18());
}

As a result:

The value change of 40was calculated -> representing the amount of value that the LP provider had accumulated from staking 20 shares -> and staking for a duration that saw the vault generate value (which increased the valuePerSharefrom 2 -> 4)

The lastValuePerSharefor the LP provider is now updated to 4.

Next, stakecalls setActorShares-> passing 30 for updatedActorShares ** (20 original shares + 10 new shares) **

****

// load actor distribution data
Distribution.Actor storage actor = wethRewardDistribution.actor[actorId];
// calculate actor updated shares amount
UD60x18 updatedActorShares = ud60x18(actor.shares).add(ud60x18(shares));
// update actor staked shares
wethRewardDistribution.setActorShares(actorId, updatedActorShares);

The following is the flow and result of the logic of setActorSharesin the Distributioncontract

function setActorShares(
Data storage self,
bytes32 actorId,
UD60x18 newActorShares
)
internal
returns (SD59x18 valueChange)
{
valueChange = getActorValueChange(self, actorId);
Actor storage actor = self.actor[actorId];
self.totalShares = ud60x18(self.totalShares).add(newActorShares).sub(ud60x18(actor.shares)).intoUint128();
actor.shares = newActorShares.intoUint128();
_updateLastValuePerShare(self, actor, newActorShares);
}
  1. valueChange = 0

    • (the LP providers lastValuePerSharewas already updated to the current global valuePerShareearlier when stakecalled accumulateActor.-> so there is no value change)

  2. actor.shares = 30

    • original 20 shares staked + 10 new shares staked

  3. The LP providers final state will be:

    • 30 total shares

    • lastValuePerShareis the current global value, meaning there isnt any value to distribute to them per share

    • valueChangeis 0.

Impact

When stakecalled accumulateActor -> Distributecalculated the correct amount of value that the LP provider earned by staking for a duration that saw the vault generate value.

But that valueChangeamount is never stored anywhere, and there is nothing done with it.

So accumulateActordoes not accumulate value the LP provider has earned, it just calculates it. And then subsequently overrides it to 0.

The LP Provider loses the amount of value that their staked shares generate and then have to start over from 0 when they stake again.

accumulateActorin the stakefunction states :

// accumulate the actor's pending reward before staking
wethRewardDistribution.accumulateActor(actorId)

Which suggests that the valueChange amount that is calculated should be stored for the LP provider to be able to redeem or different functionality -> but one that allows the LP provider to realize the value they earned.

valueChangeis returned by each function seen here in Distribution

But as shown above, the function call to accumulateActordoes not handle the return value -> it gets lost and unused.

Tools Used

Manual Review

Recommendations

  1. Because the amount of fee's earned will be overridden and will be 0 after the new stake -> apply the same check used in unstake-> which ensures the staker doesnt have any claimable rewards and only allows the action if they have no rewards to claim:

// get the claimable amount of fees
UD60x18 amountToClaimX18 = vault.wethRewardDistribution.getActorValueChange(actorId).intoUD60x18();
// reverts if the claimable amount is NOT 0
if (!amountToClaimX18.isZero()) revert Errors.UserHasPendingRewards(actorId, amountToClaimX18.intoUint256());

This adds a restriction on LP providers to only allow a subsequent stake amount only if they claim all of their rewards accumulated at that point.

But it is definitely better than staking more and losing all of the rewards from your previous staked amount and duration staked.

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Inside VaultRouterBranch if you stake wait some time then stake again makes you lose the rewards.

Support

FAQs

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