FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: medium
Valid

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.
Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

MODERATOR_CORRUPTED_GRACE anchored to expiry instead of first CORRUPTED observation gives the moderator zero actionable window before permissionless bad-faith settlement

Impact - High The mechanical branch always picks bad-faith, so the whole corpus lands at recoveryAddress and claimsStarted makes it permanent in the same call. Both alternatives it forecloses send the money elsewhere: DESIGN.md #8 lets the moderator flag SURVIVED for an out-of-scope breach, returning everything to stakers, and good-faith CORRUPTED reserves the pool for a named whitehat. Losing the classification decides who gets paid. Likelihood - Low The pool has to sit unresolved through the whole window past expiry, which is the hard part, since claimExpired stays permissionless throughout and would settle it as EXPIRED against the live active-risk state. Any staker with principal waiting has reason to make that call. Once corruption does land late, the moderator's first legal moment to classify and the fallback's first to finalize arrive together, so ordering alone decides it.

Support

FAQs

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

Give us feedback!