51324 sc high rounding in commission accounting burns delegator rewards
Submitted on Aug 1st 2025 at 17:33:58 UTC by @Rhaydden for Attackathon | Plume Network
Report ID: #51324
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/lib/PlumeRewardLogic.sol
Impacts: Theft of unclaimed yield
Description
Brief/Intro
The staking reward logic credits validator commission using floor division but debits each delegator using ceiling division. When ≥2 stakers exist, the sum of per-user charges always exceeds the amount credited to the validator, and the excess “dust” is lost forever. Over time this silently steals part of the users’ unclaimed yield and breaks the protocol's accounting.
In the validator path, updateRewardPerTokenForValidator floors the aggregate commission so the validator is never over credited:
commissionDeltaForValidator =
(grossRewardForValidatorThisSegment * commissionRate)
/* FLOOR */ / REWARD_PRECISION;In the user path, _calculateRewardsCore ceilings each delegator’s commission so the validator is never underpaid:
commissionForThisSegment =
_ceilDiv(grossRewardForSegment * commissionRate,
/* CEIL */ REWARD_PRECISION);These two choices together break conservation of tokens when more than one staker exists:
ceil(user₁) + ceil(user₂) + … + ceil(userₙ) ≥ floor(user₁+user₂+…+userₙ)
The excess created by the per-user ceilings is not recorded anywhere. Comments in the code hint at rounding intent, but the contract never reconciles the difference.
Vulnerability Details
In PlumeRewardLogic.sol:
// Validator accrual – floor (line ~185)
uint256 commissionDeltaForValidator =
(grossRewardForValidatorThisSegment * commissionRate)
/* FLOOR */ / REWARD_PRECISION;
// Delegator charge – ceil (line ~348)
uint256 commissionForThisSegment =
_ceilDiv(grossRewardForSegment * effectiveCommissionRate,
/* CEIL */ REWARD_PRECISION);Mathematically:
Σ ceil(user_i × r / P) ≥ floor(Σ user_i × r / P)
A strict inequality occurs whenever fractions exist, producing up to (n − 1) wei of “dust” per segment (n = #stakers). This dust is:
Removed from delegators’ gross rewards;
Not added to
validatorAccruedCommission;Not reflected in
totalClaimableByToken;
Because reward distribution runs every epoch for every validator, the loss compounds and eventually makes:
contract.balance > totalClaimableByToken + Σ validatorAccruedCommission
breaking conservation of tokens.
Impact Details
High (Theft of unclaimed yield)
Delegators receive less reward than they earned.
Validator cannot withdraw the difference, so funds accumulate as unclaimable dust.
Over many epochs with thousands of stakers, the loss can become material, effectively burning user yield.
Fix
Simpler fix: use floor everywhere (i.e., floor per-user commission as well).
Alternative: keep per-user ceil but credit the rounding dust to the validator (i.e., reconcile per-segment rounding delta and add it to
validatorAccruedCommissionortotalClaimableByToken).
References
https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/lib/PlumeRewardLogic.sol#L185-L187
Proof of Concept
Logs
Was this helpful?