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

After SURVIVED, later stakers must switch from `claimExpired` to `claimSurvived`

Author Revealed upon completion

Root + Impact

Description

claimExpired both resolves an unresolved pool after expiry and can pay the calling staker on the PRODUCTION -> SURVIVED auto-resolve path. Once outcome is SURVIVED, the same function rejects every later call with InvalidOutcome remaining stakers must use claimSurvived instead.

A non-staker can finalize SURVIVED first , after which no staker can ever be paid via claimExpired. Integrators that only wire claimExpired after expiry see permanent reverts while funds remain claimable through the twin entrypoint.

function claimExpired() external nonReentrant {
// @> After auto-SURVIVED, outcome is SURVIVED this gate rejects all later callers.
if (outcome != PoolStates.Outcome.UNRESOLVED && outcome != PoolStates.Outcome.EXPIRED) {
revert InvalidOutcome();
}
// ...
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED; // @> first caller may still be paid below
// ...
}
}

Risk

Likelihood:

  • Post-expiry PRODUCTION auto-resolve is a normal path; the first caller (staker or non-staker) sets outcome = SURVIVED.

  • SDKs / bots that reuse claimExpired for every staker after the first resolve hit InvalidOutcome until they switch entrypoints.

Impact:

  • Remaining stakers appear stranded under a claimExpired-only integration even though principal + bonus are fully claimable via claimSurvived.

  • Non-staker finalize makes the wrong-entrypoint trap apply to the entire cohort.

Proof of Concept

Run:

forge test --match-test test_PoC_claimExpiredAfterSurvived_requiresClaimSurvived -vv
// test/unit/ClaimExpiredRegistryGate.t.sol
function test_PoC_claimExpiredAfterSurvived_requiresClaimSurvived() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 30 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.warp(pool.expiry());
vm.prank(alice);
pool.claimExpired(); // PRODUCTION -> SURVIVED, alice paid
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.SURVIVED));
vm.prank(bob);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.claimExpired(); // same entrypoint now dead
vm.prank(bob);
pool.claimSurvived(); // twin works bob recovers principal
}

Recommended Mitigation

function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
- if (outcome != PoolStates.Outcome.UNRESOLVED && outcome != PoolStates.Outcome.EXPIRED) {
+ if (
+ outcome != PoolStates.Outcome.UNRESOLVED
+ && outcome != PoolStates.Outcome.EXPIRED
+ && outcome != PoolStates.Outcome.SURVIVED
+ ) {
revert InvalidOutcome();
}
// ... resolve if UNRESOLVED, then pay when SURVIVED or EXPIRED

Or document/SDK-enforce a hard route: after SURVIVED, only claimSurvived, with a dedicated error (e.g. UseClaimSurvived).

Support

FAQs

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

Give us feedback!