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

Expiry-anchored grace lets post-term corruption confiscate expired capital

Author Revealed upon completion

Description

  • A pool that reaches its advertised expiry while the agreement is still in an active-risk state has survived the full term. The intended EXPIRED path returns stakers' principal and their earned bonus.

  • The first post-expiry resolution instead reads the agreement's current registry state. A CORRUPTED transition that occurs only after the pool term is therefore applied retroactively to the expired pool. Because the moderator grace is measured from expiry rather than from the corruption transition or its first observation, a corruption first reported after expiry + MODERATOR_CORRUPTED_GRACE receives no moderator review period. Any account can immediately latch bad-faith CORRUPTED and make the entire expired pool sweepable to recoveryAddress.

function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
// ...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
// @> This samples live state at the first resolution call; it does not
// @> establish that CORRUPTED existed on or before the pool deadline.
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
// @> The grace is anchored to pool expiry. A newly occurring
// @> post-term corruption can therefore arrive after this deadline.
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
return;
}
// ...
}
}
function _markRiskWindowEnd() internal {
uint256 t = block.timestamp;
// @> A post-expiry observation is clamped back to expiry. This bounds
// @> bonus arithmetic but does not prove when CORRUPTED actually occurred.
if (t > expiry) t = expiry;
riskWindowEnd = uint32(t);
}

Risk

Likelihood: Low

  • The permissionless path requires an unresolved pool with a previously observed active-risk window to remain untouched through the 180-day expiry-anchored grace, followed by an ordinary CORRUPTED transition after the pool term.

  • Rational stakers can normally call claimExpired at the deadline, so the long period of inactivity makes exploitation uncommon. Once these lifecycle conditions exist, however, any account can finalize the stale pool immediately and no malicious moderator or registry migration is required.

Impact: High

  • All staker principal and all accounted bonus can be irreversibly transferred to recoveryAddress for a corruption that did not exist during the underwritten term.

  • A newly reported terminal event receives zero moderator review time, defeating both the advertised term boundary and the purpose of MODERATOR_CORRUPTED_GRACE.

Proof of Concept

Add the following test to a contract inheriting BaseConfidencePoolTest, or run the existing test at test/poc/PostExpiryCorruption.t.sol:

function testCorruptionAfterGraceReceivesNoModeratorReviewWindow() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
vm.warp(BASE_TIMESTAMP + 1 days);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
// The agreement remains attackable beyond both the independent pool
// deadline and the expiry-anchored moderator grace.
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// The fresh terminal state receives no moderator review window.
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertFalse(pool.goodFaith());
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE);
assertEq(token.balanceOf(alice), 0);
}

Run:

forge test --match-contract PostExpiryCorruptionPoC -vv

Recommended Mitigation

Bind CORRUPTED resolution to an authenticated transition time rather than to the state observed on a later call. The pool should reject CORRUPTED for this term when the transition occurred after expiry, and the review interval should begin at the valid corruption transition or its authenticated first observation.

The following diff is illustrative; it assumes the registry is extended with a trustworthy per-agreement terminal timestamp:

IAttackRegistry.ContractState state = _observePoolState();
+uint256 corruptedAt = attackRegistry.getAgreementCorruptedAt(agreement);
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
- if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
+ // A breach outside this pool's term cannot confiscate expired capital.
+ if (corruptedAt == 0 || corruptedAt > expiry) {
+ _resolveExpired();
+ return;
+ }
+
+ // Give every valid corruption a complete moderator review interval.
+ if (block.timestamp < corruptedAt + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
// ...
}

When the dependency cannot provide an authenticated transition timestamp, the protocol needs an explicit deadline checkpoint or another source of historical state. Merely preserving the current clamp to expiry is insufficient because it rewrites the arithmetic timestamp without authenticating event provenance.

Support

FAQs

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

Give us feedback!