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

CORRUPTED grace period is measured from `expiry` instead of the corruption transition, letting a post grace breach confiscate a pool that already survived its term

Author Revealed upon completion

CORRUPTED grace period is measured from expiry instead of the corruption transition, letting a post-grace breach confiscate a pool that already survived its term

Severity: Medium

Description

  • A pool that reaches expiry while the agreement remains UNDER_ATTACK or PROMOTION_REQUESTED has survived the insured term. The expected resolution is EXPIRED, returning principal and earned bonus.

  • claimExpired() reads the live registry state when settlement is triggered. Because the permissionless CORRUPTED grace period is anchored to expiry rather than the corruption transition, a breach appearing after expiry + MODERATOR_CORRUPTED_GRACE can be finalized immediately as bad-faith CORRUPTED.

// src/ConfidencePool.sol — claimExpired()
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
@> if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
@> claimsStarted = true;
emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
return;
}
// src/ConfidencePool.sol — _markRiskWindowEnd()
@> if (t > expiry) t = expiry;
riskWindowEnd = uint32(t);

Risk

Likelihood:

  • An approved attack remains UNDER_ATTACK beyond the pool deadline while no participant calls claimExpired(), leaving the pool unresolved.

  • The agreement becomes CORRUPTED only after the expiry-based grace period, allowing the first caller to bypass a fresh moderator-review window.

Impact:

  • The full pool balance, including principal and bonus, is transferred to recoveryAddress even though the insured term ended before corruption.

  • claimsStarted prevents the moderator from later choosing SURVIVED for an out-of-scope breach or naming a good-faith whitehat.

Proof of Concept

The test deposits 100 tokens of principal and 20 tokens of bonus, then opens the risk window by moving the agreement to UNDER_ATTACK. Time advances beyond both expiry and the expiry-based grace period while the pool remains unresolved.

Only afterward does the agreement become CORRUPTED. A permissionless caller resolves the pool as bad-faith CORRUPTED, and claimCorrupted() transfers all 120 tokens to recoveryAddress, leaving Alice with none of her principal.

// test/audit/ConfidencePoolM02.t.sol
function test_PostExpiryCorruptionConfiscatesExpiredPool() external {
pool.setRecoveryAddress(address(this));
_stake(alice, 100 * ONE);
_contributeBonus(carol, 20 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
uint256 expiryTs = pool.expiry();
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
uint256 recoveryBefore = token.balanceOf(address(this));
vm.prank(dave);
pool.claimCorrupted();
assertEq(token.balanceOf(address(this)) - recoveryBefore, 120 * ONE);
assertEq(token.balanceOf(alice), 0);
}

Recommended Mitigation

Only a corruption that became terminal at or before expiry should confiscate the pool. A corruption first observed after the insured term should resolve EXPIRED.

- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && riskWindowStart != 0
+ && riskWindowEnd != 0
+ && riskWindowEnd < expiry
+ ) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}

Support

FAQs

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

Give us feedback!