51455 sc low inflated earned ui rewards when validator stake is zero due to missing totalstaked guard in view logic

Submitted on Aug 3rd 2025 at 00:12:53 UTC by @Rhaydden for Attackathon | Plume Network

  • Report ID: #51455

  • Report Type: Smart Contract

  • Report severity: Low

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

  • Impacts:

    • Contract fails to deliver promised returns, but doesn't lose value

Description

Brief/Intro

calculateRewardsWithCheckpointsView keeps accruing reward-per-token during any period where a validator’s totalStaked is zero, whereas the state-changing path (updateRewardPerTokenForValidator) correctly stops accrual. The mismatch causes earned() and all UI displays to show rewards users can never claim.

Vulnerability Details

In PlumeRewardLogic.sol (view path):

// VIEW path
uint256 rptIncreaseInSegment = segmentDuration * rateForSegment;
simulatedCumulativeRPT += rptIncreaseInSegment;

The loop runs for every time segment with a non zero reward rate. There is no check that the validator actually has stake during the segment.

Opposite of what the state-changing path does:

// STATE-CHANGING path
if (totalStaked > 0) {
    uint256 rewardPerTokenIncrease = timeSinceLastUpdate * effectiveRewardRate;
    $.validatorRewardPerTokenCumulative[validatorId][token] += rewardPerTokenIncrease;
}

When totalStaked == 0, the cumulative value on chain is frozen and only validatorLastUpdateTimes is advanced. Because the view function starts its simulation from that stored timestamp, it integrates reward rate across the zero-stake window, inflating simulatedCumulativeRPT. _calculateRewardsCore then computes an overstated user delta, so earned() differs from the value that will be paid by claim().

Conditions required for the discrepancy:

1

Validator is active but has momentarily zero stake (all delegators withdrew).

2

Reward rate for the token > 0.

3

No transaction hits updateRewardPerTokenForValidator during that interval (common when there is no stake).

Every second that passes, the gap between displayed and claimable rewards widens.

Impact Details

Low – contract fails to deliver promised returns, but doesn't lose value. Users see larger rewards than they can claim.

Fix

Add a stake guard identical to the state-changing path:

if ($.validatorTotalStaked[validatorId] > 0 && rateForSegment > 0) {
    simulatedCumulativeRPT += segmentDuration * rateForSegment;
}

References

https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/lib/PlumeRewardLogic.sol#L907-L909

Proof of Concept

1

Add validator V with reward rate R > 0.

2

Delegate 100 tokens to V. Wait 1 hour; call earned(user, V) -> returns E1.

3

User withdraws entire stake (totalStake becomes 0). Do nothing else for 2 hours.

4

Call earned(user, V) again -> returns E2 where E2 - E1 ≈ 2 h * R.

5

Now call claim(user, V); the user only receives E1 (plus small rounding), proving the extra 2 hours of rewards were never claimable.

Was this helpful?