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

Zero‑Stake claimExpired Forces Finality Without Payout

Author Revealed upon completion

Root + Impact

The claimExpired function unconditionally sets claimsStarted = true during pool resolution, even when the caller has zero stake and no value is transferred. This permanently closes the moderator’s re‑flag window with a zero‑value transaction, locking in an incorrect outcome if the moderator later needs to correct it.

Description

  • The moderator can re‑flag the pool outcome until the first claim occurs (claimsStarted = false). This allows the moderator to correct a mistaken outcome.

  • A caller with zero stake can invoke claimExpired, which resolves the pool and sets claimsStarted = true even though no value is transferred. This permanently closes the re‑flag window.

// src/ConfidencePool.sol claimExpired() – resolution path
outcome = PoolStates.Outcome.SURVIVED; // (or EXPIRED)
// ...
claimsStarted = true; // @> Sets finality latch regardless of caller's stake
emit OutcomeFlagged(...);
// later in the same function, after early‑return for zero‑stake:
if (hasClaimed[msg.sender]) revert InvalidAmount();
uint256 userEligible = eligibleStake[msg.sender];
if (userEligible == 0) {
return; // @> Zero‑stake caller exits without claiming, but claimsStarted already set
}

Risk

Likelihood:

  • Any external account can call claimExpired after expiry, including those with zero stake.

  • The attack requires no special conditions; simply being past expiry.

Impact:

  • The moderator’s re‑flag window is permanently closed, potentially locking an incorrect outcome.

  • Stakers may suffer indirect loss if the true outcome should have been different (e.g., good‑faith CORRUPTED bounty).

Proof of Concept

Explanation:

A pool is created and a staker deposits 100 tokens. After expiry, a zero‑stake account calls claimExpired. The function resolves the pool to SURVIVED (because the registry state is PRODUCTION) and sets claimsStarted = true. The zero‑staker has no stake, so the function returns early without any transfer.

Then, the legitimate outcome moderator attempts to re‑flag the outcome to good‑faith CORRUPTED (simulating a later discovery of a breach), but the call reverts with OutcomeAlreadySet because claimsStarted is already true. This shows that the moderator’s re‑flag window was closed by a zero‑value transaction.

function test_M2_ZeroStakeClaimExpiredFinality() public {
MockRegistry registry = new MockRegistry();
registry.setAgreementState(agreement, IAttackRegistry.ContractState.PRODUCTION);
address sponsor = makeAddr("sponsor");
address staker = makeAddr("staker");
address zeroStaker = makeAddr("zeroStaker");
vm.prank(sponsor);
ConfidencePool pool = factory.createPool(agreement, address(stakeToken), expiry, minStake, sponsor, accounts);
deal(address(stakeToken), staker, 100 ether);
vm.startPrank(staker);
stakeToken.approve(address(pool), 100 ether);
pool.stake(100 ether);
vm.stopPrank();
vm.warp(expiry + 1 days);
vm.prank(zeroStaker);
pool.claimExpired();
vm.prank(outcomeModerator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, sponsor);
}

Recommended Mitigation

Mitigation explanation:

The fix moves the claimsStarted = true assignment from the resolution block into the actual claim branch that executes only when the caller has a non‑zero stake and a valid claim. The resolution of the pool (setting outcome) is kept separate from the finality latch.

A zero‑stake caller can still trigger the pool’s resolution (useful for mechanically advancing a stuck pool), but it does not close the moderator’s re‑flag window. The re‑flag window now ends only when a genuine claimant receives tokens, matching the design intent of “finality is value‑movement.”

- claimsStarted = true; // set unconditionally during resolution
+ // Defer claimsStarted until actual value movement occurs
+ // Inside the non-zero-stake claim branch:
+ if (!claimsStarted) claimsStarted = true;

Support

FAQs

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

Give us feedback!