FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: low
Likelihood: medium

Registry revert freezes withdrawals and resolution

Author Revealed upon completion

Root + Impact

Description

Normal behavior: stakers can withdraw before the pool enters active risk, and an unresolved pool can still auto-resolve at expiry.

Issue: withdraw() and the unresolved branch of claimExpired() both call _observePoolState(), which calls _getAgreementState() before any state-independent recovery path. If the upstream Safe Harbor registry or its attack-registry pointer starts reverting for the agreement, both paths revert. That leaves the pool stuck UNRESOLVED, blocks withdrawals, and prevents expiry resolution, so staker principal remains locked indefinitely.

## Root Cause

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);
}

Risk

Likelihood:

  • The pool depends on a live external registry read for every unresolved withdraw and resolution path.

  • A registry migration, de-registration, or stricter implementation that reverts for a valid agreement immediately bricks those paths.
    Impact:

    • Staker principal becomes locked with no on-chain recovery path until the registry is fixed.

    • The pool cannot reach a terminal outcome, so claims never become available.

Proof of Concept

// SPDX-License-Identifier: MIT
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));
}
}

Recommended Mitigation

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.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!