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

`claimExpired` can be permanently blocked, trapping staker funds for 180 days

Author Revealed upon completion

Root + Impact

Description

The claimExpired function serves as a mechanical backstop for stakers to retrieve their funds after the pool's expiry. It contains logic to resolve the pool's outcome if it is still UNRESOLVED. If the underlying agreement is in a CORRUPTED state, the function is designed to revert until a MODERATOR_CORRUPTED_GRACE period (180 days) has passed.

However, the call to _observePoolState(), which can set the riskWindowStart timestamp, occurs before this grace period check. A malicious actor can call claimExpired immediately after expiry when the agreement is CORRUPTED. This call will revert on the grace period check, but not before _observePoolState() has set riskWindowStart. Once riskWindowStart is set, the condition state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0 will always be true in subsequent calls, causing them to revert with AgreementCorruptedAwaitingModerator until the 180-day grace period ends. This allows a single, permissionless call to lock all stakers' funds in the pool for 180 days, even if their funds should have been claimable.

// Root cause in the codebase with @> marks to highlight the relevant section
function claimExpired() external nonReentrant {
// ...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
@> IAttackRegistry.ContractState state = _observePoolState();
// ...
@> if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
// ...
@> if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
@> revert AgreementCorruptedAwaitingModerator();
}
// ...
}
// ...
}
// ...
}

Risk

Likelihood: High

  • Reason 1

    • This occurs when any user calls claimExpired in the specific window after expiry but before the MODERATOR_CORRUPTED_GRACE period has elapsed, while the agreement is CORRUPTED.

  • Reason 2

    • This is a likely scenario as users will rush to claim funds post-expiry, and a CORRUPTED agreement state is a key part of the protocol's lifecycle.

Impact: High

  • Impact 1

    • All stakers are prevented from claiming their funds via claimExpired for up to 180 days.

  • Impact 2

    • This constitutes a major denial of service against a core function, temporarily freezing all staked capital and undermining the purpose of the expiry backstop.

Proof of Concept

// Add this test to a relevant test file, like `ConfidencePool.t.sol`
function test_PoC_claimExpired_DenialOfService() public {
// 1. Setup: A staker deposits into the pool.
uint256 stakeAmount = 1000e18;
vm.prank(staker);
pool.stake(stakeAmount);
// 2. Setup: The agreement becomes attackable, which allows riskWindowStart to be set.
vm.prank(BATTLECHAIN_REGISTRY_OWNER);
attackRegistry.setAgreementState(address(agreement), IAttackRegistry.ContractState.UNDER_ATTACK);
// 3. A user interacts with the pool, setting riskWindowStart.
vm.prank(staker);
pool.pokeRiskWindow();
assert(pool.riskWindowStart() > 0);
// 4. Setup: The agreement becomes corrupted.
vm.prank(BATTLECHAIN_REGISTRY_OWNER);
attackRegistry.setAgreementState(address(agreement), IAttackRegistry.ContractState.CORRUPTED);
// 5. Time warp: Move to just after the pool's expiry.
vm.warp(pool.expiry() + 1 days);
// 6. Attack: A user (or attacker) calls claimExpired. The call reverts because the
// moderator grace period is active. This is the call that poisons the state.
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
// 7. Verification: Move forward in time, but still within the 180-day grace period.
// Any subsequent call to claimExpired by any staker will also revert.
vm.warp(pool.expiry() + 90 days);
vm.prank(staker);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
log("PoC successful: claimExpired is blocked, trapping staker funds.");
// 8. (Optional) Show that after the grace period, the function is unblocked.
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1 days);
pool.claimExpired(); // This call now proceeds to the CORRUPTED outcome.
assertEq(uint(pool.outcome()), uint(PoolStates.Outcome.CORRUPTED));
}

Recommended Mitigation

Move the call to _observePoolState() and the state snapshotting logic to occur after the AgreementCorruptedAwaitingModerator check. This ensures that a reverting call cannot change the state in a way that blocks future legitimate calls.

Support

FAQs

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

Give us feedback!