52889 sc high inactive validators accrue rewards for new tokens
Submitted on Aug 14th 2025 at 01:32:54 UTC by @KlosMitSoss for Attackathon | Plume Network
Report ID: #52889
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/facets/RewardsFacet.sol
Impacts:
Theft of unclaimed yield
Description
Brief/Intro
When a validator is deactivated, the rates for all reward tokens are set to zero. This means that stakers of this validator should not and will not receive any rewards while the validator is inactive. However, when a new reward token is added while a validator is inactive, stakers of that validator will be able to receive rewards from the token addition timestamp once the validator becomes active again.
Vulnerability Details
The vulnerability stems from the RewardsFacet::addRewardToken() function creating positive-rate checkpoints for all validators, including inactive ones, without considering their active status.
When a validator is deactivated via ValidatorFacet::setValidatorStatus(), zero-rate checkpoints are correctly created:
// Create a zero-rate checkpoint for all reward tokens to signal inactivity start
for (uint256 i = 0; i < rewardTokens.length; i++) {
PlumeRewardLogic.createRewardRateCheckpoint($, rewardTokens[i], validatorId, 0);
}However, when a new token is added through addRewardToken(), it blindly creates positive-rate checkpoints for all validators:
// Create a historical record that the rate starts at initialRate for all validators
uint16[] memory validatorIds = $.validatorIds;
for (uint256 i = 0; i < validatorIds.length; i++) {
uint16 validatorId = validatorIds[i];
PlumeRewardLogic.createRewardRateCheckpoint($, token, validatorId, initialRate); // Sets rate > 0 for ALL validators
}This creates a checkpoint with rate = initialRate > 0 for inactive validators, effectively overriding their inactive status for the new token.
The issue manifests during reward calculations in PlumeRewardLogic::_calculateRewardsCore(). When a user interacts with the system after validator reactivation, their reward calculation starts from their original stake time (userValidatorStakeStartTime), not from when the token was added. The function uses PlumeRewardLogic::getDistinctTimestamps() to identify all checkpoint periods and processes each segment:
for (uint256 k = 0; k < distinctTimestamps.length - 1; ++k) {
uint256 segmentStartTime = distinctTimestamps[k];
uint256 segmentEndTime = distinctTimestamps[k + 1];
// Rate at START of segment
PlumeStakingStorage.RateCheckpoint memory rewardRateInfoForSegment =
getEffectiveRewardRateAt($, token, validatorId, segmentStartTime);
uint256 effectiveRewardRate = rewardRateInfoForSegment.rate;
// Calculate rewards using this rate
if (effectiveRewardRate > 0 && userStakedAmount > 0) {
uint256 grossRewardForSegment =
(userStakedAmount * rewardPerTokenDeltaForUserInSegment) / PlumeStakingStorage.REWARD_PRECISION;
// User receives rewards even though validator was inactive
}
}This enables the following scenario:
Impact Details
Due to this issue, users receive unearned rewards equal to userStakedAmount * initialRate * inactivePeriodDuration. The protocol loses these reward tokens that should remain in the treasury. Example: when the user has 1 PLUME staked, initialRate is 1 token per second and the validator remains inactive for 1 day, they will receive:
1e18 * 1 token per second * 86400References
Code references are provided throughout the report.
Proof of Concept
Validator is deactivated
The validator is later deactivated via
ValidatorFacet::setValidatorStatus(). (https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/facets/ValidatorFacet.sol#L271-L286). This setsslashedAtTimestamptoblock.timestampto cap rewards and creates zero-rate checkpoints for all existing reward tokens.
New reward token added while inactive
While the validator remains inactive,
RewardsFacet::addRewardToken()is called to add a new reward token. The function loops through allvalidatorIds, including inactive ones, and creates reward rate checkpoints withinitialRatefor every validator (https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/facets/RewardsFacet.sol#L193-L196). This callsPlumeRewardLogic::updateRewardPerTokenForValidator(), which only updatesvalidatorLastUpdateTimes[validatorId][token]for inactive validators (https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/lib/PlumeRewardLogic.sol#L155-L160).
Validator reactivated
The validator is reactivated via
ValidatorFacet::setValidatorStatus(), creating new reward rate checkpoints with the current global rate. (https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/facets/ValidatorFacet.sol#L292-L304).
Alice claims rewards — calculation includes inactive period
Alice claims rewards by calling
RewardsFacet::claim(address token, uint16 validatorId), which processes rewards throughRewardsFacet::_processValidatorRewards()(https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/facets/RewardsFacet.sol#L310-L331).This calls
PlumeRewardLogic::updateRewardsForValidatorAndToken()(https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/facets/RewardsFacet.sol#L468), which invokescalculateRewardsWithCheckpoints()(https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/lib/PlumeRewardLogic.sol#L112-L113). This function calls and then_calculateRewardsCore().PlumeRewardLogic::updateRewardPerTokenForValidator()updates thevalidatorLastUpdateTimes[validatorId][token]andvalidatorRewardPerTokenCumulative[validatorId][token](https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/lib/PlumeRewardLogic.sol#L163-L196).PlumeRewardLogic::_calculateRewardsCore()calculates the rewards from theuserValidatorRewardPerTokenPaidTimestamp[user][validatorId][token]up to the currentblock.timestampif theuserValidatorRewardPerTokenPaid[user][validatorId][token]differs from thevalidatorRewardPerTokenCumulative[validatorId][token](which it does due to theupdateRewardPerTokenForValidator()call). In this case, theuserValidatorRewardPerTokenPaidTimestamp[user][validatorId][token]will be equal to theuserValidatorStakeStartTime(https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/lib/PlumeRewardLogic.sol#L225-L231).The function uses
getDistinctTimestamps()to process reward segments. The segment before token addition hasrate = 0(correct), but the segment from token addition until validator reactivation usesinitialRate(incorrect). (https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/lib/PlumeRewardLogic.sol#L294-L358). As a result, Alice will receive rewards while the validator was inactive.
Was this helpful?