51124 sc high validator would loss commission fee if the rewards token are removed
Submitted on Jul 31st 2025 at 10:45:48 UTC by @pks271 for Attackathon | Plume Network
Report ID: #51124
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/facets/ValidatorFacet.sol
Impacts:
Temporary freezing of funds for at least 24 hours
Description
Bug Description
When stake/unstake/claim including RewardsFacet#removeRewardToken actions are called, the $.validatorAccruedCommission[validatorId][token] += commissionDeltaForValidator inside PlumeRewardLogic#updateRewardPerTokenForValidator() function will be updated. This parameter tracks the history of validators' commission fees and validators can call ValidatorFacet#requestCommissionClaim/finalizeCommissionClaim to claim their commission fee.
When REWARD_MANAGER_ROLE calls RewardsFacet#removeRewardToken to remove the reward token, the $.validatorAccruedCommission[validatorId][token] is updated first, then $.isRewardToken[token] is set to false. The problem is that requestCommissionClaim will revert because it uses _validateIsToken to check whether the reward token exists; if not, it reverts.
Relevant code excerpts:
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)
// @audit - update the reward per token for all validators
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
// @audit - set `$.isRewardToken[token]` to false
$.isRewardToken[token] = false;
delete $.maxRewardRates[token];
emit RewardTokenRemoved(token);
}The _validateIsToken modifier currently reverts when isRewardToken[token] is false:
modifier _validateIsToken(
address token
) {
if (!PlumeStakingStorage.layout().isRewardToken[token]) {
revert TokenDoesNotExist(token);
}
_;
}requestCommissionClaim uses this modifier, which causes it to revert for tokens that were valid reward tokens but were later removed after commission accrual:
function requestCommissionClaim(
uint16 validatorId,
address token
)
external
onlyValidatorAdmin(validatorId)
nonReentrant
_validateValidatorExists(validatorId)
// @audit - revert here
_validateIsToken(token)
{
// skip
}Impact
Validators who accrued commission for a token that is later removed will be unable to request/finalize claims for that accrued commission because
_validateIsTokenwill revert if the token has been removed.This can temporarily freeze validator funds (commission) for at least 24 hours (or until the token is re-added or the contract is changed).
Recommendation
Consider adding a historical commission check to the token validation so that tokens that are no longer active but have accrued commission can still be claimed. For example:
modifier _validateIsToken(address token, uint16 validatorId) {
if (!$.isRewardToken[token]) {
bool hasAccruedCommission = false;
if ($.validatorAccruedCommission[validatorId][token] > 0) {
hasAccruedCommission = true;
}
if (!hasAccruedCommission) {
revert TokenNotRegistered(token);
}
}
}This approach allows validators to claim previously accrued commission for tokens that have been removed while still preventing operations for tokens that were never registered.
Proof of Concept
Insert the following test case into PlumeStakingDiamond.t.sol:
function testCommissionLostAfterRewardTokenRemoval() public {
console2.log("--- Test: testCommissionLostAfterRewardTokenRemoval START ---");
// Setup: Add reward token and set reward rate
uint256 rewardRate = 1e18; // 1 PUSD per second
vm.startPrank(admin);
address[] memory tokens = new address[](1);
tokens[0] = address(pUSD);
uint256[] memory rates = new uint256[](1);
rates[0] = rewardRate;
RewardsFacet(address(diamondProxy)).setRewardRates(tokens, rates);
RewardsFacet(address(diamondProxy)).setTreasury(address(treasury));
// Ensure treasury has enough PUSD
uint256 treasuryAmount = 1000 ether;
pUSD.transfer(address(treasury), treasuryAmount);
vm.stopPrank();
// Setup validator with commission
vm.startPrank(validatorAdmin);
uint256 commissionRate = 10e16; // 10%
ValidatorFacet(address(diamondProxy)).setValidatorCommission(
DEFAULT_VALIDATOR_ID,
commissionRate
);
vm.stopPrank();
// User stakes to generate rewards and commission
uint256 stakeAmount = 10 ether;
vm.startPrank(user1);
StakingFacet(address(diamondProxy)).stake{value: stakeAmount}(
DEFAULT_VALIDATOR_ID
);
vm.stopPrank();
// Move time forward to accrue rewards and commission
vm.roll(block.number + 100);
vm.warp(block.timestamp + 100);
// Trigger reward updates to ensure commission is accrued
vm.startPrank(user1);
StakingFacet(address(diamondProxy)).unstake(DEFAULT_VALIDATOR_ID, 1); // Unstake 1 wei to trigger update
vm.stopPrank();
// Verify commission has accrued
uint256 accruedCommission = ValidatorFacet(address(diamondProxy))
.getAccruedCommission(DEFAULT_VALIDATOR_ID, address(pUSD));
assertEq(accruedCommission, 100*1e18, "Commission should have accrued");
// Now remove the reward token
vm.startPrank(admin);
RewardsFacet(address(diamondProxy)).removeRewardToken(address(pUSD));
vm.stopPrank();
// Verify token is no longer a reward token
bool isRewardToken = RewardsFacet(address(diamondProxy)).isRewardToken(address(pUSD));
assertEq(isRewardToken, false, "Token should no longer be a reward token");
// Try to request commission claim - this should fail due to _validateIsToken check
vm.startPrank(validatorAdmin);
// This should revert because _validateIsToken will check isRewardToken[token] which is now false
vm.expectRevert(); // Expect revert due to TokenNotRegistered error
ValidatorFacet(address(diamondProxy)).requestCommissionClaim(
DEFAULT_VALIDATOR_ID,
address(pUSD)
);
vm.stopPrank();
}Was this helpful?