Normal behavior: stakers can withdraw before the pool enters active risk, and an unresolved pool can still auto-resolve at expiry.
function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
...
}
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome != PoolStates.Outcome.UNRESOLVED && outcome != PoolStates.Outcome.EXPIRED) {
revert InvalidOutcome();
}
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
...
}
...
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
...
}
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract RevertingAttackRegistry {
function getAgreementState(address) external pure returns (IAttackRegistry.ContractState) {
revert("registry revert");
}
}
contract PoC_RegistryRevertFreezeTest is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant MIN_STAKE = 100 * ONE;
address internal constant SCOPE = address(0xC0FFEE);
MockAttackRegistry healthyRegistry;
RevertingAttackRegistry brokenRegistry;
MockSafeHarborRegistry safeHarborRegistry;
MockAgreement agreementContract;
MockERC20 token;
ConfidencePool pool;
address agreement;
address alice = makeAddr("alice");
function setUp() public {
healthyRegistry = new MockAttackRegistry();
brokenRegistry = new RevertingAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreement = address(agreementContract);
agreementContract.setContractInScope(SCOPE, true);
safeHarborRegistry.setAttackRegistry(address(healthyRegistry));
safeHarborRegistry.setAgreementValid(agreement, true);
healthyRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
token = new MockERC20();
ConfidencePool impl = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(impl)));
address[] memory accounts = new address[](1);
accounts[0] = SCOPE;
pool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
address(this),
block.timestamp + 31 days,
MIN_STAKE,
address(0x1234),
address(this),
accounts
);
}
function testWithdrawFreezesWhenRegistryReverts() external {
token.mint(alice, 200 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 200 * ONE);
pool.stake(200 * ONE);
vm.stopPrank();
safeHarborRegistry.setAttackRegistry(address(brokenRegistry));
vm.prank(alice);
vm.expectRevert();
pool.withdraw();
}
function testClaimExpiredCannotResolveWhenRegistryReverts() external {
token.mint(alice, 200 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 200 * ONE);
pool.stake(200 * ONE);
vm.stopPrank();
vm.warp(block.timestamp + 32 days);
safeHarborRegistry.setAttackRegistry(address(brokenRegistry));
vm.prank(alice);
vm.expectRevert();
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.UNRESOLVED));
}
}
Decouple payout recovery from live registry liveness, or wrap the registry read in a fallback path so a reverting registry does not block withdrawal / expiry resolution.