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

RiskWindowEnd Sealing Race: Permissionless pokeRiskWindow() Enables Asymmetric Bonus Redistribution

Author Revealed upon completion

Root + Impact

Description

The k=2 bonus distribution formula uses a terminal timestamp T (riskWindowEnd) as its upper bound. This timestamp is designed to be set on the first observation of a terminal registry state, after which it becomes immutable.

The observation that seals T is permissionless — any staker can trigger it via pokeRiskWindow(). An early staker can front-run the moderator's flagOutcome() call and seal T to the earliest possible block. This amplifies their (T − entry)² score relative to later stakers, shifting a measurable portion of the bonus pool from later entrants to the early entrant. The design doc claims any bias "collapses to ~zero" through counterparty action, but a later staker has no mechanism to delay or overwrite T once sealed — the race is one-sided.

// @> _markRiskWindowEnd is called by _observePoolState, which is reachable permissionlessly
function _markRiskWindowEnd() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
// @> Once set, riskWindowEnd can never be changed — not even by the moderator
riskWindowEnd = uint32(t);
emit RiskWindowEnded(t);
}
// @> _observePoolState triggers on ANY state-reading call, including permissionless pokeRiskWindow
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
// ...
// @> First terminal observation seals T permanently
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
// @> flagOutcome reads the already-sealed riskWindowEnd — moderator cannot override it
outcomeFlaggedAt = riskWindowEnd;

Risk

Likelihood: Medium-High

  • A terminal state transition occurs on every pool that reaches PRODUCTION or CORRUPTED — the normal lifecycle.

  • Front-running is trivial with MEV infrastructure and requires zero privileges — any early staker can call pokeRiskWindow() in the same or next block.

Impact: Medium

  • Measurable bonus redistribution from later stakers to the early staker — 0.82% of a 100 ETH bonus pool shifted per 10-day entry gap in testing.

  • No principal is at risk, and no funds leave the staker pool; it is bounded entirely to the voluntarily contributed bonus pool and the pre-existing entry-time spread.

Proof of Concept

The following test isolates the front-running effect by forking the same pre-seal state. Both scenarios share identical entry times, stake sizes, and bonus amounts. The only variable is whether the attacker front-runs pokeRiskWindow() or lets T seal naturally via the moderator's flagOutcome() one day later.

function test_delta_frontRunVsNaturalSealing() public {
_buildCommonStateUpToProduction();
uint256 forkPoint = vm.snapshot();
// Scenario A: natural sealing (moderator flags +1 day)
vm.warp(block.timestamp + 1 days);
vm.prank(MODERATOR);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 attackerNatural = _claimBonus(attacker);
uint256 honestNatural = _claimBonus(honestStaker);
// Attacker: 99.18 ETH, Honest: 0.82 ETH
// Scenario B: attacker front-runs pokeRiskWindow (+12s)
vm.revertTo(forkPoint);
vm.prank(attacker);
pool.pokeRiskWindow();
vm.warp(block.timestamp + 1 days);
vm.prank(MODERATOR);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 attackerFrontRun = _claimBonus(attacker);
uint256 honestFrontRun = _claimBonus(honestStaker);
// Attacker: 99.99999998 ETH, Honest: 0.000000019 ETH
// Delta: 0.82 ETH shifted by front-running alone
uint256 delta = attackerFrontRun - attackerNatural;
assertGt(delta, 0.8e18, "front-run should shift >0.8 ETH");
assertLt(delta, 0.85e18, "front-run should shift <0.85 ETH");
assertEq(delta, honestNatural - honestFrontRun, "attacker gain must equal honest loss");
}

A second test confirms that with identical entry times, front-running has zero effect — the 50/50 split is preserved regardless of who seals T, proving the mechanism only amplifies pre-existing entry-time skew.

Recommended Mitigation

For the flagOutcome() path (the common, moderator-resolved case), anchor T to the moderator's own confirmation timestamp rather than the permissionlessly-sealed riskWindowEnd. This removes the front-running incentive because the attacker's pokeRiskWindow() no longer influences the bonus split when the moderator resolves the pool. The moderator is already the trusted party for outcome determination, so anchoring T to their observation time is consistent with the existing trust model.

The claimExpired() auto-resolution backstop paths remain unchanged — they continue using riskWindowEnd sealed by the first permissionless observer. This is an accepted residual because the backstop only fires when the moderator is permanently unavailable, and in that scenario there is no trusted party to anchor T to.

This fix was verified against the delta PoC: with the change applied, the front-run delta drops to exactly 0 ETH, and both scenarios produce identical bonus splits regardless of who calls pokeRiskWindow().

Residual limitation (accepted): The claimExpired() auto-resolution backstop still uses riskWindowEnd from the first permissionless observer. This cannot be fixed the same way because there is no trusted moderator to anchor to in the auto-resolution path. This path only fires when the moderator is permanently unavailable (a failure mode the system already accepts as catastrophic) — but the "biases toward the recovery address, not the attacker" mitigation applies only to the auto-CORRUPTED sub-branch, where the pool is swept to recoveryAddress and no staker bonus is paid at all, so front-running has nothing to act on there. The far more common EXPIRED sub-branch (moderator absent, registry never reaches CORRUPTED) still pays stake + bonus to stakers using outcomeFlaggedAt = riskWindowEnd, exactly as before the fix — that path remains fully exposed to the same front-running dynamic described above, unmitigated by this change.

// In ConfidencePool.sol, function flagOutcome()
// ... existing validation and snapshot code ...
- outcomeFlaggedAt = riskWindowEnd;
+ // Anchor T to the moderator's own confirmed observation (capped at expiry,
+ // mirroring _markRiskWindowEnd). This removes the front-running incentive
+ // because the attacker's pokeRiskWindow() no longer influences the split
+ // when the moderator resolves the pool.
+ {
+ uint256 t = block.timestamp;
+ if (t > expiry) t = expiry;
+ outcomeFlaggedAt = uint32(t);
+ }
emit OutcomeFlagged(msg.sender, newOutcome, goodFaith_, attacker_);

Support

FAQs

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

Give us feedback!