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

claimExpired() reverts for subsequent stakers after auto-SURVIVED resolution

Author Revealed upon completion

Root + Impact

Description

  • The contract allows stakers to call claimExpired() post-expiry to mechanically resolve the pool and claim their portion of the stakes and bonus pool.

  • When the first caller executes claimExpired() under the PRODUCTION registry state, the pool auto-resolves to SURVIVED. Once this outcome is set, any subsequent stakers calling claimExpired() will revert due to a check that only accepts UNRESOLVED or EXPIRED outcomes, forcing subsequent stakers to call claimSurvived() instead.

// In src/ConfidencePool.sol
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
@> if (outcome != PoolStates.Outcome.UNRESOLVED && outcome != PoolStates.Outcome.EXPIRED) {
revert InvalidOutcome();
}
...

Risk

Likelihood:

  • A pool expires when the agreement is in the PRODUCTION state.

  • The first caller triggers mechanical resolution via claimExpired(), setting the outcome to SURVIVED.

  • Subsequent stakers or automated vaults attempt to claim using the claimExpired() entrypoint.


Impact:

  • Subsequent stakers' calls to claimExpired() revert.

  • Automated vaults or claiming scripts experience runtime transaction failures if they do not dynamically branch and call claimSurvived().

Proof of Concept

The test file LogicalFindingsValidation.t.sol contains the PoC.

The PoC sets up two stakers (alice and bob) and advances time to expiry with the registry in the PRODUCTION state. alice calls claimExpired() first, which auto-resolves the pool to SURVIVED. When bob then calls claimExpired(), the transaction reverts with InvalidOutcome because the outcome is now SURVIVED, not UNRESOLVED or EXPIRED. bob is forced to call claimSurvived() to retrieve their funds.
How to Run

Run the test from the workspace directory using the following Foundry command:

bashforge test --match-test testFinding2_secondStakerClaimExpiredRevertsAfterAutoSurvived -vv

Output Result

textNo files changed, compilation skipped
Ran 1 test for test/unit/LogicalFindingsValidation.t.sol:LogicalFindingsValidationTest
\[PASS] testFinding2\_secondStakerClaimExpiredRevertsAfterAutoSurvived() (gas: 559288)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.45ms (2.86ms CPU time)
Ran 1 test suite in 11.09ms (4.45ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

function testFinding2_secondStakerClaimExpiredRevertsAfterAutoSurvived() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.warp(pool.expiry());
vm.prank(alice);
pool.claimExpired(); // auto-SURVIVED
vm.prank(bob);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.claimExpired();
vm.prank(bob);
pool.claimSurvived(); // funds recoverable here
assertEq(token.balanceOf(bob), 50 * ONE);
}

Recommended Mitigation

Update claimExpired() to accept the SURVIVED outcome and route payouts to stakers accordingly, preventing post-resolution reverts.

diff --git a/src/ConfidencePool.sol b/src/ConfidencePool.sol
index fc96d2f..244b413 100644
--- a/src/ConfidencePool.sol
+++ b/src/ConfidencePool.sol
@@ -512,5 +512,5 @@ contract ConfidencePool is Initializable, Ownable2Step, ReentrancyGuard, Pausabl
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();
}

Support

FAQs

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

Give us feedback!