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.
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
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.
- ›
recoveryAddresspermanently keeps bonus funds it should not get.
Proof of concept
Add to SweepUnclaimedBonus.t.sol:
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:
- function sweepUnclaimedBonus() external nonReentrant {
+ function sweepUnclaimedBonus() external nonReentrant onlyModerator {