SECURITY AUDIT REGISTRY

Battlechain Confidence Pools

Staking pools that back a Safe Harbor agreement on BattleChain — stakers signal that in-scope contracts will survive the term and earn from a sponsor bonus, settled on a moderator-flagged outcome.

CodeHawksCodeHawks
[L-01]low

Anyone can sweep the bonus before a moderator corrects a wrong outcome, so the whitehat loses it

Summary

sweepUnclaimedBonus() sends leftover bonus funds to recoveryAddress and is callable by anyone. Separately, the moderator can correct a wrong outcome (a "re-flag") as long as nothing has been claimed. If the moderator first flags SURVIVED by mistake, someone sweeps the bonus, and the moderator then corrects the outcome to CORRUPTED and names a whitehat owed the whole pool, the bonus is already gone — the whitehat gets only the stake.

Root cause

ConfidencePool.sol#L474-L509:

solidity
function sweepUnclaimedBonus() external nonReentrant {
    // ...
    stakeToken.safeTransfer(recoveryAddress, amount);
}

No access control. Sweeping is free for the caller and moves the bonus out of the pool before a correction can pay it out.

Likelihood

Occurs whenever the moderator flags SURVIVED by mistake and later corrects it to CORRUPTED with a named whitehat. Anyone has a costless reason to sweep in the meantime.

Impact

  • The whitehat named in the correction receives only the stake, not stake + bonus.
  • recoveryAddress permanently keeps bonus funds it should not get.

Proof of concept

Add to SweepUnclaimedBonus.t.sol:

solidity
function testBonusPermanentlyDivertedToRecoveryWhenSweepRacesGoodFaithReflag() external {
    uint256 stakeAmt = 100 * ONE;
    uint256 bonusAmt = 50 * ONE;

    _stake(alice, stakeAmt);
    _contributeBonus(carol, bonusAmt);
    attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
    vm.prank(moderator);
    pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
    assertEq(pool.riskWindowStart(), 0);

    // Bonus swept while the outcome still says SURVIVED.
    address searcher = makeAddr("searcher");
    vm.prank(searcher);
    pool.sweepUnclaimedBonus();
    assertEq(token.balanceOf(recovery), bonusAmt);

    // Moderator corrects the outcome — still allowed.
    vm.prank(moderator);
    pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);

    // Whitehat only gets the stake.
    vm.prank(attacker);
    pool.claimAttackerBounty();
    assertEq(token.balanceOf(attacker), stakeAmt);
    assertEq(token.balanceOf(recovery), bonusAmt);
}

Recommendation

Restrict sweepUnclaimedBonus() to the moderator:

diff
- function sweepUnclaimedBonus() external nonReentrant {
+ function sweepUnclaimedBonus() external nonReentrant onlyModerator {
STATUS: AVAILABLE FOR ENGAGEMENTS

Open to audits, contract work, and security writing.