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

sweepUnclaimedBonus can underpay corrected good-faith CORRUPTED bounty

Author Revealed upon completion

Root + Impact

Description

flagOutcome() intentionally allows a moderator to re-flag before the first claim so a typo or wrong outcome can be corrected. claimsStarted is meant to be the value-movement finality latch.

However, sweepUnclaimedBonus() can move real bonus value to recoveryAddress without setting claimsStarted. In the no-risk-window path (riskWindowStart == 0), the function treats the whole bonus as unreserved, decrements totalBonus, and transfers it out while the re-flag window remains open.

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
...
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
}
function sweepUnclaimedBonus() external nonReentrant {
...
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
// Intentionally does NOT set claimsStarted.
stakeToken.safeTransfer(recoveryAddress, amount);
}

After a mistaken SURVIVED flag, anyone can sweep the bonus. The moderator can still correct to good-faith CORRUPTED, but flagOutcome() snapshots the already-reduced totalBonus, so the named attacker bounty excludes the swept bonus. This contradicts the documented rule that value movement finalizes distribution because a later corrective re-flag cannot be honored without breaking accounting.

Risk

Likelihood:

  • Requires a terminal-CORRUPTED pool with riskWindowStart == 0 and an initial mistaken SURVIVED flag.

  • The exploit step is permissionless: any public caller can call sweepUnclaimedBonus() before the moderator correction.

  • The moderator-error precondition makes likelihood low, but this is still in-model because pre-claim re-flagging exists specifically to recover from typo/outcome mistakes.

Impact:

  • Good-faith attacker bounty is underpaid by the swept bonus amount.

  • Bonus funds are sent to recoveryAddress under the stale SURVIVED accounting before the intended CORRUPTED correction can be honored.

  • The documented finality invariant is broken: value leaves the contract while claimsStarted remains false.

Proof of Concept

Add this test under test/unit/CodexScratch.reflag.t.sol and run:

forge test --match-path test/unit/CodexScratch.reflag.t.sol -vvv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract CodexScratchReflagTest is BaseConfidencePoolTest {
function test_noRiskSurvivedBonusSweepThenGoodFaithCorruptedReflagIsPrincipalOnly() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 50 * ONE, "bonus swept before reflag");
assertEq(pool.totalBonus(), 0, "bonus removed from live accounting");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 100 * ONE, "bounty excludes swept bonus");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 * ONE, "attacker receives principal only");
}
}

Expected good-faith CORRUPTED entitlement before the sweep is 150 * ONE (principal + bonus). After the sweep and re-flag, entitlement is only 100 * ONE.

Local result: the PoC passes. I also ran a control test with riskWindowStart != 0; that path reserves the real bonus and only sweeps donations, confirming the issue is isolated to the no-risk-window bonus sweep.

Recommended Mitigation

If sweepUnclaimedBonus() moves value that came from totalBonus / snapshotTotalBonus, mark the current outcome final by setting claimsStarted = true. Donation-only dust sweeps can remain non-final if needed.

if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ uint256 realBonusSwept = amount <= totalBonus ? amount : totalBonus;
+ totalBonus -= realBonusSwept;
+ if (realBonusSwept != 0 && !claimsStarted) claimsStarted = true;
}

Support

FAQs

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

Give us feedback!