Root + Impact
Description
A pool’s scope is meant to be the public commitment/audit trail for what contracts the pool covers. The sponsor can update scope only before the registry
leaves pre-attack staging.
For factory-valid agreements that are never registered in AttackRegistry, getAgreementState() keeps returning NOT_DEPLOYED. The pool can still resolve through
claimExpired() as EXPIRED, but scopeLocked remains false, so the owner can rewrite the published scope even after resolution and claims.
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
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);
}
}
Risk
Likelihood:
- Medium: this occurs for pools created against factory-valid agreements that never enter AttackRegistry
- Medium: registration is a separate operational step, while pool creation only checks isAgreementValid()
Impact:
- Off-chain users/indexers may reconstruct a misleading scope history for a finalized pool.
Proof of Concept
Paste this in test/unit/ConfidencePool.scope.t.sol inside ConfidencePoolScopeTest:
function testPocPostExpiredNotDeployedScopeStillMutable() external {
_seedAgreementScope();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
_stake(alice, 50 * ONE);
vm.warp(pool.expiry());
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertFalse(pool.scopeLocked());
pool.setPoolScope(_multiAccountScope());
assertFalse(pool.scopeLocked());
assertTrue(pool.isAccountInScope(ACCOUNT_X));
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
}
Run it with:
forge test --match-test testPocPostExpiredNotDeployedScopeStillMutable -vv
Expected result:
[PASS] testPocPostExpiredNotDeployedScopeStillMutable()
Recommended Mitigation
Lock scope once the pool resolves, even when the registry never leaves NOT_DEPLOYED
function claimExpired() external nonReentrant {
...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
+ if (!scopeLocked) {
+ scopeLocked = true;
+ emit ScopeLocked(block.timestamp);
+ }
...
}
}