Save the following as a single file, e.g. test/poc/SweepBeforeCorrection.t.sol — it is fully self-contained (mocks included in the same file, no other test files required) and only depends on the repo's own src/ contracts and its existing lib/ dependencies (OpenZeppelin + the battlechain-safe-harbor-contracts submodule).
pragma solidity 0.8.26;
import "forge-std/Test.sol";
import "src/ConfidencePool.sol";
import "src/libraries/PoolStates.sol";
import {IBattleChainSafeHarborRegistry} from "@battlechain/interface/IBattleChainSafeHarborRegistry.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {Account, Chain, BountyTerms, AgreementDetails, Contact} from "@battlechain/types/AgreementTypes.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockAttackRegistry is IAttackRegistry {
ContractState public state = ContractState.NOT_DEPLOYED;
function setState(ContractState s) external { state = s; }
function getAgreementState(address) external view returns (ContractState) { return state; }
function approveAttack(address) external pure { revert("stub"); }
function authorizeAgreementOwner(address, address) external pure { revert("stub"); }
function cancelPromotion(address) external pure { revert("stub"); }
function changeRegistryModerator(address) external pure { revert("stub"); }
function getAgreementForContract(address) external pure returns (address) { revert("stub"); }
function getAgreementInfo(address) external pure returns (AgreementInfo memory) { revert("stub"); }
function getAttackModerator(address) external pure returns (address) { revert("stub"); }
function getAuthorizedOwner(address) external pure returns (address) { revert("stub"); }
function getRegistryModerator() external pure returns (address) { revert("stub"); }
function getSafeHarborRegistry() external pure returns (address) { revert("stub"); }
function goToProduction(address) external pure { revert("stub"); }
function instantCorrupt(address) external pure { revert("stub"); }
function instantPromote(address) external pure { revert("stub"); }
function isTopLevelContractUnderAttack(address) external pure returns (bool) { revert("stub"); }
function markCorrupted(address) external pure { revert("stub"); }
function promote(address) external pure { revert("stub"); }
function registerContractForExistingAgreement(address) external pure { revert("stub"); }
function registerDeployment(address, address) external pure { revert("stub"); }
function rejectAttackRequest(address, bool) external pure { revert("stub"); }
function requestUnderAttack(address) external pure { revert("stub"); }
function requestUnderAttackByNonAuthorized(address) external pure { revert("stub"); }
function requestUnderAttackForUnverifiedContracts(address) external pure { revert("stub"); }
function setSafeHarborRegistry(address) external pure { revert("stub"); }
function syncNewContracts(address) external pure { revert("stub"); }
function transferAttackModerator(address, address) external pure { revert("stub"); }
function unregisterContractForExistingAgreement(address) external pure { revert("stub"); }
}
contract MockAgreement is IAgreement {
address public owner_;
mapping(address => bool) public inScope;
constructor(address o) { owner_ = o; }
function owner() external view returns (address) { return owner_; }
function setInScope(address a, bool v) external { inScope[a] = v; }
function isContractInScope(address a) external view returns (bool) { return inScope[a]; }
function addAccounts(string memory, Account[] memory) external pure { revert("stub"); }
function addOrSetChains(Chain[] memory) external pure { revert("stub"); }
function extendCommitmentWindow(uint256) external pure { revert("stub"); }
function getAgreementURI() external pure returns (string memory) { revert("stub"); }
function getBattleChainCaip2ChainId() external pure returns (string memory) { revert("stub"); }
function getBattleChainScopeAddresses() external pure returns (address[] memory) { revert("stub"); }
function getBattleChainScopeCount() external pure returns (uint256) { revert("stub"); }
function getBountyTerms() external pure returns (BountyTerms memory) { revert("stub"); }
function getCantChangeUntil() external pure returns (uint256) { revert("stub"); }
function getChainIds() external pure returns (string[] memory) { revert("stub"); }
function getDetails() external pure returns (AgreementDetails memory) { revert("stub"); }
function getProtocolName() external pure returns (string memory) { revert("stub"); }
function getRegistry() external pure returns (address) { revert("stub"); }
function removeAccounts(string memory, string[] memory) external pure { revert("stub"); }
function removeChains(string[] memory) external pure { revert("stub"); }
function setAgreementURI(string memory) external pure { revert("stub"); }
function setBountyTerms(BountyTerms memory) external pure { revert("stub"); }
function setContactDetails(Contact[] memory) external pure { revert("stub"); }
function setProtocolName(string memory) external pure { revert("stub"); }
}
contract MockRegistry is IBattleChainSafeHarborRegistry {
address public attackRegistry_;
mapping(address => bool) public valid;
constructor(address ar) { attackRegistry_ = ar; }
function setValid(address a, bool v) external { valid[a] = v; }
function isAgreementValid(address a) external view returns (bool) { return valid[a]; }
function getAttackRegistry() external view returns (address) { return attackRegistry_; }
function adoptSafeHarbor(address) external pure { revert("stub"); }
function getAgreement(address) external pure returns (address) { revert("stub"); }
function getAgreementFactory() external pure returns (address) { revert("stub"); }
function isChainValid(string calldata) external pure returns (bool) { revert("stub"); }
function setAgreementFactory(address) external pure { revert("stub"); }
}
contract MockStakeToken is ERC20 {
constructor() ERC20("Mock", "MCK") { _mint(msg.sender, 1_000_000e18); }
}
contract SweepBeforeCorrectionTest is Test {
ConfidencePool pool;
MockRegistry registry;
MockAttackRegistry attackRegistry;
MockAgreement agreement;
MockStakeToken token;
address sponsor = address(0xA11CE);
address moderator = address(0xB0B);
address staker = address(0xCAFE);
address recovery = address(0xD00D);
address attacker = address(0xEEEE);
function setUp() public {
attackRegistry = new MockAttackRegistry();
registry = new MockRegistry(address(attackRegistry));
agreement = new MockAgreement(sponsor);
token = new MockStakeToken();
registry.setValid(address(agreement), true);
agreement.setInScope(address(0x1), true);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory accounts = new address[](1);
accounts[0] = address(0x1);
vm.prank(sponsor);
pool.initialize(
address(agreement),
address(token),
address(registry),
moderator,
block.timestamp + 31 days,
1e18,
recovery,
sponsor,
accounts
);
token.transfer(staker, 100e18);
vm.startPrank(staker);
token.approve(address(pool), 100e18);
pool.stake(100e18);
vm.stopPrank();
token.transfer(sponsor, 10e18);
vm.startPrank(sponsor);
token.approve(address(pool), 10e18);
pool.contributeBonus(10e18);
vm.stopPrank();
}
function test_bonusSweptBeforeCorrectionShortsAttacker() public {
attackRegistry.setState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.sweepUnclaimedBonus();
uint256 sweptAmount = token.balanceOf(recovery) - recoveryBefore;
assertEq(sweptAmount, 10e18, "full bonus swept before correction");
attackRegistry.setState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
vm.prank(attacker);
pool.claimAttackerBounty();
assertEq(token.balanceOf(attacker), 100e18, "attacker under-paid by the pre-swept bonus");
}
}
This closes the gap without reintroducing the griefing vector the original design avoided: claimsStarted is only ever set by genuine value-movement claims (claimSurvived, claimExpired, claimCorrupted, claimAttackerBounty), so a 1-wei donation still cannot trigger it. The tradeoff is that sweepUnclaimedBonus now requires at least one legitimate claim to have already occurred — which matches the intended sequence anyway, since bonus sweeping is meant to run after stakers have had the chance to claim against a finalized outcome, not before the correction window has even closed.