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

Non-staker `claimExpired()` call closes the moderator correction window before any actual claim

Author Revealed upon completion

Root + Impact

Description

Normal behavior per the README / design docs is that the moderator may re-flag a correction until the first claim, with finality tied to value movement.
The issue is that claimExpired() sets claimsStarted = true during mechanical auto-resolution even when the caller has userEligible == 0 and receives no payout. That means any non-staker can finalize the pool after expiry and permanently close the moderator’s correction window before any real claim occurs.

function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome != PoolStates.Outcome.UNRESOLVED && outcome != PoolStates.Outcome.EXPIRED) {
revert InvalidOutcome();
}
if (outcome == PoolStates.Outcome.UNRESOLVED) {
// ... auto-resolution logic ...
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
// ...
@> claimsStarted = true;
return;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
} else {
outcome = PoolStates.Outcome.EXPIRED;
}
@> claimsStarted = true;
}
if (hasClaimed[msg.sender]) revert InvalidAmount();
uint256 userEligible = eligibleStake[msg.sender];
if (userEligible == 0) {
@> return; // no claim, no payout, but claimsStarted already latched
}
// actual payout path...
}

Risk

Likelihood:

  • any address can call claimExpired() once the pool expires

  • The issue occurs during routine post-expiry resolution, before any staker necessarily interacts

Impact:

  • The moderator loses the documented ability to correct the outcome until the first real claim

  • An incorrect auto-resolved outcome becomes final without any value movement, breaking the protocol’s stated finality rule

Proof of Concept

Use the regression test added during the audit:

Steps to run:

  1. cd confidence-pool

  2. forge test --match-test testZeroStakeCallerClosesReflagWindowBeforeAnyClaim -vv

Expected outcome:

  • The test passes

  • A non-staker calls claimExpired()

  • They receive no payout and do not become a claimant

  • claimsStarted is still flipped to true

  • A later moderator correction reverts with OutcomeAlreadySet

function testZeroStakeCallerClosesReflagWindowBeforeAnyClaim() external {
// README / DESIGN promise the correction window closes on the first claim, i.e. value
// movement. In practice a non-staker can mechanically resolve post-expiry and flip the
// finality latch before anyone has claimed.
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.warp(pool.expiry());
assertFalse(pool.claimsStarted(), "precondition: re-flag window open");
uint256 daveBefore = token.balanceOf(dave);
vm.prank(dave);
pool.claimExpired();
assertEq(token.balanceOf(dave), daveBefore, "non-staker received no payout");
assertFalse(pool.hasClaimed(dave), "non-staker did not claim");
assertTrue(pool.claimsStarted(), "re-flag window closed without a claim");
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
}

Recommended Mitigation

Only latch claimsStarted when value actually moves or when the caller has a real claim.

- claimsStarted = true;
}
if (hasClaimed[msg.sender]) revert InvalidAmount();
uint256 userEligible = eligibleStake[msg.sender];
if (userEligible == 0) {
return;
}
+ if (!claimsStarted) claimsStarted = true;

Support

FAQs

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

Give us feedback!