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

Stakers lose their full principal to a corruption that happens after the pool expiry, because claimExpired resolves on the registry state at call time instead of at the term boundary

Author Revealed upon completion

Resolving the outcome from the registry state at call time instead of at the term boundary lets a corruption that appears after expiry sweep staker principal

Description

  • A confidence pool pays stakers back their principal plus a bonus once the agreement survives the term the stakers signed up for, and that term ends at expiry. DESIGN section 2 states that reaching expiry while the agreement is still in an active risk state means the stakers survived the full term they underwrote, so the pool resolves EXPIRED and returns principal plus bonus, with no breach to settle.

  • claimExpired decides the outcome from the registry state read at the moment it is called, not from the state at expiry. A corruption that first appears after the term already ended can therefore still flip the pool into CORRUPTED and sweep every staker's principal to the recovery address, punishing stakers for a breach that happened outside the term they underwrote. The registry is memoryless, so the pool cannot tell a during term breach flagged late apart from a genuinely after term breach, and treats both the same. DESIGN section 5 only covers the opposite riskWindowStart == 0 case and section 6 only accepts a loss on the scope axis, never on the timing axis, so this case sits in the gap between them.

function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
@> IAttackRegistry.ContractState state = _observePoolState(); // reads state NOW, not at expiry
...
@> if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
@> outcome = PoolStates.Outcome.CORRUPTED; // after-term breach sweeps staker principal
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
return;
}
...
} else {
@> outcome = PoolStates.Outcome.EXPIRED; // this branch would have returned principal
outcomeFlaggedAt = expiry;
}
}
...
}

Risk

Likelihood:

  • Occurs when the risk window opened during the term so riskWindowStart is nonzero, the agreement reaches CORRUPTED after expiry, and the registry is observed in that CORRUPTED state before any staker calls claimExpired to lock in EXPIRED.

  • Occurs when the moderator is absent for the full grace window, letting anyone auto resolve bad faith CORRUPTED after expiry + MODERATOR_CORRUPTED_GRACE, or when a present moderator reads the on chain CORRUPTED state and flags CORRUPTED in good faith, which is the honest reading of an in scope corruption.

Impact:

  • Stakers lose 100 percent of their principal plus bonus to the recovery address for a corruption that happened outside the term they underwrote, directly contradicting the DESIGN section 2 guarantee that surviving to expiry returns principal plus bonus.

  • Settlement of one single real world event depends on a race between a staker claiming and the registry transition being observed, so two identical pools can settle in opposite directions and stakers in the same situation get unequal outcomes.

Proof of Concept

Both tests below pass against the existing test harness. They share identical setup and differ only in whether the staker claims before or after the corruption is observed, proving the outcome is pure call timing. Drop into test/unit/ and run with forge test --match-path "test/unit/PostExpiryCorruption.poc.t.sol" -vv.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract PostExpiryCorruptionPoc is BaseConfidencePoolTest {
// Risk window opens DURING the term; agreement is still only UNDER_ATTACK at expiry, so per
// DESIGN section 2 the staker survived the full term and is owed EXPIRED (principal back).
function _setupSurvivedTermWithOpenRiskWindow() internal {
_stake(alice, 100 * ONE);
_passThroughUnderAttack(); // seals riskWindowStart != 0 while still in-term
vm.warp(pool.expiry() + 1); // term ends with agreement still merely UNDER_ATTACK
assertTrue(pool.riskWindowStart() != 0, "risk window opened during the term");
}
// LOSS: a breach corrupts the agreement AFTER expiry and is observed before any staker claims.
// The staker loses 100% of principal to the recovery address for an after-term breach.
function test_PostExpiryBreach_SweepsStakerPrincipal() external {
_setupSurvivedTermWithOpenRiskWindow();
// A separate incident corrupts the agreement after the term already ended.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Moderator absent for the whole grace window: permissionless auto-CORRUPTED fires.
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(dave); // any non-staker can finalize
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "auto-CORRUPTED");
pool.claimCorrupted();
assertEq(token.balanceOf(alice), 0, "staker lost her entire principal");
assertEq(token.balanceOf(recovery), 100 * ONE, "principal swept for an after-term breach");
}
// WIN: identical situation, but the staker claims one block after expiry, before the corruption
// is observed. She resolves EXPIRED and recovers her full principal. Only the timing differs.
function test_SameSituation_ClaimingBeforeObservation_ReturnsPrincipal() external {
_setupSurvivedTermWithOpenRiskWindow();
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "resolves EXPIRED");
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "staker keeps her full principal");
assertEq(token.balanceOf(recovery), 0, "nothing swept to recovery");
}
}

Result:

[PASS] test_PostExpiryBreach_SweepsStakerPrincipal() (gas: 459117)
[PASS] test_SameSituation_ClaimingBeforeObservation_ReturnsPrincipal() (gas: 416468)
Suite result: ok. 2 passed; 0 failed; 0 skipped

Recommended Mitigation

Anchor the outcome to the term boundary rather than the resolution call time. Record the true, uncapped timestamp at which the terminal state was first observed, and only allow the CORRUPTED path when that observation happened at or before expiry. A corruption first seen after expiry is outside the underwritten term and must resolve EXPIRED. Apply the same term boundary check to the moderator path in flagOutcome.

+ uint256 public riskWindowEndObservedAt; // uncapped time the terminal state was first seen
function _markRiskWindowEnd() internal {
uint256 t = block.timestamp;
+ riskWindowEndObservedAt = t; // keep the real time, before the expiry cap
if (t > expiry) t = expiry;
riskWindowEnd = uint32(t);
emit RiskWindowEnded(t);
}
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
- if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
- revert AgreementCorruptedAwaitingModerator();
+ // A corruption first observed after the term is outside what the stakers underwrote.
+ // Resolve EXPIRED and return principal, per DESIGN section 2.
+ if (riskWindowEndObservedAt > expiry) {
+ outcome = PoolStates.Outcome.EXPIRED;
+ outcomeFlaggedAt = expiry;
+ claimsStarted = true;
+ emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
+ } else {
+ 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;
+ }
}

Support

FAQs

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

Give us feedback!