Skip to content

fix(ValidatorFacet): seed initial commission checkpoint in addValidator - #249

Open
0xrlawrence wants to merge 1 commit into
plumenetwork:mainfrom
0xrlawrence:fix/validator-initial-commission-checkpoint
Open

0xrlawrence wants to merge 1 commit into
plumenetwork:mainfrom
0xrlawrence:fix/validator-initial-commission-checkpoint

Conversation

@0xrlawrence

Copy link
Copy Markdown

Summary

ValidatorFacet.addValidator sets validator.commission and seeds reward-rate checkpoints, but never creates an initial commission-rate checkpoint. So validatorCommissionCheckpoints[validatorId] stays empty until the first setValidatorCommission.

While that array is empty, PlumeRewardLogic.getEffectiveCommissionRateAt(validatorId, ts) falls back to the mutable live validators[validatorId].commission for every historical timestamp:

// PlumeRewardLogic.getEffectiveCommissionRateAt
if (chkCount > 0) { ... }
// Fallback to the current commission rate stored directly in ValidatorInfo
return $.validators[validatorId].commission;

Why that's a problem

setValidatorCommission settles the validator's accrued commission at the old rate (via _settleCommissionForValidatorUpToNow) before overwriting validator.commission and pushing the first checkpoint. But a staker who claims later reconstructs that same pre-checkpoint window through the fallback, which now returns the new rate.

So the first commission change makes the two legs disagree over the already-elapsed window:

  • validator-accrual leg: booked at the old rate C0
  • staker-charge leg: re-priced at the new rate C1

On a decrease (C0 > C1), the staker is charged less than the validator was credited for the same gross, and the shared PlumeStakingRewardTreasury is left owing ~ (C0 − C1) × gross more than was funded for that window. (This is the same getEffectiveCommissionRateAt consistency surface as the previously-reported duplicate-timestamp checkpoint issue — a different trigger reaching the same inconsistency.)

Fix

Seed a commission-rate checkpoint at genesis inside addValidator, so historical commission lookups are always well-defined and never read the mutable live value:

PlumeRewardLogic.createCommissionRateCheckpoint($, validatorId, commission);

createCommissionRateCheckpoint already de-dupes same-block entries and respects maxCommissionCheckpoints, and a freshly-added validator has no stakers/accrual yet, so seeding is safe.

Testing

  • forge test --via-ir — full PlumeStakingDiamond suite passes with no regressions.
  • Added-locally repro (happy to include as a regression test if you'd like): with a validator at 5% commission and an elapsed unsettled window, lowering commission 5%→0% previously made earned(staker) + getAccruedCommission(validator) exceed the gross funded for the window by ~the full commission slice; with this change the pre-checkpoint window is no longer re-priced and the two legs sum back to the gross.

🤖 Generated with Claude Code

addValidator sets `validator.commission` but never creates a commission-rate
checkpoint, so `validatorCommissionCheckpoints[validatorId]` is empty until the
first `setValidatorCommission`. While it is empty,
`PlumeRewardLogic.getEffectiveCommissionRateAt` falls back to the *mutable live*
`validator.commission` for ALL historical timestamps.

As a result the first commission change re-prices the pre-checkpoint window: the
validator-accrual leg was settled at the old rate (setValidatorCommission settles
before overwriting), but a staker claiming afterwards reconstructs the same window
at the new rate via the fallback. On a commission decrease the two legs disagree and
the shared reward treasury is left owing more than was funded for that window.

Seed a commission checkpoint at genesis so historical lookups are always
well-defined and never read the mutable live value. Hardens the same
getEffectiveCommissionRateAt path as the previously-reported duplicate-timestamp
checkpoint issue. Verified against the existing diamond test suite (no regressions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@octane-security-app

Copy link
Copy Markdown

Summary by Octane

Adds a call to PlumeRewardLogic.createCommissionRateCheckpoint in ValidatorFacet.addValidator, writing an initial commission checkpoint at validator creation time.

Changes

  • plume/src/facets/ValidatorFacet.sol (modified) - In addValidator, after the reward-rate checkpoint loop and before emit ValidatorAdded, seeds a commission checkpoint with the commission argument, plus a comment explaining why.

Worth a look

  • plume/src/facets/ValidatorFacet.sol - addValidator now writes an entry to validatorCommissionCheckpoints[validatorId] at creation; code reading that array (e.g. getEffectiveCommissionRateAt) now sees a non-empty history.

🔗 Commit Hash: 626dedc

@greptile-apps

greptile-apps Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Critical impact] Adds commission checkpoint initialization to validator creation.

The change appears safe to merge, with a non-blocking request to commit the regression test.

Findings

  1. P2 First-change fix lacks a test ▶

Summary

Seeds the initial commission rate in addValidator, so later claims use the original rate for rewards earned before the first commission change.

  • New validators keep their starting commission for historical lookups.

Reviews (1) · Last reviewed commit: "fix(ValidatorFacet): seed initial commis..." · Reviewed by Greptile

// so the first commission change re-prices the pre-checkpoint window for stakers at the NEW
// rate (via the fallback) while the validator-accrual leg already settled it at the OLD rate —
// leaving the two legs inconsistent. Seeding here keeps every historical window well-defined.
PlumeRewardLogic.createCommissionRateCheckpoint($, validatorId, commission);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 First-change fix lacks a test

The new checkpoint in addValidator has no committed regression test. testCommissionRateChange_NonRetroactive calls setValidatorCommission before time advances, so it already creates the history this change is meant to supply. That test can pass even if this fix is removed.

Add a test that creates a validator at 5%, stakes without first calling setValidatorCommission, advances time, lowers commission to 0%, then claims. Check that earlier rewards keep the 5% charge and that staker rewards plus validator commission equal the gross rewards, allowing for rounding. The test should fail without this call and pass with it.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@octane-security-app

Copy link
Copy Markdown

Overview

Vulnerabilities found: 10                                                                                
Severity breakdown: 2 High, 4 Medium, 2 Low, 2 Informational
Warnings found: 3                                                                                

Detailed findings

plume/src/facets/ValidatorFacet.sol

  • Missing commission genesis checkpoint/backfill for legacy validators in ValidatorFacet.setValidatorCommission causes protocol treasury loss and user mispricing. See more
  • Persistent slash votes across validator inactivity/reactivation in ValidatorFacet enable immediate slashing without re-affirmation, causing delegator principal loss. See more
  • Active-token gating for commission in ValidatorFacet after reward-token removal causes validator commission payout DoS. See more
  • Inconsistent equality in commission-claim readiness vs slashing check in ValidatorFacet.finalizeCommissionClaim causes ordering-dependent treasury payout. See more
  • Unbounded full-set iteration and unnecessary per-voter deletion in ValidatorFacet slashing flow causes out-of-gas and prevents slashing. See more
  • Unbounded validator-specific reward-rate checkpoints in ValidatorFacet.setValidatorStatus transitions causes gas-exhaustion DoS of claim/unstake. See more
  • Unbounded per-token initialization in ValidatorFacet with large rewardTokens causes validator-management DoS via out-of-gas. See more
  • Unbounded user-association scan in ValidatorFacet.getUserValidators causes off-chain read DoS/UX degradation. See more
  • Unbounded, non-paginated enumeration in ValidatorFacet.getValidatorsList causes validator list availability failure. See more

plume/src/lib/PlumeRewardLogic.sol

  • Clearing inactivity cap on reactivation in ValidatorFacet.setValidatorStatus causes overpayment of rewards from treasury. See more

Warnings

plume/src/facets/ValidatorFacet.sol

  • Permissionless forceSettleValidatorCommission in ValidatorFacet causes validator commission under-credit via rounding. See more

plume/src/lib/PlumeErrors.sol

  • Commission checkpoint spamming in ValidatorFacet.setValidatorCommission causes user reward loss via per-segment rounding. See more

plume/src/lib/PlumeRewardLogic.sol

  • Commission checkpoint cap exhaustion in ValidatorFacet.setValidatorCommission causes pinned commission and staker yield loss. See more

🔗 Commit Hash: 626dedc
🛡️ Octane Dashboard: All vulnerabilities

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant