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

Permissionless sweep functions can undermine the moderator's documented re-flag correction window

Author Revealed upon completion

Root + Impact

Description

  • flagOutcome can be re-flagged by the moderator before any claim happens. This exists so a mistaken first flag can be corrected before anyone locks in the wrong distribution. DESIGN.md is explicit about this. The window closes once value actually moves, and only a legitimate beneficiary claiming their own funds is supposed to close it.

    Two functions break that assumption. sweepUnclaimedBonus, the SURVIVED and EXPIRED leftover sweep, moves real value but was built to never set claimsStarted. That's deliberate, a tiny donation shouldn't be able to lock the window shut on its own. claimCorrupted for bad-faith CORRUPTED does set claimsStarted, but has no check on who is calling it. No stake required, no relationship to the pool required. Both are permissionless and callable immediately, with no delay. Compare that to their sibling sweepUnclaimedCorrupted, which waits out a full 180 day window first.

// Root cause in the codebase with @> marks to highlight the relevant section
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
// Re-flag allowed pre-claim so the moderator can fix a typo'd outcome / attacker before
// any participant locks in the wrong distribution. The window closing on the FIRST claim
// (`claimsStarted`) is by design.
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
@> if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, toSweep);
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
...
// 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);

Risk

Likelihood:

  • A moderator needing to correct their own first flag is normal. DESIGN.md's own §4 exists because the team expects this to happen.

  • Once a correction is needed, anyone can act on it. Neither claimCorrupted nor sweepUnclaimedBonus checks who is calling. The caller gains nothing personally though, and that's the real limiter on how often this actually gets triggered.

Impact:

  • In the bad-faith CORRUPTED case, claimCorrupted sweeps the whole pool immediately and locks claimsStarted. When the moderator tries to correct to good-faith CORRUPTED and name the real whitehat, the correction reverts. The whitehat gets nothing.

  • The same lock can hit every staker at once instead of one whitehat. If the blocked correction would have changed the outcome to SURVIVED, the entire pool's principal and bonus are gone for everyone in it.

  • sweepUnclaimedBonus doesn't lock the window shut, but that doesn't save the bonus. The value it moves is gone by the time a correction lands. The correction still goes through as a transaction. It just can't restore what already left, since the whitehat's entitlement gets recalculated from an already-drained total.

Proof of Concept

This is the sharpest of the three variants. It shows the correction window closing entirely, not just losing value quietly. The moderator makes a first, conservative bad-faith CORRUPTED call. Before they revisit it, an unrelated address calls claimCorrupted. They gain nothing from it. The entire pool sweeps to recoveryAddress and claimsStarted locks in the same call. It later turns out the attacker was a legitimate whitehat. The moderator tries to fix the record. It reverts.
function testBadFaithCorruptedSweepForecloseGoodFaithCorrectionEntirely() external {
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 40 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// Moderator flags bad-faith CORRUPTED as a first, conservative call.
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
assertEq(pool.claimsStarted(), false);
uint256 recoveryBefore = token.balanceOf(recovery);
uint256 poolBalanceBefore = token.balanceOf(address(pool));
assertEq(poolBalanceBefore, 190 * ONE);
// Any unrelated address sweeps the ENTIRE pool to recovery immediately. No
// MustClaimBountyFirst gate applies since goodFaith is false. No delay either, unlike
// sweepUnclaimedCorrupted's 180 day window.
vm.prank(randomFrontRunner);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 190 * ONE);
assertEq(token.balanceOf(address(pool)), 0);
assertEq(pool.claimsStarted(), true);
assertEq(token.balanceOf(randomFrontRunner), 0, "front-runner receives zero personal benefit");
// It later becomes clear the attacker was a legitimate whitehat. The moderator tries to
// correct. The re-flag window is permanently closed.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.OutcomeAlreadySet.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
// The whitehat has no path to any funds. The pool is already empty.
vm.prank(whitehat);
vm.expectRevert(IConfidencePool.BountyAlreadyClaimed.selector);
pool.claimAttackerBounty();
}
The two reverts at the end are the proof. The moderator's own correction fails. The whitehat's claim fails right behind it. All 190 tokens are already at recoveryAddress.
Two other variants confirm the same root cause plays out differently depending on what gets blocked. One shows every staker losing everything at once, not just a single whitehat, when the blocked correction would have changed the outcome to SURVIVED. Another shows the quieter version, where sweepUnclaimedBonus doesn't lock the window at all, but the bonus is gone regardless by the time the correction lands.

Recommended Mitigation

Give the moderator a real window after flagging, before either sweep can fire. This mirrors the delay sweepUnclaimedCorrupted already uses on the good-faith path.
+ uint256 public constant SWEEP_DELAY_AFTER_FLAG = 3 days;
+
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
+ if (goodFaith == false && block.timestamp < outcomeFlaggedAt + SWEEP_DELAY_AFTER_FLAG) {
+ revert SweepDelayNotElapsed();
+ }
uint256 toSweep = stakeToken.balanceOf(address(this));
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+ if (block.timestamp < outcomeFlaggedAt + SWEEP_DELAY_AFTER_FLAG) revert SweepDelayNotElapsed();
outcomeFlaggedAt already gets set on every flag, including re-flags. No new state is needed to measure the delay. A few days gives the moderator room to catch and fix an early mistake, without meaningfully delaying a sweep once the outcome is actually final.

Support

FAQs

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

Give us feedback!