52996 sc high users can claim rewards for newly added reward tokens even when the validator they staked for was inactive during some time interval

Submitted on Aug 14th 2025 at 15:41:04 UTC by @swarun for Attackathon | Plume Network

  • Report ID: #52996

  • Report Type: Smart Contract

  • Report severity: High

  • Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/facets/ValidatorFacet.sol

  • Impacts: Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield

Description

Brief / Intro

Suppose a validator was inactive for some time interval and during that time interval a new token got added. Users shouldn't receive rewards when the validator is inactive, but they will receive rewards when a new reward token was added during the inactive interval. This causes users to receive rewards for time intervals where there was no accumulation of rewards, which should not happen.

Vulnerability Details

When a new reward token is added while a validator is inactive, the logic creates reward checkpoints for all validators (including the inactive one) at the addition time. Later, when the validator is reactivated, a new checkpoint is created again at the activation time. Because the reward calculation logic splits time into segments based on checkpoints, this can result in a segment that covers the inactive interval with a non-zero effective reward rate (from the token addition checkpoint), producing rewards to stakers even though the validator was inactive during that time.

Impact Details

Users receive more rewards than they are eligible for.

References

https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/facets/ValidatorFacet.sol#L252

Proof of Concept

1

Setup: validator made inactive while users are staked

Suppose the admin sets a validator inactive (without slashing) while users remain staked. The function used:

function setValidatorStatus(
    uint16 validatorId,
    bool newActiveStatus
) external onlyRole(PlumeRoles.ADMIN_ROLE) _validateValidatorExists(validatorId) {
    PlumeStakingStorage.Layout storage $ = PlumeStakingStorage.layout();
    PlumeStakingStorage.ValidatorInfo storage validator = $.validators[validatorId];

    // Prevent activating an already slashed validator through this function
    if (newActiveStatus && validator.slashed) {
        revert ValidatorAlreadySlashed(validatorId);
    }

    bool currentStatus = validator.active;

    // If status is actually changing
    if (currentStatus != newActiveStatus) {
        address[] memory rewardTokens = $.rewardTokens;

        // If going INACTIVE: settle validator commission and record timestamp
        if (!newActiveStatus && currentStatus) {
            // Settle commission for validator using current rates
            PlumeRewardLogic._settleCommissionForValidatorUpToNow($, validatorId);

            // Record when the validator became inactive (reuse slashedAtTimestamp field)
            // This allows existing reward logic to cap rewards at this timestamp
            validator.slashedAtTimestamp = block.timestamp;

            // 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);
            }

            // NOTE: User rewards will be settled naturally when users interact
            // (stake, unstake, claim, etc.) due to the timestamp cap in reward logic
        }

        // Update the status
        validator.active = newActiveStatus;

        // If going ACTIVE: reset timestamps and clear the timestamp cap
        if (newActiveStatus && !currentStatus) {
            // Create a new checkpoint to restore the reward rate, signaling activity resumes
            for (uint256 i = 0; i < rewardTokens.length; i++) {
                address token = rewardTokens[i];
                $.validatorLastUpdateTimes[validatorId][token] = block.timestamp;
                uint256 currentGlobalRate = $.rewardRates[token];
                PlumeRewardLogic.createRewardRateCheckpoint($, token, validatorId, currentGlobalRate);
            }
            // Clear the timestamp since validator is active again (unless actually slashed)
            if (!validator.slashed) {
                validator.slashedAtTimestamp = 0;
            }
        }
    }

    emit ValidatorStatusUpdated(validatorId, newActiveStatus, validator.slashed);
}
  • Validator becomes inactive at t = 0 with users still staked.

  • Validator remains inactive until t = 2 days.

2

Add a new reward token during inactivity

At t = 1 day (while the validator is inactive), a new reward token is added via:

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);
    }
}
  • This creates a reward-rate checkpoint for every validator (including the inactive one) at t = 1 day with a non-zero rate.

3

Validator reactivated and another checkpoint created

At t = 2 days the admin reactivates the validator. The active transition creates another checkpoint for all reward tokens at t = 2 days and resets timestamp caps:

// If going ACTIVE: reset timestamps and clear the timestamp cap
if (newActiveStatus && !currentStatus) {
    // Create a new checkpoint to restore the reward rate, signaling activity resumes
    for (uint256 i = 0; i < rewardTokens.length; i++) {
        address token = rewardTokens[i];
        $.validatorLastUpdateTimes[validatorId][token] = block.timestamp;
        uint256 currentGlobalRate = $.rewardRates[token];
        PlumeRewardLogic.createRewardRateCheckpoint($, token, validatorId, currentGlobalRate);
    }
    // Clear the timestamp since validator is active again (unless actually slashed)
    if (!validator.slashed) {
        validator.slashedAtTimestamp = 0;
    }
}
  • Now the newly added token has two checkpoints for this validator: one at t = 1 day (added) and one at t = 2 days (reactivation).

4

Reward accumulation logic and how it miscomputes for the inactive interval

Important notes:

  • While the validator was inactive, the system prevents updating $.validatorRewardPerTokenCumulative[validatorId][token] due to timestamp caps (no reward accumulation while inactive).

  • Later, after reactivation and time passing, the cumulative RPT is updated, and a user triggers a claim.

Claim flow (relevant parts):

function claim(address token, uint16 validatorId) external nonReentrant returns (uint256) {
    _validateTokenForClaim(token, msg.sender);
    _validateValidatorForClaim(validatorId);

    uint256 reward = _processValidatorRewards(msg.sender, validatorId, token);

    if (reward > 0) {
        _finalizeRewardClaim(token, reward, msg.sender);
    }

    PlumeStakingStorage.Layout storage $ = PlumeStakingStorage.layout();
    PlumeRewardLogic.clearPendingRewardsFlagIfEmpty($, msg.sender, validatorId);
    PlumeValidatorLogic.removeStakerFromValidator($, msg.sender, validatorId);

    return reward;
}

Main reward processing:

function _processValidatorRewards(
    address user,
    uint16 validatorId,
    address token
) internal returns (uint256 reward) {
    PlumeStakingStorage.Layout storage $ = PlumeStakingStorage.layout();

    // Settle pending rewards for this specific user/validator/token combination.
    // This updates both $.userRewards and $.totalClaimableByToken consistently.
    PlumeRewardLogic.updateRewardsForValidatorAndToken($, user, validatorId, token);

    // Now that rewards are settled, the full claimable amount is in storage.
    reward = $.userRewards[user][validatorId][token];

    if (reward > 0) {
        _updateUserRewardState(user, validatorId, token);
        emit RewardClaimedFromValidator(user, token, validatorId, reward);
    }
}
  • updateRewardsForValidatorAndToken ultimately calls core calculation logic which splits the period since the user's last update into segments based on distinct checkpoint timestamps (rate or commission changes).

5

How the segments cause incorrect rewards

The core loop computing rewards iterates segments between distinct timestamps (checkpoints). Simplified snippet:

for (uint256 k = 0; k < distinctTimestamps.length - 1; ++k) {
    uint256 segmentStartTime = distinctTimestamps[k];
    uint256 segmentEndTime = distinctTimestamps[k + 1];

    if (segmentEndTime <= segmentStartTime) {
        continue;
    }

    uint256 rptAtSegmentStart;
    if (k == 0) {
        rptAtSegmentStart = lastUserPaidCumulativeRewardPerToken;
    } else {
        rptAtSegmentStart = rptTracker;
    }

    PlumeStakingStorage.RateCheckpoint memory rewardRateInfoForSegment =
        getEffectiveRewardRateAt($, token, validatorId, segmentStartTime); // Rate at START of segment
    uint256 effectiveRewardRate = rewardRateInfoForSegment.rate;
    uint256 segmentDuration = segmentEndTime - segmentStartTime;

    uint256 rptIncreaseInSegment = 0;
    if (effectiveRewardRate > 0 && segmentDuration > 0) {
        rptIncreaseInSegment = segmentDuration * effectiveRewardRate;
    }

    uint256 rptAtSegmentEnd = rptAtSegmentStart + rptIncreaseInSegment;
    uint256 rewardPerTokenDeltaForUserInSegment = rptAtSegmentEnd - rptAtSegmentStart;

    if (rewardPerTokenDeltaForUserInSegment > 0 && userStakedAmount > 0) {
        uint256 grossRewardForSegment =
            (userStakedAmount * rewardPerTokenDeltaForUserInSegment) / PlumeStakingStorage.REWARD_PRECISION;

        uint256 effectiveCommissionRate = getEffectiveCommissionRateAt($, validatorId, segmentStartTime);
        uint256 commissionForThisSegment =
            _ceilDiv(grossRewardForSegment * effectiveCommissionRate, PlumeStakingStorage.REWARD_PRECISION);

        if (grossRewardForSegment >= commissionForThisSegment) {
            totalUserRewardDelta += (grossRewardForSegment - commissionForThisSegment);
        }

        totalCommissionAmountDelta += commissionForThisSegment;
    }
    rptTracker = rptAtSegmentEnd;
}
  • In the scenario: distinctTimestamps includes t = 1 day (token addition) and t = 2 days (validator reactivated).

  • For the segment [1 day, 2 day], getEffectiveRewardRateAt at segmentStartTime (1 day) returns a non-zero rate (token was added).

  • segmentDuration = 1 day, userStakedAmount > 0, so gross reward is calculated and added to the user's rewards even though the validator was inactive during that segment.

  • This yields extraneous rewards for the inactive interval.

Was this helpful?