Brilliant Menthol Jaguar
High
This report outlines a critical Reward Precision Loss vulnerability in the smart contract’s reward distribution mechanism. When extremely small rewards are computed for very large staked amounts, integer arithmetic in Solidity causes a non-trivial loss of precision. The root issue is that multiplying a tiny reward by a large stake, then dividing by the total stake, can truncate fractional components. Although the deviation may appear insignificant in a single payout, these inaccuracies can accumulate over time, potentially leading to substantial discrepancies in rewards and undermining user confidence.
-
Mechanics of the Vulnerability
- Integer Arithmetic Truncation: In Solidity, operations on
uint256
discard any fractional components. Therefore, if a small reward (e.g.,1 GWEI
) is multiplied by a large stake (e.g.,10,000,000 ETH
) and then divided by the total stake, the result can be significantly lower than the intended reward. - No Native Floating-Point: Solidity lacks floating-point math, so fractional parts are lost during multiplication/division unless the developer implements a fixed-point or scaling approach.
- Integer Arithmetic Truncation: In Solidity, operations on
-
Where It Occurs in the Contract
function _scaledUnclaimedReward(Deposit storage deposit) internal view virtual returns (uint256) { return deposit.scaledUnclaimedRewardCheckpoint + (deposit.earningPower * (rewardPerTokenAccumulated() - deposit.rewardPerTokenCheckpoint)); }
- Here,
deposit.earningPower
(auint96
) is multiplied by the difference between two large integers (rewardPerTokenAccumulated()
anddeposit.rewardPerTokenCheckpoint
). Because this is integer arithmetic, any fractional remainder is discarded. - If
earningPower
is very large or if the difference in reward accumulators is relatively small (e.g., due to a tiny reward), the truncated fractions can accumulate to a noticeable shortfall over multiple distributions.
b.
rewardPerTokenAccumulated()
function rewardPerTokenAccumulated() public view virtual returns (uint256) { if (totalEarningPower == 0) return rewardPerTokenAccumulatedCheckpoint; return rewardPerTokenAccumulatedCheckpoint + (scaledRewardRate * (lastTimeRewardDistributed() - lastCheckpointTime)) / totalEarningPower; }
- This function calculates the global reward per token.
- The expression
(scaledRewardRate * (lastTimeRewardDistributed() - lastCheckpointTime)) / totalEarningPower
uses integer division. IfscaledRewardRate
is small (due to a tiny reward) andtotalEarningPower
is extremely large, the division may truncate much of the fractional component, leading to understated rewards.
c.
notifyRewardAmount()
if (block.timestamp >= rewardEndTime) { scaledRewardRate = (_amount * SCALE_FACTOR) / REWARD_DURATION; } else { uint256 _remainingReward = scaledRewardRate * (rewardEndTime - block.timestamp); scaledRewardRate = (_remainingReward + _amount * SCALE_FACTOR) / REWARD_DURATION; }
- Although a
SCALE_FACTOR
(1e36) is applied to improve precision, a very small_amount
can makescaledRewardRate
still too low compared to a hugetotalEarningPower
. Later divisions bytotalEarningPower
can further reduce the reward portion. - The contract attempts to maintain precision by scaling, but integer arithmetic can still lose fractions once you divide by large numbers.
- Here,
-
Example of Failure
- Expected Reward:
1 GWEI
- Actual Reward:
0.9999999 GWEI
(or even less, depending on scaling and integer truncation)
Repeated daily or across many participants, these rounding gaps can add up to a substantial overall discrepancy.
- Expected Reward:
-
Incremental Financial Losses
- Underpayment of Stakers: With each reward cycle, users may receive slightly less than expected. Over a long period or with frequent rewards, these underpayments accumulate into significant losses.
- Potential Exploit: A sophisticated actor could theoretically create transactions timed to maximize truncation, gradually siphoning reward shortfalls. Although it’s not a straightforward exploit, the risk remains non-negligible in edge cases.
-
Reputational Risks
- Trust Erosion: Users noticing persistent shortfalls in rewards will lose confidence in the protocol’s fairness and accuracy. Repeated precision errors can also raise questions about the contract’s overall integrity.
-
System Instability
- Verification Failures: Testing frameworks (e.g., Forge) rely on
assertApproxEqAbs
or similar checks. As soon as the result falls below the acceptance threshold, tests break. This signals the system may not meet its design specifications for precise reward distribution. - Inter-Contract Dependencies: Other contracts or libraries expecting exact reward amounts might behave unpredictably if those expectations are not fulfilled, causing broader ecosystem issues.
- Verification Failures: Testing frameworks (e.g., Forge) rely on
Below is a distilled version of a test scenario demonstrating the discrepancy. Here, the assertion fails because the calculated reward is smaller than tinyReward
:
uint256 tinyReward = 1e9; // 1 GWEI
uint256 largeStake = 10_000_000e18; // 10M ETH
uint256 totalStake = largeStake;
uint256 reward = (tinyReward * largeStake) / totalStake;
// Test assertion fails due to precision loss
assertApproxEqAbs(reward, tinyReward, 1, "Precision issue for small reward");
A comparable logic path is seen in the actual contract code (_scaledUnclaimedReward()
and rewardPerTokenAccumulated()
), where integer operations similarly truncate fractions.
Manual Review
- The testing was performed by minting tokens, simulating reward calculations with large stakes, and then comparing expected vs. actual results.
- A thorough review of the math in
_scaledUnclaimedReward()
,rewardPerTokenAccumulated()
, andnotifyRewardAmount()
confirmed that integer division discards fractional components, producing under-rewards in edge scenarios.
-
Utilize Fixed-Point or High-Precision Libraries
- Libraries: Integrate libraries such as [ABDKMathQuad](https://github.com/abdk-consulting/abdk-libraries-solidity) or [PRBMath](https://github.com/PaulRBerg/prb-math), which preserve fractional components in critical multiplications/divisions.
- Methodology: Replace standard
uint256
math with fixed-point math to avoid integer truncation.
-
Adopt a Scaling Strategy
- Scale Up, Then Scale Down: Multiply all relevant values (e.g.,
tinyReward
,stakerStake
, etc.) by a large constant (e.g.,1e18
) before dividing, then scale down at the end. - Example Implementation:
function calculateReward(uint256 tinyReward, uint256 stakerStake, uint256 totalStake) public pure returns (uint256) { require(totalStake > 0, "Total stake cannot be zero"); uint256 scaledReward = (tinyReward * 1e18 * stakerStake) / totalStake; // Scale up return scaledReward / 1e18; // Scale down }
- This ensures you capture and preserve the fractional value until the final step.
- Scale Up, Then Scale Down: Multiply all relevant values (e.g.,
-
Minimum Reward Threshold
- Threshold Setting: If the calculated reward is below a certain cutoff (e.g.,
1e5
Wei), accumulate it until it reaches a more meaningful amount. This avoids repeated micro-truncation for very tiny distributions. - Benefits: Dramatically reduces the frequency of round-off events for negligible rewards.
- Threshold Setting: If the calculated reward is below a certain cutoff (e.g.,
-
Comprehensive Testing and Fuzzing
- Edge Cases:
- Extremely small reward amounts (1 GWEI or less).
- Extremely large stake totals (10M ETH or more).
- Large participant pools that amplify tiny rounding errors.
- Fuzz Testing: Randomly vary stakes and reward sizes to detect unanticipated rounding errors under diverse conditions.
- Edge Cases:
-
Clear Documentation
- Transparency: Document known limitations around integer-based calculations and highlight potential rounding errors.
- Versioning & Audits: Track code changes impacting reward calculation logic. Periodic external audits help verify that any modifications do not reintroduce precision loss issues.