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:
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:
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:
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 validatorAccruedCommission or totalClaimableByToken).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../src/lib/PlumeRewardLogic.sol";
import "../src/lib/PlumeStakingStorage.sol";
/**
* @title CommissionRoundingBugTest
* @notice Test to demonstrate the rounding mismatch between validator commission (floor division)
* and user commission charges (ceiling division)
*/
contract CommissionRoundingBugTest is Test {
// Constants and storage
uint256 constant REWARD_PRECISION = PlumeStakingStorage.REWARD_PRECISION;
uint256 constant COMMISSION_RATE = REWARD_PRECISION / 10; // 10% commission
// Simulated users and validators
address validatorAdmin = address(0x1);
address alice = address(0x2);
address bob = address(0x3);
address carol = address(0x4);
uint16 validatorId = 1;
address rewardToken = address(0x10);
// Track balances for verification
uint256 totalGrossRewards;
uint256 totalValidatorCommission;
uint256 totalUserNetRewards;
uint256 expectedLostDust;
function setUp() public {
// Initial setup - no specific setup needed for this test
}
// Helper to simulate validator commission calculation using floor division
function calculateValidatorCommission(uint256 grossAmount) public view returns (uint256) {
// Simulates how updateRewardPerTokenForValidator calculates commission
return (grossAmount * COMMISSION_RATE) / REWARD_PRECISION;
}
// Helper to simulate user commission calculation using ceiling division
function calculateUserCommission(uint256 grossAmount) public view returns (uint256) {
// Simulates how _calculateRewardsCore calculates commission
if (grossAmount == 0) return 0;
return (grossAmount * COMMISSION_RATE + REWARD_PRECISION - 1) / REWARD_PRECISION;
}
function testCommissionRoundingMismatch() public {
// SCENARIO: We'll create rewards for 3 users where each reward amount
// produces a fractional commission amount that gets rounded differently
// A deliberately chosen value to demonstrate the rounding issue
uint256 baseAmount = REWARD_PRECISION / 9; // This produces fractional commission
// User rewards (each slightly different to show consistent loss pattern)
uint256 aliceGross = baseAmount;
uint256 bobGross = baseAmount + 1;
uint256 carolGross = baseAmount - 1;
totalGrossRewards = aliceGross + bobGross + carolGross;
console.log("Total gross rewards:", totalGrossRewards);
// PART 1: Calculate validator commission using floor division (as in the contract)
uint256 aggregateValidatorCommission = calculateValidatorCommission(totalGrossRewards);
totalValidatorCommission = aggregateValidatorCommission;
console.log("Validator commission (floor):", aggregateValidatorCommission);
// PART 2: Calculate per-user commission using ceiling division (as in the contract)
uint256 aliceCommission = calculateUserCommission(aliceGross);
uint256 bobCommission = calculateUserCommission(bobGross);
uint256 carolCommission = calculateUserCommission(carolGross);
uint256 totalUserCommissionCharges = aliceCommission + bobCommission + carolCommission;
console.log("User 1 commission (ceil):", aliceCommission);
console.log("User 2 commission (ceil):", bobCommission);
console.log("User 3 commission (ceil):", carolCommission);
console.log("Total user commission charges:", totalUserCommissionCharges);
// PART 3: Calculate net rewards for users
uint256 aliceNet = aliceGross - aliceCommission;
uint256 bobNet = bobGross - bobCommission;
uint256 carolNet = carolGross - carolCommission;
totalUserNetRewards = aliceNet + bobNet + carolNet;
console.log("Total net user rewards:", totalUserNetRewards);
// PART 4: Calculate accounting discrepancy
expectedLostDust = totalUserCommissionCharges - aggregateValidatorCommission;
console.log("Expected 'dust' loss:", expectedLostDust);
// PART 5: Verify the accounting issue exists
uint256 actualTotalTokens = totalValidatorCommission + totalUserNetRewards;
console.log("Tokens accounted for:", actualTotalTokens);
console.log("Original total tokens:", totalGrossRewards);
// The key assertion: there's a discrepancy due to the rounding mismatch
assertTrue(
actualTotalTokens < totalGrossRewards,
"Accounting error: tokens have been lost"
);
// Verify the exact amount of dust loss
assertEq(
totalGrossRewards - actualTotalTokens,
expectedLostDust,
"Lost dust should match the expected amount"
);
// Demonstrate how many users can result in even more dust loss
console.log("");
console.log("DUST LOSS SCALING DEMONSTRATION");
uint256[] memory userCounts = new uint256[](3);
userCounts[0] = 10;
userCounts[1] = 100;
userCounts[2] = 1000;
for (uint i = 0; i < userCounts.length; i++) {
uint256 userCount = userCounts[i];
uint256 singleUserGross = baseAmount;
uint256 totalGross = singleUserGross * userCount;
uint256 validatorCommTotal = calculateValidatorCommission(totalGross);
uint256 userCommTotal = calculateUserCommission(singleUserGross) * userCount;
uint256 dustLoss = userCommTotal - validatorCommTotal;
console.log("With", userCount, "users: dust loss =", dustLoss);
}
// PART 6: Demonstrate one possible fix (using floor division everywhere)
console.log("");
console.log("SOLUTION DEMONSTRATION: Floor division everywhere");
uint256 fixedAliceCommission = (aliceGross * COMMISSION_RATE) / REWARD_PRECISION; // floor
uint256 fixedBobCommission = (bobGross * COMMISSION_RATE) / REWARD_PRECISION; // floor
uint256 fixedCarolCommission = (carolGross * COMMISSION_RATE) / REWARD_PRECISION; // floor
uint256 fixedTotalUserCommission = fixedAliceCommission + fixedBobCommission + fixedCarolCommission;
uint256 fixedUserNetRewards = aliceGross - fixedAliceCommission + bobGross - fixedBobCommission + carolGross - fixedCarolCommission;
console.log("Fixed validator commission:", aggregateValidatorCommission);
console.log("Fixed total user commission charges:", fixedTotalUserCommission);
console.log("Fixed net user rewards:", fixedUserNetRewards);
console.log("Total tokens accounted for:", aggregateValidatorCommission + fixedUserNetRewards);
console.log("Original total tokens:", totalGrossRewards);
// Verify the fix restores proper accounting
assertEq(
aggregateValidatorCommission + fixedUserNetRewards,
totalGrossRewards,
"Fixed solution should preserve all tokens"
);
}
}
Ran 1 test for test/CommissionRoundingBug.t.sol:CommissionRoundingBugTest
[PASS] testCommissionRoundingMismatch() (gas: 110332)
Logs:
Total gross rewards: 333333333333333333
Validator commission (floor): 33333333333333333
User 1 commission (ceil): 11111111111111112
User 2 commission (ceil): 11111111111111112
User 3 commission (ceil): 11111111111111111
Total user commission charges: 33333333333333335
Total net user rewards: 299999999999999998
Expected 'dust' loss: 2
Tokens accounted for: 333333333333333331
Original total tokens: 333333333333333333
DUST LOSS SCALING DEMONSTRATION
With 10 users: dust loss = 9
With 100 users: dust loss = 90
With 1000 users: dust loss = 900
SOLUTION DEMONSTRATION: Floor division everywhere
Fixed validator commission: 33333333333333333
Fixed total user commission charges: 33333333333333333
Fixed net user rewards: 300000000000000000
Total tokens accounted for: 333333333333333333
Original total tokens: 333333333333333333
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.22ms (3.46ms CPU time)