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

sweepUnclaimedBonus moves the whole bonus to recoveryAddress without latching claimsStarted, so a pre-claim SURVIVED to good-faith-CORRUPTED correction under-pays the named whitehat by the entire bonus pool

Author Revealed upon completion

Root + Impact

A non-claim bonus sweep during the moderator's re-flag window strands the bonus at recoveryAddress, so a corrected good-faith CORRUPTED pays the named whitehat only the principal instead of the whole pool.

Description

  • The moderator may re-flag an outcome before the first claim (DESIGN §4), and good-faith CORRUPTED entitles the named whitehat to the WHOLE pool = snapshotTotalStaked + snapshotTotalBonus (DESIGN §12). sweepUnclaimedBonus deliberately does not latch claimsStarted so a dust donation cannot grief the re-flag window.

  • In the riskWindowStart == 0 branch, sweepUnclaimedBonus permanently transfers the entire bonus to recoveryAddress and zeroes live totalBonus, without latching claimsStarted. If the moderator first flags SURVIVED (out-of-scope judgement) and later corrects to good-faith CORRUPTED, flagOutcome re-snapshots snapshotTotalBonus from the now-zero totalBonus, so bountyEntitlement = snapshotTotalStaked only. The bonus already left irreversibly (contributeBonus is blocked post-outcome), so the whitehat can never receive it — it is stranded at the sponsor's recoveryAddress.

// src/ConfidencePool.sol — sweepUnclaimedBonus (riskWindowStart == 0 path)
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus; // whole bonus leaves
}
@> // Intentionally does NOT set claimsStarted -> flagOutcome re-flag stays open
stakeToken.safeTransfer(recoveryAddress, amount);
// flagOutcome re-snapshots from the now-zero totalBonus
snapshotTotalBonus = totalBonus; // == 0
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;

Risk

Likelihood:

  • Triggers when the registry reaches CORRUPTED with riskWindowStart == 0 (a breach with no observed active-risk window), the moderator's first flag is SURVIVED and is later corrected to good-faith CORRUPTED (exactly the correction the re-flag window exists for, DESIGN §4).

  • Realized when anyone — notably the sponsor, who controls recoveryAddress and profits — calls the permissionless sweepUnclaimedBonus between the SURVIVED flag and the correction.

Impact:

  • The named whitehat is under-paid by the entire bonus pool; those funds are diverted to the sponsor-controlled recoveryAddress.

  • Breaks the DESIGN §12 whole-pool-to-attacker guarantee and the DESIGN §4 costless-correction guarantee.

Proof of Concept

The baseline confirms a direct good-faith CORRUPTED pays the whitehat the whole pool (200), while the exploit shows a permissionless sweepUnclaimedBonus during the SURVIVED→CORRUPTED re-flag window strands the bonus at recoveryAddress, leaving the whitehat only the principal (100).

// test/poc/SweepUnderpaysWhitehat.t.sol (S = 100e18 stake, B = 100e18 bonus)
function testBaselineGoodFaithCorruptedPaysWholePool() external {
_stake(alice, S); _contributeBonus(carol, B);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator); pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
vm.prank(attacker); pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), S + B); // correct: whole pool (200)
}
function testSweepDuringReflagWindowUnderpaysWhitehatByBonus() external {
_stake(alice, S); _contributeBonus(carol, B);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator); pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
pool.sweepUnclaimedBonus(); // B -> recovery, totalBonus=0, no latch
assertFalse(pool.claimsStarted());
vm.prank(moderator); pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), S); // bonus missing from entitlement
vm.prank(attacker); pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), S); // under-paid: 100, should be 200
assertEq(token.balanceOf(recovery), B); // bonus stranded at sponsor
}

Recommended Mitigation

Do not release the no-risk bonus sweep until the outcome is final. A real claim (not a dust donation) latches claimsStarted, so gating the sweep on it closes the correction race without reintroducing the dust-grief the current design avoids:

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
+ // The bonus is only unambiguously unowed once the outcome is final. Sweeping it while the
+ // moderator can still re-flag to good-faith CORRUPTED would strand it away from the whitehat.
+ if (!claimsStarted) revert OutcomeNotFinal();

(Alternative: reserve snapshotTotalBonus even when riskWindowStart == 0 until claimsStarted.)

Support

FAQs

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

Give us feedback!