The Plume Staking contract allows validators to earn commission in reward tokens and later request commission claims through ValidatorFacet::requestCommissionClaim. However, a vulnerability exists where if a reward token is removed via RewardsFacet::removeRewardToken before the validator claims their pending accrued commission, the validator permanently loses access to those unclaimed funds.
Vulnerability Details
When a validator admin calls ValidatorFacet::requestCommissionClaim, the function verifies that the specified token is still a valid reward token using the ValidatorFacet::_validateIsToken modifier. This checks PlumeStakingStorage.layout().isRewardToken[token], which must be true.
However, in RewardsFacet::removeRewardToken, once a token is removed:
Reward checkpoints are finalized correctly.
The token’s rate is set to 0.
isRewardToken[token] is set to false.
RewardsFacet::removeRewardToken:
After removal, the token becomes permanently ineligible for claiming via requestCommissionClaim because _validateIsToken will revert due to the token no longer being recognized as valid.
There is no mechanism to check if any validator has unclaimed commission accrued for the token being removed. As a result, any validator who has not claimed their commission before the token is removed loses access to it forever.
Even if $.isRewardToken[token] is set to false, the validator admin should still be allowed to claim their accrued commission, since the rewards were earned prior to the token’s removal and they have the appropriate permission to claim them.
Impact Details
This vulnerability results in permanent loss of validator commission for any validator who failed to claim rewards in a timely manner before token removal.
Permanent freezing of funds: unclaimed commission for removed reward tokens cannot be claimed due to the token no longer being recognized as valid.
Proof of Concept
1
Assume TOKEN_A is an active reward token.
2
Validator V1 has accrued 100 TOKEN_A as commission but has not yet called requestCommissionClaim.
function removeRewardToken(
address token
) external onlyRole(PlumeRoles.REWARD_MANAGER_ROLE) {
PlumeStakingStorage.Layout storage $ = PlumeStakingStorage.layout();
if (!$.isRewardToken[token]) {
revert TokenDoesNotExist(token);
}
// Find the index of the token in the array
uint256 tokenIndex = _getTokenIndex(token);
// Store removal timestamp to prevent future accrual
uint256 removalTimestamp = block.timestamp;
$.tokenRemovalTimestamps[token] = removalTimestamp;
// Update validators (bounded by number of validators, not users)
for (uint256 i = 0; i < $.validatorIds.length; i++) {
uint16 validatorId = $.validatorIds[i];
// Final update to current time to settle all rewards up to this point
PlumeRewardLogic.updateRewardPerTokenForValidator(
$,
token,
validatorId
);
// Create a final checkpoint with a rate of 0 to stop further accrual definitively.
PlumeRewardLogic.createRewardRateCheckpoint(
$,
token,
validatorId,
0
);
}
// Set rate to 0 to prevent future accrual. This is now redundant but harmless.
$.rewardRates[token] = 0;
// DO NOT delete global checkpoints. Historical data is needed for claims.
// delete $.rewardRateCheckpoints[token];
// Update the array
$.rewardTokens[tokenIndex] = $.rewardTokens[$.rewardTokens.length - 1];
$.rewardTokens.pop();
// Update the mapping
@> $.isRewardToken[token] = false;
delete $.maxRewardRates[token];
emit RewardTokenRemoved(token);
}