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

No Claim Deadline in SURVIVED / EXPIRED Paths Allows a Single Non-Claimer to Permanently Lock Their Principal and Bonus Entitlement

Author Revealed upon completion

Root + Impact

Description

  • In the normal SURVIVED / EXPIRED flow, stakers are expected to claim their principal plus their time-weighted bonus share through claimSurvived() or the payout branch of claimExpired().

  • The issue is that these claim paths have no deadline, while sweepUnclaimedBonus() permanently reserves outstanding staker principal and bonus entitlement. As a result, a single staker who never claims can permanently keep their principal and bonus share reserved inside the contract, preventing recovery through sweepUnclaimedBonus().

Risk

Likelihood:

  • A staker leaves funds unclaimed after a SURVIVED or EXPIRED resolution where riskWindowStart != 0.

  • The protocol documentation describes non-claimers' forfeited shares as sweepable, but the implementation never triggers forfeiture for these claim paths.

Impact:

  • The non-claimer’s principal remains permanently locked in the contract.

  • The non-claimer’s bonus entitlement remains permanently reserved, preventing sweepUnclaimedBonus() from recovering it to recoveryAddress.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract NonClaimerSharesNotActuallySweepableTest is BaseConfidencePoolTest {
uint256 internal constant TEN_YEARS = 3650 days;
function testPoC_survivedNonClaimerPrincipalAndBonusRemainReservedForever() external {
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 100 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
pool.claimSurvived();
assertEq(pool.totalEligibleStake(), 100 * ONE, "Bob principal remains tracked");
assertEq(pool.claimedBonus(), 50 * ONE, "Alice claimed only her half of the bonus");
assertEq(pool.snapshotTotalBonus() - pool.claimedBonus(), 50 * ONE, "Bob bonus remains unclaimed");
vm.warp(block.timestamp + TEN_YEARS);
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.sweepUnclaimedBonus();
assertEq(pool.totalEligibleStake(), 100 * ONE, "Bob principal is still reserved after 10 years");
assertEq(pool.snapshotTotalBonus() - pool.claimedBonus(), 50 * ONE, "Bob bonus is still reserved");
assertEq(token.balanceOf(address(pool)), 150 * ONE, "Bob principal plus bonus remain locked in pool");
}
}

Recommended Mitigation

+ uint256 public constant SURVIVED_EXPIRED_CLAIM_WINDOW = 180 days;
+ uint256 public survivedExpiredClaimDeadline;
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
...
outcome = newOutcome;
+ if (newOutcome == PoolStates.Outcome.SURVIVED) {
+ survivedExpiredClaimDeadline = block.timestamp + SURVIVED_EXPIRED_CLAIM_WINDOW;
+ }
...
}
function claimExpired() external nonReentrant {
...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
...
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
+ survivedExpiredClaimDeadline = block.timestamp + SURVIVED_EXPIRED_CLAIM_WINDOW;
} else {
outcome = PoolStates.Outcome.EXPIRED;
+ survivedExpiredClaimDeadline = block.timestamp + SURVIVED_EXPIRED_CLAIM_WINDOW;
}
}
+ if (block.timestamp > survivedExpiredClaimDeadline) revert ClaimWindowExpired();
...
}
function claimSurvived() external nonReentrant {
+ if (block.timestamp > survivedExpiredClaimDeadline) revert ClaimWindowExpired();
...
}
function sweepUnclaimedBonus() external nonReentrant {
...
+ if (survivedExpiredClaimDeadline != 0 && block.timestamp > survivedExpiredClaimDeadline) {
+ uint256 amount = stakeToken.balanceOf(address(this));
+ if (amount == 0) revert NothingToSweep();
+
+ totalEligibleStake = 0;
+ totalBonus = 0;
+
+ stakeToken.safeTransfer(recoveryAddress, amount);
+ emit BonusSwept(msg.sender, recoveryAddress, amount);
+ return;
+ }
...
}

Support

FAQs

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

Give us feedback!