Liquid Staking

Stakelink
DeFiHardhatOracle
50,000 USDC
View results
Submission Details
Severity: low
Invalid

Total fee amounts can surpass the accrued rewards, resulting in a loss for stakers due to more shares being minted than necessary

Summary

Whenever rewards are accrued from staking, calling _updateStrategyRewards on the StakingPool will allocate a portion of the rewards to fee receivers. There are several fees included in the system.

  1. The StakingPool has fee receivers, which are capped at a total of 40% during initialization.

    function initialize(
    address _token,
    string memory _liquidTokenName,
    string memory _liquidTokenSymbol,
    Fee[] memory _fees,
    uint256 _unusedDepositLimit
    ) public initializer {
    __StakingRewardsPool_init(_token, _liquidTokenName, _liquidTokenSymbol);
    for (uint256 i = 0; i < _fees.length; i++) {
    fees.push(_fees[i]);
    }
    @> require(_totalFeesBasisPoints() <= 4000, "Total fees must be <= 40%");
    unusedDepositLimit = _unusedDepositLimit;
    }
  2. Each strategy has its own fee receivers, which are capped at 30% during initialization.

    function __VaultControllerStrategy_init(
    address _token,
    address _stakingPool,
    address _stakeController,
    address _vaultImplementation,
    Fee[] memory _fees,
    uint256 _maxDepositSizeBP,
    uint256 _vaultMaxDeposits,
    address _vaultDepositController
    ) public onlyInitializing {
    __Strategy_init(_token, _stakingPool);
    stakeController = IStaking(_stakeController);
    vaultImplementation = _vaultImplementation;
    for (uint256 i = 0; i < _fees.length; ++i) {
    fees.push(_fees[i]);
    }
    @> if (_totalFeesBasisPoints() > 3000) revert FeesTooLarge();
    if (_maxDepositSizeBP > 10000) revert InvalidBasisPoints();
    maxDepositSizeBP = _maxDepositSizeBP;
    vaultMaxDeposits = _vaultMaxDeposits;
    vaultDepositController = _vaultDepositController;
    }
  3. In addition, the OperatorVCS strategy has an additional _operatorRewardPercentage, which is capped at 100% of all rewards in the vaults managed by this strategy.

    function initialize(
    address _token,
    address _stakingPool,
    address _stakeController,
    address _vaultImplementation,
    Fee[] memory _fees,
    uint256 _maxDepositSizeBP,
    uint256 _vaultMaxDeposits,
    uint256 _operatorRewardPercentage,
    address _vaultDepositController
    ) public reinitializer(3) {
    if (address(token) == address(0)) {
    __VaultControllerStrategy_init(
    _token,
    _stakingPool,
    _stakeController,
    _vaultImplementation,
    _fees,
    _maxDepositSizeBP,
    _vaultMaxDeposits,
    _vaultDepositController
    );
    @> if (_operatorRewardPercentage > 10000) revert InvalidPercentage();
    operatorRewardPercentage = _operatorRewardPercentage;
    globalVaultState = GlobalVaultState(5, 0, 0, 0);
    } else {
    globalVaultState = GlobalVaultState(5, 0, 0, uint64(maxDepositSizeBP + 1));
    maxDepositSizeBP = _maxDepositSizeBP;
    delete fundFlowController;
    vaultMaxDeposits = _vaultMaxDeposits;
    }
    for (uint64 i = 0; i < 5; ++i) {
    vaultGroups.push(VaultGroup(i, 0));
    }
    }

When calling _updateStrategyRewards, there is no check to ensure that the feeAmounts are greater than the actual rewards gained.

...
// distribute fees to receivers if there are any
if (totalFeeAmounts > 0) {
uint256 sharesToMint = (totalFeeAmounts * totalShares) /
(totalStaked - totalFeeAmounts);
@> _mintShares(address(this), sharesToMint);
uint256 feesPaidCount;
for (uint256 i = 0; i < receivers.length; i++) {
for (uint256 j = 0; j < receivers[i].length; j++) {
if (feesPaidCount == totalFeeCount - 1) {
transferAndCallFrom(
address(this),
receivers[i][j],
balanceOf(address(this)),
"0x"
);
} else {
transferAndCallFrom(address(this), receivers[i][j], feeAmounts[i][j], "0x");
feesPaidCount++;
}
}
}
}

This means that there will be more shares minted than necessary which will result in loss for stakers.

PoC

Consider the following fee takers:

  • StakingPool -> fee takers are taking 35%

  • CommunityVCS -> fee takers are taking 30%

  • OperatorVCS -> fee takers are taking 25% + operator is taking 70%

Rewards:

  • Rewards from community vaults -> 1000

  • Rewards from operator vaults -> 1200

  • Total rewards -> 2200

Fees:

  • StakingPool fee -> 0.4 x 2200 = 770.0

  • CommunityVCS fee -> 0.3 x 1000 = 300.0

  • OperatorVCS fee takers -> 0.25 x 1200 = 300.0

  • Operator fee -> 0.7 x 1200 = 840.0

Total taken: 2210 > 2200

This diference can be larger if the fees are set to their maximum limits.
As a result, more shares will be minted, lowering the price of shares for stakers.

Impact

Likelihood: Low

It depends on the fees set during initialization and update.

Impact: Medium

If the fees are not initialized or updated correctly, it will result in a loss for stakers as more shares will be minted.

Tools Used

Manual review.

Recommendations

  • One way to solve this is to prevent the fee amount from exceeding 100% by modifying the updateRewards function to handle this case. The team can decide not to send any fees at all in this situation.

// safety check
if (totalFeeAmounts >= totalStaked) {
totalFeeAmounts = 0;
}
+ if (totalFeeAmounts >= totalRewards) {
+ totalFeeAmounts = 0;
+ }
// distribute fees to receivers if there are any
if (totalFeeAmounts > 0) {
...
}
  • Another way to solve this is to ensure that the total fee amount cannot exceed 100%. For example, it can be shown that setting the operator fee to a maximum of 30%, without changing any other caps, will result in the fees always being capped at the total rewards amount.

A - total operator rewards
B - total community rewards
Staking pool takes maximum = 0.4 * (A + B)
CommunityVCS takes maximum = 0.3 * A
Find maximum operatorVCS fee = X ?
0.3 * A + 0.4 * (A + B) + X * B <= A + B
0.7 * A + (X + 0.4) * B <= A + B
(X + 0.4) * B <= B
X = maximum of 60% => operator fee 30% + fee takers 30%

Updates

Lead Judging Commences

inallhonesty Lead Judge 8 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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