52409 sc high asymmetric commission rounding creates systematic accounting drift
Submitted on Aug 10th 2025 at 13:41:05 UTC by @spongebob for Attackathon | Plume Network
Report ID: #52409
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/facets/StakingFacet.sol
Impacts:
Contract fails to deliver promised returns, but doesn't lose value
Description
Brief/Intro
PlumeStaking calculates commission two different ways: users get charged with ceiling division, while validators accrue commission with floor division.
The mismatch creates a small rounding gap on every calc. That gap isn’t tracked anywhere, so over time the system’s books drift: sum(user_payouts) + sum(validator_commission) < sum(emitted_rewards).
No one can steal funds and individual balances look fine, but the totals won’t reconcile and value effectively leaks from accounting.
Vulnerability Details
User commission (ceil)
When users claim, _calculateRewardsCore() uses ceiling division to compute commission:
https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/lib/PlumeRewardLogic.sol#L346-L348
The helper _ceilDiv explicitly rounds up so users always pay at least as much commission as validators should get:
https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/lib/PlumeRewardLogic.sol#L421-L433
Validator commission (floor)
Validator accrual in updateRewardPerTokenForValidator() uses normal (floor) division. _ceilDiv’s comment even calls out this asymmetry: it’s designed so “sum of user commissions ≥ validator accrued commission.”
The extra few wei created by ceiling division during user claims never gets recorded as anyone’s balance. Users are paid net (gross − ceiling commission), validators later claim what they accrued via floor division, and the difference disappears.
Because rewards are processed across time segments between checkpoints, each segment can introduce another tiny delta.
https://github.com/immunefi-team/attackathon-plume-network/blob/580cc6d61b08a728bd98f11b9a2140b84f41c802/plume/src/lib/PlumeRewardLogic.sol#L340-L356
Lots of micro-claims or frequent rate changes mean lots of segments—and more drift. This is very subtle because each piece works alone.
Users don’t underpay, validators don’t over-accrue, and there are no negative balances.
The problem only shows up when you add everything across the system and compare to total emitted rewards.
Impact Details
I consider a low severity as the contract delivers per-user/per-validator amounts, but total distribution won’t match emissions.
Proof of Concept
Deploy and setup
Deploy the PlumeStaking system with a test validator and reward token.
Set a commission rate for the validator (e.g., 33% to maximize rounding effects).
Add a reward token with a specific rate that will create fractional commission amounts.
Fund the treasury with sufficient reward tokens.
Create a minimal-stake scenario
Stake a small amount (e.g., 1 wei) to create minimal rewards that amplify rounding effects.
Set reward rate to a value that creates fractional commission (e.g., a rate that results in commission like 2.7 tokens).
Wait for a short time period (e.g., 1 second) to accrue minimal rewards.
Manual expected calculation
Calculate expected values manually:
Gross reward = stake × rate × time
Commission (ceiling) = ceil(gross_reward × commission_rate)
Commission (floor) = floor(gross_reward × commission_rate)
Rounding delta = ceiling_commission - floor_commission
Expected net user reward = gross_reward - ceiling_commission
Final observation
Confirm treasury balance shows more tokens than the sum of all distributed rewards.
Conclusion: Each claim cycle will show a small amount (the rounding delta) that disappears from accounting. The cumulative effect grows with each transaction. Treasury will retain slightly more tokens than the sum of user payouts + validator commissions and the equation emitted_rewards ≠ Σ(user_payouts) + Σ(validator_commission) will be violated.
Was this helpful?