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

`userSumStakeTime/userSumStakeTimeSq` public getters return stale values for inactive stakers, creating an off-chain read-consistency gap

Author Revealed upon completion

Description

Normal behavior: userSumStakeTime[u]/userSumStakeTimeSq[u] are meant to represent a staker's time-weighted contribution, floored at riskWindowStart once the risk window opens, this floor is what the k=2 bonus formula relies on to pay correctly.

The issue: the floor is only applied lazily, via _clampUserSums(u), which runs solely as a side effect of that specific user's own next stake/withdraw/claim call. Because both mappings are public, Solidity auto-generates external view getters for them, and those getters return the unclamped, stale, pre-window value for any staker who hasn't personally transacted since the window opened, even though their eventual payout (once they do claim) will correctly use the clamped value.

@> mapping(address staker => uint256 sum) public userSumStakeTime;
@> mapping(address staker => uint256 sum) public userSumStakeTimeSq;
function _clampUserSums(address u) internal {
uint256 start = riskWindowStart;
uint256 stake_ = eligibleStake[u];
if (start == 0 || stake_ == 0) return;
if (userSumStakeTime[u] < stake_ * start) {
@> // Only runs when THIS user's own function call reaches here — nothing else
@> // in the contract calls this on their behalf, so their public getter values
@> // stay stale indefinitely until they act again.
userSumStakeTime[u] = stake_ * start;
userSumStakeTimeSq[u] = stake_ * start * start;
}
}

Risk

Likelihood:

  • This is the default state for any staker who deposited before the risk window opened and simply holds their position, the majority-case behavior for a staking pool, not a rare occurrence.

No action by any party is required to trigger the staleness, it exists automatically the moment riskWindowStart seals via anyone's unrelated transaction.

Impact:

  • Any off-chain system that reads these getters directly, a front-end estimating a user's expected bonus share, a subgraph, an integrating contract, computes an incorrect (understated, pre-clamp) figure for as long as the user remains inactive, with no on-chain signal that the value is stale.

Proof of Concept

  • Add this test to test/unit/ConfidencePool.k2Bonus.t.sol

  • Run forge test --mt testPublicGetterStaysStaleAfterWindowOpens

function testPublicGetterStaysStaleAfterWindowOpens() external {
// Alice stakes before any risk exists.
_stake(alice, 100 * ONE);
uint256 preWindowValue = pool.userSumStakeTime(alice);
// Window opens via a THIRD PARTY's unrelated interaction, not alice's.
_enterRisk(); // e.g. bob or a keeper calls pokeRiskWindow()
// Alice has not transacted again. Her public getter is still the pre-window value,
// even though riskWindowStart is now sealed and her eventual payout will be
// computed using the CLAMPED value, not this one.
assertEq(pool.userSumStakeTime(alice), preWindowValue);
assertTrue(pool.riskWindowStart() != 0);
// Any off-chain reader of this getter right now gets a stale, understated figure.
}

Recommended Mitigation

  • Either expose a view function that returns the clamped, current-truth value without requiring a state changing call, or clearly document that these mappings' raw getters are last interaction-relative and not live truth:

Support

FAQs

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

Give us feedback!