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

Permissionless bonus sweep before a good-faith CORRUPTED re-flag permanently underfunds the whitehat bounty

Author Revealed upon completion

Summary

sweepUnclaimedBonus() can transfer the pool's accounted bonus to recoveryAddress without setting claimsStarted.

While claimsStarted == false, the moderator is still permitted to correct a previously flagged outcome. If the moderator changes a SURVIVED outcome to good-faith CORRUPTED, flagOutcome() creates a new snapshot from the current totalBonus.

Consequently, when riskWindowStart == 0, any account can sweep the entire bonus before the correction transaction. The subsequent re-flag snapshots the reduced totalBonus, permanently reducing the whitehat's bounty by the swept amount.

Description

The protocol permits the moderator to re-flag an outcome until claimsStarted becomes true:

if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) {
revert OutcomeAlreadySet();
}

Every call to flagOutcome() overwrites the resolution snapshots using the current live accounting:

snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
bountyEntitlement = willBeGoodFaithCorrupted
? snapshotTotalStaked + snapshotTotalBonus
: 0;

When a pool has been flagged SURVIVED or EXPIRED and riskWindowStart == 0, stakers are not entitled to any bonus. Therefore, sweepUnclaimedBonus() considers the entire bonus sweepable.

The function also decrements totalBonus:

if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}

However, despite transferring value out of the pool, it intentionally does not set claimsStarted:

stakeToken.safeTransfer(recoveryAddress, amount);

This produces the following sequence:

  1. The registry is CORRUPTED, but no pool interaction observed an active-risk state, leaving riskWindowStart == 0.

  2. The moderator initially flags SURVIVED, for example because the breach was initially classified as outside the pool's scope.

  3. Before any staker claim, the moderator determines that the breach was in scope and prepares to correct the outcome to good-faith CORRUPTED.

  4. Any account calls sweepUnclaimedBonus().

  5. The entire bonus is transferred to the sponsor-controlled recoveryAddress, and totalBonus is reduced to zero.

  6. Because the sweep does not set claimsStarted, the moderator's correction still succeeds.

  7. The corrected outcome re-snapshots totalBonus == 0, permanently excluding the swept bonus from bountyEntitlement.

The problem is not merely that the bonus was transferred. The protocol continues to allow a supposedly valid correction after the transfer but calculates the corrected entitlement from accounting that the transfer has already reduced.

Risk

Impact

The named whitehat permanently loses up to 100% of the bonus component of their bounty.

For example, with 150 tokens of principal and 50 tokens of bonus:

  • Intended good-faith bounty: 200 tokens

  • Bonus swept before correction: 50 tokens

  • Re-snapshotted bounty: 150 tokens

  • Permanent whitehat loss: 50 tokens

The transferred bonus is sent to recoveryAddress, which is controlled by the pool sponsor. The sponsor can therefore directly benefit from ordering a bonus sweep before the moderator's correction. A third party can also trigger the same loss because the sweep is permissionless.

This undermines the protocol's good-faith CORRUPTED settlement, under which the whitehat is intended to receive the snapshotted principal and bonus.

Likelihood

The issue requires a specific but supported lifecycle:

  • the registry is terminally CORRUPTED;

  • riskWindowStart == 0;

  • the moderator initially flags SURVIVED;

  • no claim has started;

  • the moderator later corrects the classification to good-faith CORRUPTED.

Once these conditions occur, exploitation requires only one permissionless call with no capital or special authorization. The transaction can be submitted before or ordered ahead of the moderator's correction.

Because the financial impact is direct but the required lifecycle is conditional, Medium severity is appropriate.

Proof of Concept

Add the following test to the test suite and run:

forge test --match-contract PoC_M01_SweepBeforeReflag -vvv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from
"@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from
"test/helpers/BaseConfidencePoolTest.sol";
contract PoC_M01_SweepBeforeReflag is BaseConfidencePoolTest {
function testSweepBeforeReflagReducesAttackerBounty() external {
// Pool contains 150 ONE principal and 50 ONE bonus.
_stake(alice, 100 * ONE);
_stake(bob, 50 * ONE);
_contributeBonus(carol, 50 * ONE);
// The registry reaches CORRUPTED without an active-risk state
// being observed by the pool.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
// The moderator initially considers the breach out of scope.
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.SURVIVED,
false,
address(0)
);
assertEq(pool.riskWindowStart(), 0);
assertFalse(pool.claimsStarted());
// At this point, a good-faith CORRUPTED correction should
// produce a 200 ONE bounty.
assertEq(
pool.snapshotTotalStaked()
+ pool.snapshotTotalBonus(),
200 * ONE
);
// Any account can sweep the bonus before the correction.
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
assertEq(
token.balanceOf(recovery) - recoveryBefore,
50 * ONE
);
assertEq(pool.totalBonus(), 0);
// The transfer does not close the re-flag window.
assertFalse(pool.claimsStarted());
// The moderator corrects the outcome to an in-scope,
// good-faith corruption.
vm.prank(moderator);
pool.flagOutcome(
PoolStates.Outcome.CORRUPTED,
true,
attacker
);
// The new snapshot excludes the already-swept bonus.
assertEq(pool.snapshotTotalBonus(), 0);
assertEq(pool.bountyEntitlement(), 150 * ONE);
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
// The whitehat receives only principal and permanently
// loses the 50 ONE bonus.
assertEq(
token.balanceOf(attacker) - attackerBefore,
150 * ONE
);
assertEq(
token.balanceOf(recovery) - recoveryBefore,
50 * ONE
);
}
}

Recommended Mitigation

Any sweep that transfers an amount represented by the pool's accounted totalBonus should be treated as a value-moving finalization event.

Set claimsStarted when the sweep removes accounted bonus. Pure direct-transfer donations or unrelated dust can remain sweepable without closing the correction window.

uint256 bonusReduction;
if (totalEligibleStake == 0 || riskWindowStart == 0) {
bonusReduction =
amount <= totalBonus ? amount : totalBonus;
totalBonus -= bonusReduction;
}
// Removing accounted pool funds must finalize the outcome.
// A pure donation or dust sweep does not have to close the
// moderator's correction window.
if (bonusReduction != 0 && !claimsStarted) {
claimsStarted = true;
}
stakeToken.safeTransfer(recoveryAddress, amount);

This restores the intended invariant that an outcome cannot be re-flagged after accounted value has left the pool.

Alternatively, the protocol can preserve the moderator's correction ability by preventing accounted bonus from being swept while the registry is CORRUPTED and the outcome remains mutable. That approach should include an explicit finalization mechanism or deadline so that legitimate out-of-scope SURVIVED resolutions do not leave bonus funds permanently locked.

Support

FAQs

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

Give us feedback!