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

A corruption recorded after expiry can confiscate principal from an already-survived pool

Author Revealed upon completion

Root + Impact

Description

The documented normal behavior is that a pool which reaches expiry while the agreement is still UNDER_ATTACK or PROMOTION_REQUESTED has survived the full term. It should resolve EXPIRED and return principal plus bonus to stakers.

The pool does not freeze the registry state at that deadline. claimExpired() reads the live state whenever somebody eventually calls it, so a legitimate CORRUPTED transition after expiry can retroactively change the already-matured pool from EXPIRED into bad-faith CORRUPTED:

function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
// ...
@> IAttackRegistry.ContractState state = _observePoolState();
// ...
@> 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;
return;
}
// A still-active registry reaches this EXPIRED branch only when resolution is prompt.
@> outcome = PoolStates.Outcome.EXPIRED;
}

Calls after the late corruption but during the grace period revert. After the grace period, claimExpired() sets CORRUPTED and claimCorrupted() sweeps the entire pool. By contrast, calling at the deadline immediately before that same later transition returns all funds to the staker.

_markRiskWindowEnd() also clamps a terminal state first observed after expiry back to expiry, erasing the only local indication that the observation was late:

uint256 t = block.timestamp;
@> if (t > expiry) t = expiry;
riskWindowEnd = uint32(t);

Risk

Likelihood:

  • This occurs when the common riskWindowStart != 0 path is active, the agreement remains attackable when the pool expires, nobody resolves before a later legitimate corruption, and the moderator is unavailable for the documented grace fallback.

  • It needs no malicious protocol moderator. A valid attack after the pool term can cause the attack moderator to record CORRUPTED. Immediate claiming is not a reliable mitigation because the transition can be ordered immediately after expiry or before a staker's resolution transaction.

Impact:

  • Every staker loses 100% of principal and the sponsor's entire bonus to recoveryAddress, even though the insured term ended without a terminal corruption.

  • Two executions from identical state produce opposite principal ownership solely from resolution timing after the entitlement date.

Proof of Concept

Add this test to a file inheriting BaseConfidencePoolTest:

function testPostExpiryCorruptionChangesMaturedEntitlement() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
_passThroughUnderAttack();
uint256 expiryTs = pool.expiry();
uint256 atRiskCheckpoint = vm.snapshotState();
// Resolving while the agreement is still active at expiry pays the staker.
vm.warp(expiryTs);
vm.prank(alice);
pool.claimExpired();
assertEq(token.balanceOf(alice), 200 * ONE);
// Restore identical state, then record corruption only after the term ended.
vm.revertToState(atRiskCheckpoint);
vm.warp(expiryTs + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(expiryTs + pool.MODERATOR_CORRUPTED_GRACE());
pool.claimExpired();
pool.claimCorrupted();
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(recovery), 200 * ONE);
}

Run:

forge test --match-test testPostExpiryCorruptionChangesMaturedEntitlement -vvv

Result:

[PASS] testPostExpiryCorruptionChangesMaturedEntitlement()
1 passed; 0 failed

The complete non-fork suite also passes (259 tests, 0 failures).

Recommended Mitigation

Resolve from terminal state as of the pool deadline. The robust fix is for the registry to expose an authenticated terminal-transition timestamp and to accept CORRUPTED for this pool only when that timestamp is no later than expiry.

Without an upstream timestamp, retain whether a terminal state was observed before expiry and do not auto-CORRUPT solely from a terminal state first observed afterward:

- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
+ if (
+ state == IAttackRegistry.ContractState.CORRUPTED
+ && riskWindowStart != 0
+ && terminalObservedBeforeExpiry
+ ) {

A terminal state first observed after the term should require moderator adjudication or use the staker-favorable EXPIRED fallback. Do not clamp away the late-observation distinction without storing it separately.

Support

FAQs

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

Give us feedback!