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) {
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;
}
}
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:
cd confidence-pool
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 {
_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;