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

A late staker seals riskWindowStart late, erasing earlier stakers' at-risk time and transferring their k=2 bonus premium

Author Revealed upon completion

Root + Impact

Root cause: riskWindowStart seals on the first on-chain observation of an active-risk registry state (_markRiskWindowStart, ConfidencePool.sol#L801), not on the true attack-start block. _clampUserSums then floors every earlier staker's effective entry up to that seal (#L677), and _bonusShare weights each deposit by (T − effectiveEntry)² (#L696).

Impact: A staker who deposits late during an UNDER_ATTACK phase nobody has observed yet makes their own deposit the first observation, sealing riskWindowStart late. Every earlier staker's entry floors up to it, erasing the at-risk time they actually bore. The k=2 score spread collapses toward an amount-weighted split, transferring the early honest stakers' earned bonus premium to the late (larger) staker — a ~88% loss of the early staker's fair bonus in the PoC.

Description

The bonus rewards time at risk: each deposit scores amount × (T − entryTime)², so a longer-underwriting staker earns more. But riskWindowStart — which all pre-seal entries floor to — is set by the first party to touch the pool during an active-risk state, not by the true transition. A passive early staker doesn't interact during an unobserved attack, so the window doesn't seal on its own; a late staker makes their own deposit the first observation, seals riskWindowStart late, and wipes out earlier stakers' real at-risk time.

// _clampUserSums — ConfidencePool.sol#L677 : early entry PROMOTED UP to the late seal
if (userSumStakeTime[u] < stake_ * start) {
userSumStakeTime[u] = stake_ * start;
userSumStakeTimeSq[u] = stake_ * start * start;
}

This is not the timing residual the docs accept: that residual is for the end anchor T (riskWindowEnd), framed as a symmetric public race. A4 manipulates the start anchor and is not symmetric — the loss falls entirely on a passive early staker. The time it erases is genuinely at-risk time (true UNDER_ATTACK → late first-observation), which the docs never address.

Risk

Likelihood: Medium — occurs whenever an active-risk interval passes with no pool interaction to seal the window (normal for passive stakers); the late large staker is economically incentivised to withhold the poke and stake late.

Impact: Medium — a specific honest staker loses the majority of their fair bonus to the attacker; redistribution among stakers, no principal loss.

Proof of Concept

test/audit/AdversarialA4.t.sol (both tests pass). Alice stakes 10 early, bears ~21 days at risk; a whale stakes 90 one day before resolution, its late stake sealing riskWindowStart. Baseline is identical but seals at the true start via one pokeRiskWindow().

Scenario Alice (early, ~21d) Whale (late, ~1d)
Attack (late seal) 10 90
Honest baseline (early poke) 98 2
function test_A4_lateWhaleSealsRiskWindowLate_stealsEarlyStakerPremium() external {
_contributeBonus(makeAddr("sponsor"), 100 * ONE);
_stake(alice, 10 * ONE); // early, before any attack
vm.warp(vm.getBlockTimestamp() + 2 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(vm.getBlockTimestamp() + 20 days); // Alice bears ~20 more days at risk (passive, no poke)
_stake(whale, 90 * ONE); // late stake = FIRST observation, seals riskWindowStart late
vm.warp(vm.getBlockTimestamp() + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
pool.pokeRiskWindow();
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 aliceBonus = _claimBonus(alice, 10 * ONE); // = 10e18 (premium gone)
uint256 whaleBonus = _claimBonus(whale, 90 * ONE); // = 90e18
assertLt(aliceBonus, 20 * ONE);
}

Recommended Mitigation

Seal riskWindowStart from the registry's canonical active-risk transition timestamp if available, so a late observer cannot move it. If no on-chain transition timestamp exists, credit at-risk time from min(depositTime, earliest-known-active-risk) rather than flooring up to a possibly-late riskWindowStart, or auto-seal the window on the registry transition instead of lazily on first interaction.

For example, replace the lazy first-observation seal with a value derived from the transition itself:

// Instead of: riskWindowStart = uint32(block.timestamp) on first observation,
// anchor to the true active-risk start reported by the registry:
uint256 trueStart = attackRegistry.activeRiskStartedAt(agreement); // canonical, not observation-dependent
riskWindowStart = uint32(trueStart > expiry ? expiry : trueStart);

Support

FAQs

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

Give us feedback!