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

[L-1] `ConfidencePool::sweepUnclaimedCorrupted` sets `claimsStarted` thus closing the re-flag window without a claim ever ocurring

Author Revealed upon completion

Description:

The contract has two structurally identical "sweep whatver nobody claimed" functions. Simply one for the SURVIVED/EXPIRED bonus leftovers and one for the CORRUPTED good-faith leftovers.

As seen from the 2 functions:

Both are permissionless.
Both gate on a deadline/outcome check.
Both transfer residual funds to recoveryaddress.

Their treatment towards the claimStarted bool, the finality latch that permanently close the moderator's flagOutcome re-flag correction window diverges:

The sweepUnclaimedBonus function:

function sweepUnclaimedBonus() external nonReentrant {
...
// Intentionally does NOT set claimsStarted. A direct-transfer donation of as little as 1
// wei would otherwise let anyone flip the flag post-flagOutcome and block the moderator's
// documented pre-claim re-flag window. Genuine reliance only comes from claim entrypoints.
stakeToken.safeTransfer(recoveryAddress, amount);
}

The sweepUnclaimedCorrupted function:

function sweepUnclaimedCorrupted() external nonReentrant {
...
if (!claimsStarted) claimsStarted = true; // <-- sets the latch; bonus sibling never does
stakeToken.safeTransfer(recoveryAddress, amount);
}

As observed, the claimStarted is documented in the docs/design.md as a value-movement finality latch (closing the moderator's window specifically because " a claim is the distribution-locking event"). The bonus side comment firmly defends why a sweep should never trip this latch: a trivial donation on the other hand could otherwise let anyone grief the moderator's correction window through a non-claim entrypoint.

The sweepUnclaimedCorrupted function is permissionless(triggered by anyone). Moving a possibly donation-inflated balanceOf amount but received the opposite treatement with no comment explaining the divergence.

Impact:

From my lens, a permissionless sweep, not a claim can close the moderator's correction window. If the named attacker never claims within the 180 day window, sweepUnclaimedCorrupted becomes the first interaction to set claimsStarted thus permanently blocking any flagOutcome correction i.e a wrong attacker address even though no participant ever actually claimed a payout. This breaks the finality guarantee the contract documents for itself: correction access should end on value movement not on cleanup.

Proof of Concept:

Kindly place this under test/SweepClaimsStartedAssymetry.t.sol and run:

forge test --mt test_SweepUnclaimedCorrupted_ClosesReflagWindow_UnlikeSweepUnclaimedBonus -vvvv
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.26;
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
/// @notice POC for my Low: `sweepUnclaimedCorrupted()` sets `claimsStarted` thus permanently closing
/// the moderator's documented pre-claim re-flag correction window where its structural sibling
/// `sweepUnclaimedBonus()` deliberately does NOT, specifically to avoid a non-claim sweep being
/// able to trip that latch. This test proves the asymmetry directly, side by side, on the same
/// kind of event (a permissionless "sweep the unclaimed leftovers" call) with no participant claim
/// ever occurring on either side.
contract SweepClaimsStartedAsymmetryTest is BaseConfidencePoolTest {
function test_SweepUnclaimedCorrupted_ClosesReflagWindow_UnlikeSweepUnclaimedBonus() external {
address namedAttacker = makeAddr("namedAttacker");
// ═══════════════════════════════════════════════════════════════════════════════
// SIDE A >> CORRUPTED path: sweepUnclaimedCorrupted() sets claimsStarted, even
// though nobody ever claimed anything.
// ═══════════════════════════════════════════════════════════════════════════════
_stake(alice, 100 * ONE);
_contributeBonus(carol, 20 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, namedAttacker);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
// Confirm the door is still open pre-sweep: claimsStarted is false, and the moderator
// could still correct a mistaken flag (e.g. wrong attacker address) right now.
assertFalse(pool.claimsStarted(), "claimsStarted must start false");
// The named attacker NEVER claims. Nobody claims anything at all. Warp past the
// 180-day claim window so the sweep becomes callable.
vm.warp(pool.corruptedClaimDeadline() + 1);
// Permissionless sweep >> functionally identical in spirit to sweepUnclaimedBonus:
// "nobody claimed, so send the leftovers to recoveryAddress."
pool.sweepUnclaimedCorrupted();
// PROOF POINT 1: claimsStarted flipped to true from a SWEEP, not a claim.
assertTrue(pool.claimsStarted(), "sweepUnclaimedCorrupted incorrectly latches claimsStarted");
// PROOF POINT 2: the moderator's documented pre-claim correction window is now
// permanently closed >> even though no participant ever actually claimed a payout.
vm.prank(moderator);
vm.expectRevert(); // OutcomeAlreadySet
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, alice);
// ═══════════════════════════════════════════════════════════════════════════════
// SIDE B >> SURVIVED path, same shape of event, on a freshly deployed sibling pool
// with its own registry so it can't be cross-contaminated by Side A's state.
// ═══════════════════════════════════════════════════════════════════════════════
MockSafeHarborRegistry safeHarborRegistryB = new MockSafeHarborRegistry();
MockAttackRegistry attackRegistryB = new MockAttackRegistry();
safeHarborRegistryB.setAttackRegistry(address(attackRegistryB));
safeHarborRegistryB.setAgreementValid(agreement, true);
ConfidencePool implementation = new ConfidencePool();
ConfidencePool poolB = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[](1);
scope[0] = DEFAULT_SCOPE_ACCOUNT;
poolB.initialize(
agreement,
address(token),
address(safeHarborRegistryB),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
scope
);
token.mint(alice, 100 * ONE);
vm.prank(alice);
token.approve(address(poolB), 100 * ONE);
vm.prank(alice);
poolB.stake(100 * ONE);
token.mint(carol, 20 * ONE);
vm.prank(carol);
token.approve(address(poolB), 20 * ONE);
vm.prank(carol);
poolB.contributeBonus(20 * ONE);
attackRegistryB.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
poolB.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint256(poolB.outcome()), uint256(PoolStates.Outcome.SURVIVED));
assertFalse(poolB.claimsStarted(), "claimsStarted must start false on the sibling pool too");
// Nobody claims. Permissionless sweep of the unclaimed bonus >> the exact structural
// sibling of sweepUnclaimedCorrupted, called under the exact same "nobody claimed" state.
poolB.sweepUnclaimedBonus();
// PROOF POINT 3: claimsStarted stays FALSE on the SURVIVED/bonus side >> the moderator's
// correction window would still be open here, by explicit design (see the code comment
// on sweepUnclaimedBonus). This is the asymmetry: the same kind of leftover-sweep event
// is treated as claim-equivalent finality on one path and not on the other.
assertFalse(poolB.claimsStarted(), "sweepUnclaimedBonus correctly leaves claimsStarted false");
}
}

Summary: Side A resolves CORRUPTED, the named attacker never claims and sweepUnclaimedCorrupted after the claim window latches claimsStarted and blocks a subsequent moderator re-flag whereas Side B resolves SURVIVED under the identical "nobody claimed" conditions and sweepUnclaimedBonus correctly leaves claimsStarted to false

Recommended Mitigation:

Kindly apply the same reasoning used for sweepUnclaimedBonus to sweepUnclaimedCorrupted by omitting the claimsStarted bool since genuine reliance on the outcome should come from a participant's own claim, not a permissionless residual sweep.

function sweepUnclaimedCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (!goodFaith) revert NotGoodFaithCorrupted();
if (block.timestamp <= corruptedClaimDeadline) revert ClaimWindowNotExpired();
// aderyn-fp-next-line(reentrancy-state-change)
uint256 amount = stakeToken.balanceOf(address(this));
if (amount == 0) revert NothingToSweep();
corruptedReserve = 0;
bountyClaimed = bountyEntitlement;
- if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, amount);
emit UnclaimedCorruptedSwept(msg.sender, recoveryAddress, amount);
}

Support

FAQs

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

Give us feedback!