FoundrySolidityLayer 2
7.25 ETH
View results
Submission Details
Severity: medium
Valid

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

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();
}
Updates

Lead Judging Commences

inallhonesty Lead Judge
28 days ago
inallhonesty Lead Judge 22 days ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

Known Design #7

Known Design #6

Known Design #5

inallhonesty Lead Judge 22 days ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

Known Design #7

Known Design #6

Known Design #5

Appeal created

moin Submitter
21 days ago
inallhonesty Lead Judge
19 days ago
inallhonesty Lead Judge 18 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!