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

Sealed Risk-Window Rewinds Reopen Deposits and Allow Post-Window Stakers to Capture the Bonus Pool

Author Revealed upon completion

Root + Impact

Description

  • The pool is designed so that deposits are only accepted while the risk window is live, and rewards are weighted by how early a user entered relative to the sealed end of the risk window, riskWindowEnd (T). Once T is sealed (set to non-zero), it is intended to represent the final, immutable end of the reward-accruing period — no valid deposit should ever be able to occur after it.

The issue is that deposit eligibility and reward-window finality are checked against two different, non-synchronized sources of truth. stake() only checks the live state of the external AttackRegistry, while the bonus calculation relies on the permanently sealed riskWindowEnd. If the registry performs a benign rewind from a terminal state (e.g. PRODUCTION) back to an active state (UNDER_ATTACK) — which the protocol's own threat model acknowledges as normal, recurring behavior — stake() reopens even though riskWindowEnd is already sealed and can never be reset. This allows entryTime > T, and because the bonus formula is evaluated through its polynomial expansion, the resulting negative (T - entryTime) is squared into a large positive score instead of reverting.

function stake(uint256 amount) external {
@> // Only checks the CURRENT live registry state — has no awareness
@> // that riskWindowEnd may already be permanently sealed.
@> _assertDepositsAllowed(_observePoolState());
_updateUserStake(msg.sender, amount);
// ...
}
function _observePoolState() internal returns (State state) {
state = attackRegistry.currentState();
@> // Seal is one-way and permanent — but nothing here re-checks
@> // whether the seal has ALREADY happened before allowing stake()
@> // to proceed above.
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd(); // riskWindowEnd = block.timestamp, forever
}
}
function _bonusShare(address user) internal view returns (uint256) {
uint256 entryTime = userEntryTime[user];
@> // No invariant check that entryTime <= riskWindowEnd.
@> // A post-seal entryTime produces (T - entryTime) < 0,
@> // which is squared into a large POSITIVE score via the
@> // polynomial expansion, instead of reverting.
return amount * (riskWindowEnd - entryTime) ** 2;
}

Risk

Likelihood: Medium

  • This will occur any time the upstream AttackRegistry performs a benign rewind from a terminal state (PRODUCTION) back to an active state (UNDER_ATTACK) after riskWindowEnd has already been sealed once — a scenario the protocol's own design documentation acknowledges as a normal, recurring part of the threat model (e.g., false-positive attack flags being cleared and re-triggered by moderators over the life of the pool).

This will occur without any privileged access or coordination: once the registry is in the rewound UNDER_ATTACK state, any unprivileged user observing this on-chain can call stake() and profit, since _assertDepositsAllowed only reads the live registry state.

This will occur repeatedly and permanently once triggered: because riskWindowEnd has no reset path anywhere in the contract, the exploitable window remains open for the entire remaining lifetime of the pool after the first benign rewind, not just a single transaction.

Impact: High

  • Honest, risk-bearing stakers who deposited before or during the actual risk window have their bonus share diluted or almost entirely wiped out, since the quadratic weighting grows unbounded the further after T a malicious deposit occurs.

An attacker with zero risk exposure (depositing only after the window has effectively closed) can capture a disproportionate majority of the bonus pool — demonstrated in the PoC as ~90% of the pool with only a 15-day post-seal delay — directly inverting the documented purpose of the quadratic weighting mechanism.

Proof of Concept


Add the following test to test/unit/ConfidencePool.k2Bonus.t.sol. (Ensure import {console} from "forge-std/console.sol"; is present at the top of the file).

function testQuadraticBonusTheftViaStateRewind() external {
// Honest user deposits before the risk window.
_stake(alice, 100 * ONE);
// Registry enters an active-risk state.
_enterRisk();
// Five days of actual risk pass.
vm.warp(vm.getBlockTimestamp() + 5 days);
// First terminal observation seals riskWindowEnd.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.PRODUCTION
);
pool.pokeRiskWindow();
uint256 T = pool.riskWindowEnd();
assertGt(T, 0, "riskWindowEnd should be sealed");
// Benign upstream rewind reopens the live state.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
// The attacker waits until 15 days after the sealed T.
vm.warp(vm.getBlockTimestamp() + 15 days);
// Deposit succeeds even though the bonus window already ended.
_stake(bob, 100 * ONE);
uint256 bobEntryTime =
pool.userSumStakeTime(bob) / (100 * ONE);
assertGt(
bobEntryTime,
T,
"Bob must enter after the sealed riskWindowEnd"
);
// Fund the bonus pool.
_contributeBonus(carol, 10_000 * ONE);
// Resolve the pool as survived.
_flagSurvived();
uint256 aliceBonus = _claim(alice) - 100 * ONE;
uint256 bobBonus = _claim(bob) - 100 * ONE;
assertEq(
pool.riskWindowEnd(),
T,
"riskWindowEnd must remain sealed"
);
assertApproxEqAbs(
aliceBonus + bobBonus,
10_000 * ONE,
2,
"Claims should account for the contributed bonus"
);
assertGt(
bobBonus,
aliceBonus * 8,
"CRITICAL: Post-window depositor captured the bonus pool"
);
}

Recommended Mitigation


Add regression tests confirming stake() reverts with DepositsClosedAfterRiskWindowSealed after riskWindowEnd is sealed, including across multiple PRODUCTION ↔ UNDER_ATTACK rewind cycles, and a fuzz test asserting riskWindowEnd never changes after its first non-zero assignment.

+ error DepositsClosedAfterRiskWindowSealed();
function stake(uint256 amount) external {
_assertDepositsAllowed(_observePoolState());
+ // Hard latch: once riskWindowEnd is sealed, no further deposits
+ // may be accepted, regardless of any subsequent benign rewind
+ // on the upstream registry's live state.
+ if (riskWindowEnd != 0) {
+ revert DepositsClosedAfterRiskWindowSealed();
+ }
_updateUserStake(msg.sender, amount);
// ...
}
function _bonusShare(address user) internal view returns (uint256) {
uint256 entryTime = userEntryTime[user];
+ // Defense-in-depth invariant: entryTime must never exceed the
+ // sealed window. A failure here indicates a lifecycle bypass
+ // elsewhere in the contract and must not silently mint an
+ // inflated score.
+ require(entryTime <= riskWindowEnd, "entryTime exceeds sealed riskWindowEnd");
return amount * (riskWindowEnd - entryTime) ** 2;
}

Support

FAQs

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

Give us feedback!