FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

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.)

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

sweepUnclaimedBonus() omits claimsStarted latch, letting a moderator re-flag re-snapshot a drained totalBonus and short the CORRUPTED bounty

Impact – Medium Up to the entire bonus pool can be routed to the wrong party for good, with no on-chain way to unwind it, and in a stakerless pool the effect is total since bountyEntitlement computes to zero and claimAttackerBounty reverts outright. This impact is definitely not High: the pool stays solvent throughout with no principal ever at risk, and the money ends up at the sponsor's own recoveryAddress, which in most pools means a sponsor recovering a bonus they funded themselves. The whitehat's claim on that bonus also only exists because the moderator changed their mind after the fact. Likelihood – Low Four separate things have to coincide: nobody touches the pool for the whole active-risk window (or there are no stakers to begin with), the moderator flags SURVIVED and later reverses to CORRUPTED, that reversal is good-faith with a named attacker, and a sweep lands between the two flags. The first cuts against staker self-interest, since skipping the poke costs them their entire bonus share, and the second asks the moderator to overturn a scope judgement, which is a bigger deal than the typo fix DESIGN.md #4 offers the window for. The sweep itself I'd treat as near-certain once the rest holds, given it's permissionless and the sponsor has an obvious reason to make the call, but assembling the first three in one pool lifecycle is where this stays rare.

Appeal created

Hawk 1 Submitter
about 7 hours ago

Support

FAQs

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

Give us feedback!