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

CORRUPTED Freeze During 180‑Day Grace Window

Author Revealed upon completion

Root + Impact

When the agreement reaches the CORRUPTED state and a risk window has been observed, all principal‑recovery paths withdraw, stake, contributeBonus, and claimExpired are simultaneously blocked for a 180‑day grace period after expiry. Stakers have no self‑release mechanism and are forced to wait for the moderator or the automatic backstop.

Description

  • When an agreement is legitimately corrupted, stakers should not be able to withdraw, to prevent front‑running the corruption flag. After a corruption flag, the pool expects the moderator to quickly resolve the outcome.

  • When the agreement reaches CORRUPTED with an observed risk window, all exit paths close for 180 days after expiry, even if the moderator is silent. Stakers are locked with no self‑release mechanism.

// src/ConfidencePool.sol claimExpired() – grace deferral
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) { // @> 180‑day lock
revert AgreementCorruptedAwaitingModerator();
}
// ...
}
// src/ConfidencePool.sol withdraw()
if (riskWindowStart != 0 || (state not pre‑attack)) {
revert WithdrawsDisabled(); // @> Withdrawals blocked during grace
}

Risk

Likelihood:

  • An agreement transitions to CORRUPTED (DAO‑approved UNDER_ATTACK + sponsor markCorrupted). The risk window was previously observed.

  • The moderator does not act within the 180‑day grace window (intentional or due to unavailability).

Impact:

  • Staker principal is frozen for up to 180 days after expiry. No funds are lost, but liquidity is completely denied.

  • The lock is absolute; no staker‑callable escape exists.

Proof of Concept

Explanation:

A pool is created and a staker deposits 100 tokens. The risk window is opened via pokeRiskWindow while the agreement is UNDER_ATTACK. The registry is then set to CORRUPTED. The staker tries to withdraw but is blocked because the risk window is active and the state is not pre‑attack.

After expiry, the staker tries claimExpired but is again blocked because the grace window is still active (revert with AgreementCorruptedAwaitingModerator). Only after warping past the grace period does claimExpired succeed, and the sweep follows. This demonstrates the absolute freeze during the 180‑day grace.

function test_M1_CorruptedFreezeDuringGrace() public {
MockRegistry registry = new MockRegistry();
registry.setAgreementState(agreement, IAttackRegistry.ContractState.UNDER_ATTACK);
address sponsor = makeAddr("sponsor");
address staker = makeAddr("staker");
vm.prank(sponsor);
ConfidencePool pool = factory.createPool(agreement, address(stakeToken), expiry, minStake, sponsor, accounts);
deal(address(stakeToken), staker, 100 ether);
vm.startPrank(staker);
stakeToken.approve(address(pool), 100 ether);
pool.stake(100 ether);
vm.stopPrank();
pool.pokeRiskWindow();
registry.setAgreementState(agreement, IAttackRegistry.ContractState.CORRUPTED);
vm.prank(staker);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.warp(expiry + 1 days);
vm.prank(staker);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
vm.warp(expiry + 181 days);
pool.claimExpired();
uint256 before = stakeToken.balanceOf(sponsor);
pool.claimCorrupted();
assertEq(stakeToken.balanceOf(sponsor) - before, 100 ether);
}

Recommended Mitigation

Mitigation explanation:

The current code defers entirely to the moderator during the grace window, leaving stakers with zero agency. The mitigation adds a shorter, staker‑callable path that returns only principal (no bonus, no bounty) after a reasonable timeout for example, 30 days after expiry.

This preserves the moderator’s exclusive ability to name a whitehat and trigger the good‑faith bounty during the early part of the window, while giving stakers a bounded, trust‑minimised way to recover their funds if the moderator is permanently silent. The lock is softened from an indefinite 180‑day hold to a manageable 30‑day wait, after which principal is safely returned.

- if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
- revert AgreementCorruptedAwaitingModerator();
- }
+ // Add a shorter staker‑callable path that returns only principal after a reasonable timeout
+ if (block.timestamp >= expiry + SHORT_GRACE) {
+ // Allow principal-only withdrawal if moderator hasn't acted
+ _returnPrincipalToStakers();
+ }

Support

FAQs

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

Give us feedback!