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

Lazy `riskWindowStart` observation lets a delayed staker's entry time (during active risk state) overwrite earlier stakers' time-weighted bonus score

Author Revealed upon completion

Root + Impact

Description

  • The riskWindowStart variable is set lazily with the interaction of the confidence pool during an active attack risk state.

  • Delayed first interactions to the pool after the agreement entered under attack state, leads to the setting of riskWindowStart being delayed, which results in the clamping of earlier stakers (who staked even before the pool went under attack) to be clamped to the same time as the delayed stakers, leading to consideration of same weighted score for both the earlier and delayed stakers.

  • But the early stakers should be getting the benefit of the specified T=2 weighted logic used, but due to the delayed updates there share will be reduced because the later interactions got the stakers staking late to benefit from the weighted score being set late.

Risk

Likelihood:

This occurs when the first interaction in the active risk state gets delayed, which leads to delayed update of the riskWindowStart.

Impact:

  • The delayed staker staking in the active risk window gets the benefit of the staker who would stake from the very beginning.

  • This penalizes the early stakers, as the weighted score of the delayed staker would become same as early staker, which decreases the net bonus the early stakers should actually get.

Proof of Concept

Make a new file in test/unit, add the below test.
Run it using:

forge test --mt testEarlyStakerLosesTimeWeightPremiumWhenRiskWindowSealedLate
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
/// @notice PoC: lazy `riskWindowStart` observation lets a delayed staker's entry timestamp
/// overwrite an earlier staker's time-weighted bonus score, erasing the earlier staker's
/// risk-bearing premium entirely.
contract ConfidencePoolLazyRiskWindowStartTest is BaseConfidencePoolTest {
function testEarlyStakerLosesTimeWeightPremiumWhenRiskWindowSealedLate() external {
// alice stakes well before the agreement is ever attacked.
_stake(alice, 100 * ONE);
// The agreement genuinely enters UNDER_ATTACK now (t1) — but nobody calls
// pokeRiskWindow() or any other observing entrypoint. riskWindowStart stays 0.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
// 10 days pass with zero pool interaction. alice is genuinely at risk this
// entire time, but the contract has no on-chain record of it yet.
vm.warp(vm.getBlockTimestamp() + 10 days);
// bob stakes now. His stake() call is the FIRST entrypoint to observe the
// registry in an active-risk state, so it lazily seals
// riskWindowStart = block.timestamp (t1 + 10 days) — NOT the true attack start t1.
_stake(bob, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 alicePayout = token.balanceOf(alice) - aliceBefore;
uint256 bobBefore = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobPayout = token.balanceOf(bob) - bobBefore;
// BUG: alice was genuinely at risk for 11 days, bob for only 1 day, yet
// _clampUserSums() overwrites alice's userSumStakeTime up to
// stake * riskWindowStart the moment she claims — identical to bob's own
// entry, since bob's stake() call is what set riskWindowStart to his own
// block.timestamp. Both end up with the exact same time-weighted score.
// where riskWindowStart should be set to the exact time when the risk state was entered
assertEq(alicePayout, bobPayout, "alice's 11-day exposure premium was erased by lazy sealing");
}
}

Recommended Mitigation

Maintain the state updations timing in AttackerRegistry, by doing this the Confidence Pool will be able to fetch the actual timing when a state was first reached, and use that same under attack / promotion requested state timing from the registry or the first stake time whichever is latest.

Support

FAQs

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

Give us feedback!