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

A single staker's claim permanently finalizes the pool outcome for everyone, freezing other stakers' still-in-contract principal into a wrong resolution

Author Revealed upon completion

Description

Normal behavior. After the moderator flags an outcome, participants claim their entitlement. The resolution is meant to become final only once value has actually been distributed, so that already-paid amounts cannot be clawed back.

Issue. claimsStarted is a single, pool-wide flag. The first participant to claim sets it, and once set, the outcome can never be re-resolved for the entire pool. Because the flag is global rather than per-position, one self-interested staker can permanently lock a favorable-but-wrong outcome for everyone — including other stakers whose principal is still held by the contract, and the recoveryAddress that should have received it. The lock therefore freezes funds that have not moved, not just the claimant's own paid-out amount.

// ConfidencePool.sol — claimSurvived(): ANY single claim sets the pool-wide latch
@> if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(msg.sender, payout);
// ConfidencePool.sol — flagOutcome(): once the pool-wide latch is set, re-resolution is blocked for ALL
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();

Risk

Likelihood:

  • An outcome is flagged SURVIVED while the true in-scope outcome is CORRUPTED (an in-scope breach that should slash stakers).

  • Any staker calls claimSurvived() immediately; it needs only a nonzero stake and is permissionless.

  • The claimant is economically motivated: claiming keeps principal (and bonus) that a CORRUPTED resolution would send to recoveryAddress.

Impact:

  • The pool outcome is permanently frozen as SURVIVED.

  • Every staker — including those who never claimed and whose principal is still in the contract at the moment of the lock — can then withdraw their stake, evading slashing.

  • The recoveryAddress receives nothing, although the entire pool should have been swept to it.


Proof of Concept

Two stakers deposit 100 each (200 in the pool). The agreement is CORRUPTED (in-scope breach → the whole pool should sweep to recoveryAddress). One staker claims first; that single claim locks the outcome pool-wide, and the second staker — whose 100 was still in the contract when the lock happened — walks out with it too.

Place in test/unit/ and run:
forge test --match-test test_oneClaimLocks -vv

Output:

bob (unclaimed at lock) still extracted: 100
recovery received (should be 200): 0
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {console2 as console} from "forge-std/console2.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract PoC_GlobalLock is BaseConfidencePoolTest {
uint256 constant STAKE = 100 * ONE;
function test_oneClaimLocks_otherStakersUnclaimedFunds() external {
_stake(alice, STAKE);
_stake(bob, STAKE); // 200 in the pool
// in-scope breach: the correct outcome is CORRUPTED -> the whole pool goes to recoveryAddress
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// outcome is resolved SURVIVED
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// ALICE claims -> her 100 leaves, and this single claim sets the pool-wide latch
vm.prank(alice);
pool.claimSurvived();
assertTrue(pool.claimsStarted());
// BOB's 100 is STILL in the contract at the moment of the lock
assertEq(token.balanceOf(address(pool)), STAKE, "bob's principal still in the pool");
// re-resolving to CORRUPTED (so BOB's 100 goes to recovery) is now impossible pool-wide
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
// Bob walks out with funds that never left the contract at lock time
uint256 b0 = token.balanceOf(bob);
vm.prank(bob);
pool.claimSurvived();
uint256 bobGot = token.balanceOf(bob) - b0;
console.log("bob (unclaimed at lock) still extracted:", bobGot / ONE);
console.log("recovery received (should be 200):", token.balanceOf(recovery) / ONE);
assertEq(bobGot, STAKE, "one staker's claim froze bob's still-in-contract funds");
assertEq(token.balanceOf(recovery), 0, "in-scope CORRUPTED, yet recovery got nothing");
}
}

Recommended Mitigation

Decouple finality from the first claim, so a single claimant can no longer lock the whole pool. Record the flag time and require a short finalization delay before any claim is allowed; during that delay the outcome can still be re-resolved, but no claim can pre-empt it.

+ uint32 public outcomeFlaggedTimestamp;
+ uint256 public constant FINALIZATION_DELAY = 1 hours;
...
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
+ outcomeFlaggedTimestamp = uint32(block.timestamp);
...
}
function claimSurvived() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED) revert OutcomeNotSet();
+ // no claim may lock the pool-wide outcome during the correction window
+ if (block.timestamp < outcomeFlaggedTimestamp + FINALIZATION_DELAY) revert OutcomeNotSet();
...
}

Apply the same delay guard to every claim entrypoint that sets claimsStarted (claimCorrupted, claimAttackerBounty, sweepUnclaimedCorrupted, claimExpired).

Support

FAQs

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

Give us feedback!