Liquid Staking

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

Missing Storage Gap in Upgradable Contract

Summary

Missing Storage Gap in Upgradable Contract

Vulnerability Details

The contracts OperatorVCS and CommunityVCS inherit from the following base contracts:

OperatorVCS -> VaultControllerStrategy -> Strategy
CommunityVCS -> VaultControllerStrategy -> Strategy

As demonstrated in the code snippets below, both OperatorVCS and CommunityVCS lack the necessary storage gaps required for upgradeability:

// Strategy
abstract contract Strategy is IStrategy, Initializable, UUPSUpgradeable, OwnableUpgradeable {
IERC20Upgradeable public token;
IStakingPool public stakingPool;
// SNIP...
// miss __gap; ❌
}
// VaultControllerStrategy
abstract contract VaultControllerStrategy is Strategy {
// SNIP...
uint256[4] private __gap;
// SNIP...
}
// OperatorVCS
contract OperatorVCS is VaultControllerStrategy {
// basis point amount of an operator's earned rewards that they receive
uint256 public operatorRewardPercentage;
// total unclaimed operator LST rewards
uint256 private unclaimedOperatorRewards;
// SNIP...
// miss __gap; ❌
}
// CommunityVCS
contract CommunityVCS is VaultControllerStrategy {
// min number of non-full vaults before a new batch is deployed
uint128 public vaultDeploymentThreshold;
// number of vaults to deploy when threshold is met
uint128 public vaultDeploymentAmount;
// SNIP...
// miss __gap; ❌
}

The same issue applies to the OperatorVault and CommunityVault contracts:

OperatorVault -> Vault
CommunityVault -> Vault

Both contracts also lack the necessary storage gaps:

// vault
abstract contract Vault is Initializable, UUPSUpgradeable, OwnableUpgradeable {
// SNIP...
// storage gap for upgradeability
uint256[9] private __gap;
// SNIP...
}
// OperatorVault
contract OperatorVault is Vault {
address public operator;
address public rewardsReceiver;
IPFAlertsController public pfAlertsController;
uint128 public trackedTotalDeposits;
uint128 private unclaimedRewards;
// SNIP...
// miss __gap; ❌
}
// CommunityVault
contract CommunityVault is Vault {
// SNIP...
// miss __gap; ❌
}

This issue extends to other critical contracts like OperatorStakingPool, FundFlowController, WithdrawalPool, and more, all of which lack storage gaps:

OperatorStakingPool

contract OperatorStakingPool is Initializable, UUPSUpgradeable, OwnableUpgradeable {
// address of liquid staking token
IStakingPool public lst;
// list of whitelisted operators
address[] private operators;
// used to check membership of operators in this pool
mapping(address => bool) private operatorMap;
// stores the LST share balance for each operator
mapping(address => uint256) private shareBalances;
// total number of LST shares staked in this pool
uint256 private totalShares;
// max LST deposits per operator
uint256 public depositLimit;
// SNIP...
// miss __gap; ❌
}

FundFlowController

contract FundFlowController is UUPSUpgradeable, OwnableUpgradeable {
// address of operator vcs
IVaultControllerStrategy public operatorVCS;
// address of community vcs
IVaultControllerStrategy public communityVCS;
// duration of the unbonding period in the Chainlink staking contract
uint64 public unbondingPeriod;
// duration of the claim period in the Chainlink staking contract
uint64 public claimPeriod;
// total number of vault groups
uint64 public numVaultGroups;
// index of current unbonded vault group
uint64 public curUnbondedVaultGroup;
// time that each vault group was last unbonded
uint256[] public timeOfLastUpdateByGroup;
// SNIP...
// miss __gap; ❌
}

WithdrawalPool

contract WithdrawalPool is UUPSUpgradeable, OwnableUpgradeable {
// address of staking token
IERC20Upgradeable public token;
// address of liquid staking token
IERC20Upgradeable public lst;
// address of priority pool
IPriorityPool public priorityPool;
// list of withdrawal requests in order of creation
Withdrawal[] internal queuedWithdrawals;
// stores a list of withdrawal requests for each account
mapping(address => uint256[]) internal queuedWithdrawalsByAccount;
// mapping of withdrawal request index to the request owner
mapping(uint256 => address) internal withdrawalOwners;
// total number of LST shares queued for withdrawal
uint256 internal totalQueuedShareWithdrawals;
// index of the withdrawal that's at the front of the queue
uint256 public indexOfNextWithdrawal;
// list of withdrawal batches in order of creation
WithdrawalBatch[] internal withdrawalBatches;
// all batches before this index have had all withdrawal requests fully withdrawn
uint128 public withdrawalBatchIdCutoff;
// all withdrawal requests before this index have been fully withdrawn
uint128 public withdrawalIdCutoff;
// min amount of LSTs that can be queued for withdrawal
uint256 public minWithdrawalAmount;
// min amount of time between execution of withdrawals
uint64 public minTimeBetweenWithdrawals;
// time of last execution of withdrawals
uint64 public timeOfLastWithdrawal;
// SNIP...
// miss __gap; ❌
}

StakingPool -> StakingRewardsPool

// StakingRewardsPool
abstract contract StakingRewardsPool is ERC677Upgradeable, UUPSUpgradeable, OwnableUpgradeable {
// SNIP...
// miss __gap; ❌
}
contract StakingPool is StakingRewardsPool {
// list of all strategies controlled by pool
address[] private strategies;
// total number of tokens staked in the pool
uint256 public totalStaked;
// max number of tokens that can sit in the pool outside of a strategy
uint256 public unusedDepositLimit;
// list of fees that are paid on rewards
Fee[] private fees;
// address of priority pool
address public priorityPool;
// address of rebase controller
address public rebaseController;
uint16 private poolIndex; // deprecated
// SNIP...
// miss __gap; ❌
}

PriorityPool

contract PriorityPool is UUPSUpgradeable, OwnableUpgradeable, PausableUpgradeable {
// address of staking asset token
IERC20Upgradeable public token;
// address of staking pool and liquid staking token
IStakingPool public stakingPool;
// address of SDL pool
ISDLPool public sdlPool;
// address of oracle contract that handles LST distribution
address public distributionOracle;
// min amount of tokens that can be deposited into the staking pool in a single tx
uint128 public queueDepositMin;
// max amount of tokens that can be deposited into the staking pool in a single tx
uint128 public queueDepositMax;
// current status of the pool
PoolStatus public poolStatus;
// merkle root for the latest distribution tree
bytes32 public merkleRoot;
// ipfs hash where the latest distribution tree is stored
bytes32 public ipfsHash;
// number of entries in the latest distribution tree
uint256 public merkleTreeSize;
// total number of tokens queued for deposit into the staking pool
uint256 public totalQueued;
// total number of tokens deposited into the staking pool since the last distribution
uint256 public depositsSinceLastUpdate;
// total number of shares received for tokens deposited into the staking pool since the last distribution
uint256 private sharesSinceLastUpdate;
// list of all accounts that have ever queued tokens
address[] private accounts;
// stores each account's index in the distribution tree
mapping(address => uint256) private accountIndexes;
// stores the lifetime amount of queued tokens for each account less any tokens that were unqueued
mapping(address => uint256) private accountQueuedTokens;
// stores the total amount of LSTs that each account has claimed
mapping(address => uint256) private accountClaimed;
// stored the total amount of LST shares that each account has claimed
mapping(address => uint256) private accountSharesClaimed;
// address with authorization to pause the pool
address public rebaseController;
// address of withdrawal pool
IWithdrawalPool public withdrawalPool;
// SNIP...
// miss __gap; ❌
}

Upgradeable contracts must preserve their storage layout across contract versions because the proxy retains the storage structure of the original implementation. Without a storage gap, adding new state variables during an upgrade can overwrite existing variables in derived contracts, leading to undefined behavior or security risks.

The absence of storage gaps violates the best practices for maintaining future-proof upgradeable contracts. A well-defined storage gap reserves extra storage slots to ensure that future variables can be added without interfering with the existing layout.

Impact

Failing to reserve storage slots for future upgrades risks overwriting existing storage variables during contract upgrades, potentially causing unpredictable behavior or introducing security vulnerabilities.

Tools Used

Manual Review

Recommendations

It is recommended to add appropriate storage gaps in the upgradeable contracts to reserve space for future variables. For instance, the following code snippet can be added to the contracts:

// storage gap for upgradeability
uint256[10] private __gap;

This will ensure compatibility with future upgrades while maintaining the integrity of the contract's storage layout.

Updates

Lead Judging Commences

inallhonesty Lead Judge
9 months ago
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.