Root + Impact
The ConfidencePool utilizes a lazy-evaluation pull architecture via _observePoolState() to sync with upstream registry transitions. When the external IBattleChainSafeHarborRegistry transitions into an active risk state (UNDER_ATTACK or PROMOTION_REQUESTED) and subsequently resets back to a benign state (NOT_DEPLOYED) due to a DAO rejection or a canceled attack request, the pool completely misses the history if no user transaction triggers an update during that active risk window. Because riskWindowStart remains 0, the withdraw() function lets stakers escape the pool with their full principal post-rewind, bypassing the critical temporal security latch.
Description
The protocol's game theory relies on permanently locking staker withdrawals once a risk event is initiated to enforce capital commitment. However, because pool state evaluation is lazy, it only reads the current registry state point-in-time.
If a state rollback occurs (such as a false alarm, canceled deployment, or DAO rejection), the target registry reverts to a benign state. When a staker subsequently calls withdraw(), the pool evaluates the current state as safe and allows the exit:
function withdraw() external {
IAttackRegistry.ContractState state = _observePoolState();
@> if (riskWindowStart != 0 || (state != PoolStates.NOT_DEPLOYED && state != PoolStates.NEW_DEPLOYMENT && state != PoolStates.ATTACK_REQUESTED)) {
revert WithdrawsDisabled();
}
}
Risk
Likelihood:
-
Reason 1: The external registry state transitions backward (from UNDER_ATTACK back to NOT_DEPLOYED via rejectAttackRequest()) during low pool activity when no user transactions update the pool clone's local storage.
-
Reason 2: Strategic stakers deliberately withhold transactions ("pokes") during the active attack state, choosing to wait out the resolution timeline to extract their principal post-rejection.
Impact:
-
Impact 1 (Broken Game Theory): Stakers bypass downside exposure during active risk states while retaining upside premium eligibility.
-
Impact 2 (Invariant Violation): Directly violates the specification that an upstream state rewind must never reopen withdrawals.
Proof of Concept
Setup: Alice stakes 10,000 tokens into the pool while the registry is in a benign state (NOT_DEPLOYED).
Active Risk State: The registry transitions to UNDER_ATTACK. No users execute transactions on the pool, leaving the pool's internal riskWindowStart state at 0.
State Rewind: The registry is rejected and resets back to NOT_DEPLOYED (simulating rejectAttackRequest()).
Bypass & Extraction: Alice calls withdraw(). The lazy evaluation evaluates the active registry state as NOT_DEPLOYED and successfully returns her entire principal, bypassing the security latch.
Run this test suite using:
forge test --match-path "test/LazyLatchExploitSimulation.t.sol"
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 {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
contract LazyLatchExploitSimulation is Test {
uint256 internal constant ONE = 1e18;
address internal constant DEFAULT_SCOPE_ACCOUNT = address(0xC0FFEE);
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreementContract;
ConfidencePool internal pool;
address internal alice = makeAddr("alice");
function setUp() public {
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreementContract = new MockAgreement(address(this));
agreementContract.setContractInScope(DEFAULT_SCOPE_ACCOUNT, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreementContract), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
pool.initialize(
address(agreementContract),
address(token),
address(safeHarborRegistry),
makeAddr("moderator"),
block.timestamp + 31 days,
ONE,
makeAddr("recovery"),
address(this),
_defaultScope()
);
}
function _defaultScope() internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = DEFAULT_SCOPE_ACCOUNT;
}
function test_LazyLatchBypass_RegistryRewindAllowsWithdrawal() external {
token.mint(alice, 10_000 * ONE);
vm.startPrank(alice);
token.approve(address(pool), 10_000 * ONE);
pool.stake(10_000 * ONE);
vm.stopPrank();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
vm.prank(alice);
pool.withdraw();
assertEq(pool.totalEligibleStake(), 0, "Exploit Failed: Alice stake should have been locked");
assertEq(token.balanceOf(alice), 10_000 * ONE, "Exploit Succeeded: Funds extracted post-rewind");
}
}
Recommended Mitigation
To prevent lazy-evaluation bypasses, the pool must track the sequential flow of the registry states rather than checking them only point-in-time.
We introduce a tracking variable _lastSeenRegistryVersion inside ConfidencePool.sol that queries a monotonically increasing state transition counter from the Safe Harbor Registry. If the current version is lower than the last-seen version, the pool identifies that a state rollback occurred without pool intervention and reverts the transaction, securing the lock mechanism.
// Add to ConfidencePool storage
+ uint256 private _lastSeenRegistryVersion;
// Add to IBattleChainSafeHarborRegistry interface
+ function getAgreementStateVersion(address agreement) external view returns (uint256);
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
+ // Track registry state version to detect historical rewind
+ address attackRegistry = safeHarborRegistry.getAttackRegistry();
+ uint256 currentVersion = IAttackRegistry(attackRegistry).getAgreementStateVersion(agreement);
+
+ if (currentVersion < _lastSeenRegistryVersion) {
+ revert RegistryStateRewound();
+ }
+ _lastSeenRegistryVersion = currentVersion;
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}