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

setPoolScope() Calls _observePoolState() Before Checking scopeLocked, So the Sponsor's Own First Scope-Update Call Always Self-Reverts

Author Revealed upon completion

Root + Impact

Description

setPoolScope() (L636-641) calls _observePoolState() before checking the scopeLocked guard:

function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

_observePoolState() (L784-799) sets scopeLocked = true as a side effect the first time it observes the registry past pre-attack staging (NOT_DEPLOYED / NEW_DEPLOYMENT). Because the guard is checked after this side effect, the Sponsor's own call can be the interaction that seals the lock, then revert against the lock it just set — in the same transaction.

DESIGN.md §8 documents that "scope locks permanently on the first interaction observing any other state" as intentional, but does not address this specific consequence: the Sponsor's dedicated update entrypoint can never succeed if its own call is the first post-staging observation.

Impact: the Sponsor loses the ability to make any successful scope update once the registry leaves pre-attack staging, if their own setPoolScope() call is the first post-staging interaction with the pool.


Root Cause Code

// ConfidencePool.sol:636-641
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
// ConfidencePool.sol:784-799
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();
}
}

Risk

Likelihood reasons:

  • Triggers on the first setPoolScope() call made after the registry leaves pre-attack staging — an ordinary Sponsor action, not an edge case.

  • Deterministic: no attacker, no race condition required.

  • Masked in the common case where scope was already locked by a prior staker interaction, which is likely why it went unnoticed — but nothing guarantees that happens first.

Impact points:

  • Permanently forecloses the Sponsor's ability to update scope during the window the design intends it to still be open.

  • Self-inflicted by the protocol's own code path — the Sponsor cannot avoid it if their first post-staging interaction is a scope update.

  • Low severity: no fund loss, no third-party impact; general scope-locking behavior otherwise works as intended.


Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test, console2} from "forge-std/Test.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
interface IAttackRegistryLike {
enum ContractState {
NOT_DEPLOYED, NEW_DEPLOYMENT, ATTACK_REQUESTED, UNDER_ATTACK,
PROMOTION_REQUESTED, PRODUCTION, CORRUPTED
}
function getAgreementState(address agreement) external view returns (ContractState);
}
contract MockAttackRegistry is IAttackRegistryLike {
mapping(address => ContractState) public stateOf;
function setState(address agreement, ContractState s) external { stateOf[agreement] = s; }
function getAgreementState(address agreement) external view returns (ContractState) { return stateOf[agreement]; }
}
contract MockSafeHarborRegistry {
address public attackRegistry;
constructor(address r) { attackRegistry = r; }
function getAttackRegistry() external view returns (address) { return attackRegistry; }
function isAgreementValid(address) external pure returns (bool) { return true; }
}
contract MockAgreement {
address public owner;
constructor(address o) { owner = o; }
function isContractInScope(address) external pure returns (bool) { return true; }
}
contract SetPoolScopeSelfLockExploit is Test {
ConfidencePool internal pool;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAttackRegistry internal attackRegistry;
MockAgreement internal agreement;
address internal poolOwner = address(this);
address internal recoveryAddress = address(0xDEAD);
function setUp() public {
vm.warp(0);
agreement = new MockAgreement(poolOwner);
attackRegistry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry(address(attackRegistry));
attackRegistry.setState(address(agreement), IAttackRegistryLike.ContractState.NEW_DEPLOYMENT);
pool = new ConfidencePool();
address[] memory initialScope = new address[](1);
initialScope[0] = address(0x1234);
pool.initialize(
address(agreement), address(0xB0B) /* stake token */, address(safeHarborRegistry),
address(0xD0D) /* moderator */, block.timestamp + 30 days, 1,
recoveryAddress, poolOwner, initialScope
);
}
function test_L1_SetPoolScopeSelfLocking() public {
assertFalse(pool.scopeLocked());
// Registry moves past staging. Nobody has interacted with the pool yet.
attackRegistry.setState(address(agreement), IAttackRegistryLike.ContractState.ATTACK_REQUESTED);
assertFalse(pool.scopeLocked());
// Sponsor's first-ever post-staging call: a legitimate scope update.
address[] memory newScope = new address[](1);
newScope[0] = address(0xCAFE);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(newScope);
// Reverted, YET scope is now locked -- the call itself sealed the lock.
assertTrue(pool.scopeLocked());
}
}

Run: forge test --match-test test_L1_SetPoolScopeSelfLocking -vvv

setPoolScope([0x...CAFE])
-> _observePoolState()
-> emit ScopeLocked(timestamp)
-> if (scopeLocked) revert ScopePostLockImmutable()
<- [Revert] ScopePostLockImmutable()

Recommended Mitigation

function setPoolScope(address[] calldata accounts) external onlyOwner {
+ if (scopeLocked) revert ScopePostLockImmutable();
_observePoolState();
- if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

Support

FAQs

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

Give us feedback!