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

A post-expiry `CORRUPTED` event immediately confiscates an expired pool

Author Revealed upon completion

Root + Impact

Description

Confidence Pools are intended to let stakers underwrite an agreement until the pool's immutable
expiry. After expiry, anyone may call claimExpired() to resolve the pool. A known
CORRUPTED agreement is held for MODERATOR_CORRUPTED_GRACE so the outcome moderator can make
the scope-aware and good-faith determination before the permissionless fallback is available.

claimExpired() measures that entire grace period from the pool's expiry, rather than from the
time at which the agreement became CORRUPTED. Therefore, a pool that remains unresolved until
expiry + MODERATOR_CORRUPTED_GRACE can receive a brand-new CORRUPTED state and be immediately
resolved as bad-faith CORRUPTED. The pool neither records nor queries a corruption timestamp, so
it cannot distinguish a breach during the covered term from one that occurred after the term.

// src/ConfidencePool.sol
function claimExpired() external nonReentrant {
// ...
IAttackRegistry.ContractState state = _observePoolState();
@> // `state` is read at resolution time; no corruption-transition timestamp is checked.
@> if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
@> // The response window is wholly anchored to pool expiry.
@> if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
@> // A CORRUPTED state first appearing after expiry + grace is finalized immediately.
outcome = PoolStates.Outcome.CORRUPTED;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
@> claimsStarted = true;
return;
}
// ...
}
// A post-expiry observation can still satisfy the auto-CORRUPTED risk-window gate.
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
@> if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
}

Risk

Likelihood:

  • Pools remain unresolved after expiry because claiming is optional and neither
    claimExpired() nor the staker claim paths impose a deadline on stakers.

  • An agreement can remain in UNDER_ATTACK through expiry + MODERATOR_CORRUPTED_GRACE, then
    transition to CORRUPTED. A permissionless pokeRiskWindow() at that point records
    riskWindowStart = expiry, and the first claimExpired() call immediately takes the
    auto-CORRUPTED branch.

Impact:

  • A corruption occurring outside the coverage term retroactively changes an expired pool into
    bad-faith CORRUPTED; every unclaimed staker principal and the bonus become sweepable to
    recoveryAddress.

  • claimsStarted is set during the mechanical resolution, preventing the outcome moderator from
    subsequently selecting SURVIVED for an out-of-scope incident or assigning a good-faith bounty.

Proof of Concept

Add the following test to a test contract inheriting BaseConfidencePoolTest:

function testFreshPostGraceCorruptionImmediatelySweepsAnExpiredPool() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 20 * ONE);
// No pool interaction observes the risk window before coverage ends.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
// A late observation is capped to `expiry`, satisfying riskWindowStart != 0.
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), pool.expiry());
// The agreement becomes corrupted only after the full grace period has elapsed.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// No new moderator window is provided: the pool immediately finalizes as bad-faith corrupted.
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertTrue(pool.claimsStarted());
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 120 * ONE);
assertEq(token.balanceOf(alice), 0);
}

The repository PoC is available at
test/audit/ExpiryCorruptedAndK2Boundary.t.sol.
It passes with:

forge test --match-test testFreshPostGraceCorruptionImmediatelySweepsAnExpiredPool -vv

Recommended Mitigation

The authoritative registry should expose an immutable corruptedAt timestamp. The pool must
only apply its corruption path to incidents that occurred no later than the pool's coverage
deadline; a post-expiry incident must not alter the expired pool's result.

// IAttackRegistry.sol
+ function getCorruptedAt(address agreement) external view returns (uint256);
// ConfidencePool.sol -- inside claimExpired()
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
- if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
- revert AgreementCorruptedAwaitingModerator();
- }
- outcome = PoolStates.Outcome.CORRUPTED;
- outcomeFlaggedAt = riskWindowEnd;
- corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
- claimsStarted = true;
- return;
- }
+ if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ uint256 corruptedAt = IAttackRegistry(safeHarborRegistry.getAttackRegistry()).getCorruptedAt(agreement);
+
+ // The pool covers only incidents that happened during its term.
+ if (corruptedAt > expiry) {
+ outcome = PoolStates.Outcome.EXPIRED;
+ outcomeFlaggedAt = expiry;
+ } else {
+ if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
+ revert AgreementCorruptedAwaitingModerator();
+ }
+ outcome = PoolStates.Outcome.CORRUPTED;
+ outcomeFlaggedAt = riskWindowEnd;
+ corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
+ claimsStarted = true;
+ return;
+ }
+ }

Without a trustworthy upstream incident timestamp, the pool cannot reliably distinguish a
pre-expiry breach reported late from a genuine post-expiry breach. In that case, a scope-blind,
permissionless auto-CORRUPTED fallback should not finalize a terminal state first observed after
expiry; moderator-mediated settlement is required.

Support

FAQs

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

Give us feedback!