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

Late `CORRUPTED` transitions bypass the moderator grace period and permit an immediate full-pool sweep

Author Revealed upon completion
## Description
The 180-day auto-`CORRUPTED` delay is measured from the pool's historical `expiry`, not from when
the registry first becomes `CORRUPTED`. This is safe only if corruption already exists by expiry.
The upstream registry does not impose that ordering: an agreement may remain `UNDER_ATTACK` through
the entire pool term and the following 180 days, then become `CORRUPTED` afterward.
Before that late transition, the pool moderator cannot classify the breach because
`flagOutcome(CORRUPTED, ...)` requires the live registry to be `CORRUPTED`. Once the transition
occurs, `expiry + MODERATOR_CORRUPTED_GRACE` is already in the past. The first permissionless
`claimExpired()` call therefore immediately:
1. snapshots all principal and bonus;
2. selects bad-faith `CORRUPTED`;
3. sets `claimsStarted = true`; and
4. returns without paying a staker.
The finality latch makes every moderator correction revert. A subsequent permissionless
`claimCorrupted()` call transfers the pool's entire token balance to `recoveryAddress`.
This is distinct from the documented scope-blind fallback. That fallback accepts staker loss when
the pool moderator is unavailable for a full grace period after a classifiable breach. Here no
breach exists during the elapsed grace, so even a continuously available moderator receives zero
protocol-enforced reaction time after classification first becomes possible.
## Root + Impact
```solidity
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
// ...
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
// ...
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
// @> The timer is anchored to the old pool expiry, not to the CORRUPTED transition.
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
// @> A newly created CORRUPTED state can therefore receive immediate bad-faith finality.
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
// @> This immediately closes the moderator's correction/classification window.
claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}
}
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_)
external
onlyModerator
{
// @> The late mechanical resolution makes this condition revert permanently.
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) {
revert OutcomeAlreadySet();
}
// ...
}
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
// @> The full live balance, not merely excess or bonus, is sent to recoveryAddress.
stakeToken.safeTransfer(recoveryAddress, toSweep);
}
```
## Impact
**High.** All unclaimed staker principal, the complete bonus, and any direct token donations in an
affected pool can be irreversibly transferred to the sponsor-selected `recoveryAddress`. The pool
moderator receives no opportunity to determine that the breach was post-term or outside this
pool's scope, or to name a good-faith attacker.
## Likelihood
**Low.** The pool must remain unresolved for more than 180 days after expiry, an active-risk window
must previously have been observed, and the registry must transition to `CORRUPTED` only after the
expiry-anchored grace has elapsed. Any caller could resolve the pool as `EXPIRED` while the registry
is still `UNDER_ATTACK`, which can preempt the sequence. However, claims have no deadline or keeper
guarantee, and the party that produces the late `CORRUPTED` transition can order the permissionless
finalization and sweep calls in the same private bundle.
## Risk
**Severity: Medium (High impact × Low likelihood).**
- The impact is concrete and maximal for one clone: the PoC moves 100 tokens of staker principal
plus a 20-token bonus, leaving the pool with a zero balance.
- The moderator cannot act early because the registry is not yet terminal, and cannot act late
because `claimsStarted` is set by the first mechanical finalization.
- The calls that finalize and sweep are permissionless. If the agreement owner is also the
upstream attack moderator and pool sponsor—as in the normal registration/factory path—it can
choose the recovery address and order `markCorrupted -> claimExpired -> claimCorrupted`.
- Likelihood remains Low because the pool must be dormant and unresolved beyond
`expiry + 180 days`; an earlier `claimExpired()` while the registry is active-risk would safely
finalize `EXPIRED` and defeat this sequence.
## Proof of Concept
The mock registry
setter models the real dependency's reachable late `UNDER_ATTACK -> CORRUPTED` transition; all pool
entrypoints used for observation, finalization, and sweeping are the production implementation.
```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract LateCorruptionAutoSweepPoC is BaseConfidencePoolTest {
function test_PoC_CorruptionAfterExpiryGraceGetsImmediateAutoCorruptedFinality() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 20 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
// No corruption exists during the pool term or its advertised 180-day grace.
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE() + 1);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED));
// Corruption appears only now, but the expiry-anchored grace is already over.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertTrue(pool.claimsStarted());
// The live pool moderator never receives a valid classification window.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// Anyone can now sweep all principal and bonus to the selected recovery address.
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 120 * ONE);
assertEq(token.balanceOf(address(pool)), 0);
}
}
```
## Recommended Mitigation
Anchor the grace period to the later of pool expiry and the registry's `CORRUPTED` transition:
```solidity
uint256 graceDeadline = Math.max(uint256(expiry), corruptedAt) + MODERATOR_CORRUPTED_GRACE;
```
If the registry cannot expose `corruptedAt`, start the timer on the first successful post-expiry
observation and return so the write is not rolled back:
```diff
+ uint256 public corruptedGraceStartedAt;
function claimExpired() external nonReentrant {
// ...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && riskWindowStart != 0
+ && corruptedGraceStartedAt == 0
+ ) {
+ corruptedGraceStartedAt = block.timestamp;
+ return;
+ }
// Take the resolution snapshots only after the grace-start write above.
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
- if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
+ if (block.timestamp < corruptedGraceStartedAt + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
}
// ...
}
}
```
Do not use `riskWindowEnd`, because it is capped at `expiry`. Existing non-upgradeable clones need
a migration or recovery plan.

Support

FAQs

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

Give us feedback!