52588 sc high retroactive reward accrual for newly added tokens when validator was inactive
Submitted on Aug 11th 2025 at 19:47:43 UTC by @light279 for Attackathon | Plume Network
Report ID: #52588
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
Protocol insolvency
Description
Brief / Intro
When a validator is turned inactive the system records a timestamp to cap rewards. If a new reward token is added while the validator is inactive, the contract creates reward checkpoints for all validators (including the inactive one). After the validator is reactivated, users who had already claimed rewards up to the inactive timestamp can later claim the newly added token’s rewards — and the reward calculation mistakenly includes the period when the validator was inactive and the user should not have earned rewards.
In short: users may receive retroactive rewards for a token that was added while the validator was inactive.
Vulnerability Details
The issue is best understood as a sequence of events:
User claims token X after reactivation
User claims token
Xafter reactivation (timeT2, T2 > T1).Functional flow:
RewardsFacet::claim(address token,uint16 validatorId)=>RewardsFacet::_processValidatorRewards=>PlumeRewardLogic.updateRewardsForValidatorAndToken=>PlumeRewardLogic::calculateRewardsWithCheckpoints=>PlumeRewardLogic::_calculateRewardsCore.The code updates the validator cumulative RPT for token
X(from reactivation →T2), then calls_calculateRewardsCore.Because the user never had any per-token paid-pointer for
X(token did not exist at timeT),lastUserRewardUpdateTimeis set to the stake start of the user — and the distinct timestamp logic includes the token-addition / inactive period window (T' → T1).
Root causes
RewardsFacet::addRewardTokencreates checkpoints for inactive validators without taking inactive status into account.
Relevant code excerpt:
function addRewardToken(
address token,
uint256 initialRate,
uint256 maxRate
) external onlyRole(PlumeRoles.REWARD_MANAGER_ROLE) {
PlumeStakingStorage.Layout storage $ = PlumeStakingStorage.layout();
if (token == address(0)) {
revert ZeroAddress("token");
}
if ($.isRewardToken[token]) {
revert TokenAlreadyExists();
}
if (initialRate > maxRate) {
revert RewardRateExceedsMax();
}
// Prevent re-adding a token in the same block it was removed to avoid checkpoint overwrites.
if ($.tokenRemovalTimestamps[token] == block.timestamp) {
revert CannotReAddTokenInSameBlock(token);
}
// Add to historical record if it's the first time seeing this token.
if (!$.isHistoricalRewardToken[token]) {
$.isHistoricalRewardToken[token] = true;
$.historicalRewardTokens.push(token);
}
uint256 additionTimestamp = block.timestamp;
// Clear any previous removal timestamp to allow re-adding
$.tokenRemovalTimestamps[token] = 0;
$.rewardTokens.push(token);
$.isRewardToken[token] = true;
$.maxRewardRates[token] = maxRate;
$.rewardRates[token] = initialRate; // Set initial global rate
$.tokenAdditionTimestamps[token] = additionTimestamp;
// 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
@> );
@> }
emit RewardTokenAdded(token);
if (maxRate > 0) {
emit MaxRewardRateUpdated(token, maxRate);
}
}Impact Details
Unintended token distribution / inflation — users can receive rewards for periods where the validator was inactive.
Proof of Concept
The PoC is shown as a step sequence:
T2 — user claims tokenX after reactivation
Ucallsclaim(tokenX, V)(or a function that processes rewards fortokenX).updateRewardsForValidatorAndToken→calculateRewardsWithCheckpoints→updateRewardPerTokenForValidatorupdates cumulative RPT from activation →T2._calculateRewardsCorecomputeslastUserRewardUpdateTimefortokenX: sinceUnever had a per-token paid timestamp fortokenX(token was added after their earlier claim), the code uses the stake start time of the user and collects distinct timestamps includingT' → T1.The resulting
totalUserRewardDeltaincludes reward segments coveringT' → T1(the period when validator was inactive) — rewards that should have been capped/zero.
Was this helpful?