FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: low
Likelihood: low

Info - 1. `_clampUserSums` over clamps post risk deposits, erasing legitimate entry times

Author Revealed upon completion

Root + Impact

_clampUserSums uses an aggregate check — userSumStakeTime[u] < eligibleStake[u] × riskWindowStart — that compares total weighted entry time against total stake floored to riskWindowStart. When a user holds both pre-risk deposits and post-risk deposits, the average entry time can fall below the threshold, causing the clamp to floor the entire stake to riskWindowStart. The post-risk deposit's actual entry time is erased, giving it credit for at-risk time it never served.

However, this condition is unreachable in practice. The second stake() call (line 244) calls _clampUserSums BEFORE adding the new deposit, which correctly clamps the pre-risk portion to riskWindowStart. The post-risk deposit is then added with its actual entry time. At claim time, the aggregate check compares the combined sum against totalStake × riskWindowStart — but the combined sum already includes the correctly-clamped pre-risk portion plus the post-risk portion, which is always >= totalStake × riskWindowStart. The clamp never fires on a user who has post-risk deposits.

Description

The function _clampUserSums at line 677 normalizes pre-risk deposits — those made before the risk window opened — so they are treated as if they entered at riskWindowStart. The implementation:

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; // @> ALL stake floored to start
userSumStakeTimeSq[u] = stake_ * start * start;
}
}

The check fires when the weighted average entry timestamp is earlier than riskWindowStart. A user with mixed deposits — 100 tokens at t=100 (pre-risk) and 100 tokens at t=500 (post-risk), with riskWindowStart=400 — has an average entry of (100×100 + 100×500) / 200 = 300. Since 300 < 400, the clamp would fire and set both deposits to t=400.

However, this scenario never occurs because _clampUserSums is called during the second stake() at line 244 — before the post-risk deposit is added to the accumulators. At that point, only the pre-risk deposit exists (100 tokens at t=100). The clamp correctly floors it to t=400. Then the post-risk deposit (100 tokens at t=500) is added with its actual entry time. The final userSumStakeTime = 100×400 + 100×500 = 90,000, which is the correct per-deposit value.

At claim time, the aggregate check compares 90,000 against 200 × 400 = 80,000. Since 90,000 >= 80,000, the clamp does not fire. The post-risk entry time is preserved.

A Foundry test confirmed this: with 100 tokens pre-risk and 1 token post-risk, userSumStakeTime equaled the correct per-deposit sum (176750030500000000000000000000), not the clamped sum (176750030300000000000000000000).

Risk

Likelihood:

None. The condition required for the over-clamp (average entry below riskWindowStart despite post-risk deposits) cannot be reached because _clampUserSums is called during each stake() before the new deposit is added, correctly handling the pre-risk portion in isolation.

Impact:

None. The post-risk deposit's entry time is preserved in all reachable states.

Proof of Concept

The following test demonstrates that the clamp does NOT fire on a user with mixed pre-risk and post-risk deposits. The userSumStakeTime equals the correct per-deposit value, not the aggregate-clamped value.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract Test_ClampOverclamp is BaseConfidencePoolTest {
/// @notice Demonstrates that the aggregate clamp does NOT fire on
/// mixed deposits because _clampUserSums is called during
/// the second stake() before the post-risk deposit is added.
function test_clampDoesNotFire() external {
// Alice deposits 100 tokens pre-risk
_stake(alice, 100 * ONE);
// Risk window opens at t=300
vm.warp(block.timestamp + 300);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint256 riskStart = pool.riskWindowStart();
// Alice deposits 1 token post-risk at t=500
vm.warp(block.timestamp + 200);
_stake(alice, 1 * ONE);
// Read userSumStakeTime
uint256 userSumTime = pool.userSumStakeTime(alice);
// Correct per-deposit value:
// pre-risk 100@riskStart + post-risk 1@(riskStart+200)
uint256 correctSumTime = (100 * ONE * riskStart) + (1 * ONE * (riskStart + 200));
// Aggregate-clamped value (if it fired):
// 101 * riskStart
uint256 clampedSum = (101 * ONE) * riskStart;
emit log_named_uint("riskWindowStart", riskStart);
emit log_named_decimal_uint("userSumStakeTime (actual)", userSumTime, 18);
emit log_named_decimal_uint("correct sum (per-deposit)", correctSumTime, 18);
emit log_named_decimal_uint("clamped sum (aggregate)", clampedSum, 18);
// The actual value equals the correct per-deposit value, NOT the clamped value
assertEq(userSumTime, correctSumTime, "Post-risk entry time is preserved");
assertGt(userSumTime, clampedSum, "Aggregate clamp did NOT fire");
}
}

Test output:

[PASS] test_clampDoesNotFire() (gas: 436590)
Logs:
riskWindowStart: 1750000300
userSumStakeTime (actual): 176750030500.000000000000000000
correct sum (per-deposit): 176750030500.000000000000000000
clamped sum (aggregate): 176750030300.000000000000000000

The actual value (176750030500) equals the correct per-deposit value and exceeds the clamped value (176750030300). The aggregate clamp does not fire.

Recommended Mitigation

No mitigation is required. The code correctly handles mixed pre-risk and post-risk deposits because `_clampUserSums` is called during each `stake()` before the new deposit is added, isolating the pre-risk portion for clamping. The aggregate check at claim time is a no-op for any user who has made post-risk deposits.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!