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

Post-expiry corruption can retroactively confiscate funds from a pool that already survived its term

Author Revealed upon completion

Root + Impact

Description

A confidence pool only underwrites the agreement until expiry.

When the agreement is still in an active-risk state at expiry, the pool has survived the complete period stakers agreed to underwrite. The intended result is therefore EXPIRED, returning staker principal and bonus.

However, expiration is not recorded automatically at the deadline. claimExpired() instead reads the agreement's current registry state whenever someone eventually calls it:

function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
// @> This may execute days or months after the pool's coverage ended.
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
// @> Any CORRUPTED state visible at claim time is treated as corruption
// @> of the expired pool, even if the transition happened after expiry.
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;
emit OutcomeFlagged(
address(0),
PoolStates.Outcome.CORRUPTED,
false,
address(0)
);
return;
}
...
}
}

The moderator path has the same problem. flagOutcome() only verifies that the registry is currently CORRUPTED; it does not verify that the corruption occurred before the pool expired:

} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
...
// @> No comparison between the corruption timestamp and pool.expiry.
if (
state != IAttackRegistry.ContractState.CORRUPTED
) {
revert InvalidOutcome();
}
}

This permits the following sequence:

  1. Stakers deposit principal and the risk window opens.

  2. The pool reaches expiry while the agreement remains UNDER_ATTACK.

  3. At this point, the pool has survived its complete term and should resolve as EXPIRED.

  4. Nobody immediately calls claimExpired().

  5. The agreement becomes CORRUPTED after the pool's expiry.

  6. The moderator can now flag the expired pool as CORRUPTED.

  7. If the moderator is unavailable, anyone can obtain the same result through claimExpired() after the 180-day grace period.

  8. The entire principal and bonus balance is transferred to recoveryAddress or to a named good-faith attacker.

BattleChain's active states can subsequently transition to CORRUPTED, so remaining active after pool expiry does not imply that a later corruption occurred during the pool's term. :contentReference[oaicite:1]{index=1}

The implementation even caps a terminal observation made after expiry back to expiry:

function _markRiskWindowEnd() internal {
uint256 t = block.timestamp;
// @> This removes evidence that the terminal state was first observed
// @> only after the pool's coverage period had ended.
if (t > expiry) t = expiry;
riskWindowEnd = uint32(t);
emit RiskWindowEnded(t);
}

Consequently, the pool cannot distinguish:

corruption during the insured term

from:

corruption after the insured term but before settlement

Stakers are therefore required to race to settle immediately at expiry, although no such liveness requirement is imposed on their claims.

The repository defines expiry during an active-risk state as successful completion of the underwritten term, but the live-state resolution logic can retroactively reverse that result. :contentReference[oaicite:2]{index=2}

Risk

All staker principal and all contributed bonus can be confiscated because of an event that occurred only after the pool's coverage period ended.

A whitehat may also exploit an in-scope contract after the confidence pool has expired and then receive the entire expired pool if the moderator flags good-faith CORRUPTED.

For the permissionless fallback, the funds are sent to the sponsor-controlled recoveryAddress after the moderator grace period.

Consequences include:

  • complete loss of staker principal;

  • complete loss of the bonus pool;

  • payment to a whitehat for risk that was outside the agreed pool term;

  • a race requiring stakers to finalize exactly when the pool expires;

  • different outcomes for economically identical pools solely because one was settled sooner.

The maximum loss is the complete token balance held by the pool.

Proof of Concept

Add the following test to a contract inheriting BaseConfidencePoolTest:

function test_PostExpiryCorruptionRetroactivelyConfiscatesPool()
external
{
uint256 principal = 100 * ONE;
uint256 bonus = 50 * ONE;
_stake(alice, principal);
_contributeBonus(carol, bonus);
// The agreement is legally attackable during the pool term.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
uint256 expiryTs = pool.expiry();
// The pool reaches its deadline while still active.
// It has now survived the complete term underwritten by stakers.
vm.warp(expiryTs);
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.UNRESOLVED)
);
// Nobody submits the settlement transaction immediately.
// A new corruption occurs only after coverage has ended.
vm.warp(expiryTs + 1);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
// The moderator remains unavailable, so the permissionless
// fallback is called after the grace period.
vm.warp(
expiryTs + pool.MODERATOR_CORRUPTED_GRACE()
);
vm.prank(dave);
pool.claimExpired();
// The post-expiry breach has retroactively changed the
// already-survived pool into CORRUPTED.
assertEq(
uint256(pool.outcome()),
uint256(PoolStates.Outcome.CORRUPTED)
);
// The observation was made after expiry but was capped to expiry.
assertEq(pool.riskWindowEnd(), expiryTs);
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(
token.balanceOf(recovery) - recoveryBefore,
principal + bonus,
"entire expired pool was confiscated"
);
assertEq(token.balanceOf(address(pool)), 0);
// Alice can no longer recover any principal.
vm.prank(alice);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.claimExpired();
vm.prank(alice);
vm.expectRevert(IConfidencePool.OutcomeNotSet.selector);
pool.claimSurvived();
}

The moderator path can be demonstrated without waiting for the grace period:

function test_ModeratorCanAwardPoolForPostExpiryCorruption()
external
{
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
uint256 expiryTs = pool.expiry();
vm.warp(expiryTs + 1);
// Corruption occurs after the pool's term.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
attacker
);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(
token.balanceOf(attacker) - attackerBefore,
150 * ONE,
"post-expiry attacker received the complete expired pool"
);
}

Recommended Mitigation

The outcome must be based on the registry state at the end of the insured term, not merely on the state visible when settlement is eventually submitted.

The robust solution is for the attack registry to expose a canonical timestamp for the transition to CORRUPTED:

uint256 corruptedAt =
attackRegistry.getCorruptionTimestamp(agreement);
if (
state == IAttackRegistry.ContractState.CORRUPTED
&& corruptedAt <= expiry
) {
// Resolve CORRUPTED.
} else {
// A corruption after expiry cannot affect this pool.
// Resolve EXPIRED or SURVIVED as appropriate.
}

The same check must be applied in both:

  • flagOutcome(CORRUPTED, ...);

  • the auto-CORRUPTED branch of claimExpired().

Do not use riskWindowEnd as a substitute for the canonical transition timestamp. It records only when the pool first observed the state, and the current implementation additionally caps all post-expiry observations to expiry.

If the upstream registry cannot provide historical transition timestamps, the protocol cannot safely infer whether a currently visible corruption occurred during the pool term. In that case, it should either:

  • remove the scope-blind automatic confiscation path and require an explicit moderator decision that includes temporal validation; or

  • introduce a trusted expiry keeper that finalizes pools at the deadline, while clearly documenting this as a required liveness dependency.

Support

FAQs

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

Give us feedback!