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

Reverted scope lock permits coverage changes for existing stakers

Author Revealed upon completion

Description

The protocol permits the sponsor to replace a pool's scope only while the
registry is in pre-attack staging. The first pool interaction that observes any
other registry state must permanently set scopeLocked, so that the pool's
coverage cannot later expand beyond the scope on which existing stakers relied.

setPoolScope() first calls _observePoolState(). When the registry is in
ATTACK_REQUESTED and the pool is not yet locked, that helper sets
scopeLocked = true. setPoolScope() then reverts because the flag is true.
The revert rolls back the newly written flag. Consequently, the pool retains no
history that it observed ATTACK_REQUESTED.

The AttackRegistry permits the DAO to reject an attack request and returns the
agreement to NOT_DEPLOYED. The sponsor can then call setPoolScope() again:
the registry is once more in a staging state and the still-false scopeLocked
permits a scope replacement. This can occur while a user who staked during
NOT_DEPLOYED remains in the pool; stake() observes the state but does not
lock the scope in that staging state.

function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
// @> The first ATTACK_REQUESTED observation sets scopeLocked, but this revert
// @> rolls the write back instead of leaving a permanent historical lock.
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
) {
// @> This assignment is lost when the calling entry point reverts.
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
}

The protocol's design makes the scope the binding audit trail for the
moderator's off-chain decision between SURVIVED and CORRUPTED. Thus, this is
not merely an event/indexing inconsistency: it lets the sponsor expand the
coverage used to determine an existing staker's later settlement outcome.

This is not an accepted design choice. docs/DESIGN.md says scope locks permanently on the first interaction observing any state outside NOT_DEPLOYED / NEW_DEPLOYMENT, and that once locked, stakers' exposure is bounded by what they signed up for at deposit time. The issue is that the first observation is rolled back by a revert, allowing later coverage changes for existing stake.

Risk

Likelihood: Low

  • A user stakes while the registry is NOT_DEPLOYED, which leaves
    scopeLocked unset by design. The agreement can be a valid factory-created
    agreement without being registered in the AttackRegistry.

  • The agreement reaches ATTACK_REQUESTED, the sponsor becomes the first
    observer through a reverting setPoolScope() call, and no successful pool
    interaction persists the lock during that interval.

  • The DAO rejects the request, returning the registry to NOT_DEPLOYED.
    Rejection is a supported registry transition but is outside the sponsor's
    direct control. A later loss additionally requires a future in-scope
    corruption; stakers can also withdraw while the registry is in either
    NOT_DEPLOYED or ATTACK_REQUESTED.

Impact: Medium

  • The sponsor can enlarge the pool-local scope after a historical
    post-staging observation while prior stake remains in the pool. This violates
    the pool's fixed-coverage commitment and expands those stakers' future
    exposure beyond the scope originally advertised to them.

  • Scope is used by the moderator as the binding audit trail. A later corruption
    of an address added through this bypass can change the pool's resolution from
    SURVIVED to CORRUPTED; the corrupted path can ultimately transfer the
    pool's token balance to recoveryAddress or a good-faith attacker. The scope
    replacement itself does not move funds, so this is indirect fund risk rather
    than an immediate drain.

Under the CodeHawks impact/likelihood matrix, Low likelihood and Medium impact
classify this issue as Low severity.

Proof of Concept

Add the following test to test/unit/ConfidencePool.scope.t.sol inside ConfidencePoolScopeTest. The test uses the existing suite fixtures and helpers (_seedAgreementScope, _multiAccountScope, _stake, pool, attackRegistry, and agreementContract):

function test_scopeLockRollbackLetsSponsorChangeCoverageForExistingStaker() external {
_seedAgreementScope();
// A real agreement is NOT_DEPLOYED until its attack request is registered.
// Staking in this allowed staging state does not lock scope.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
_stake(alice, 100 * ONE);
assertEq(pool.eligibleStake(alice), 100 * ONE);
assertFalse(pool.scopeLocked());
// The sponsor is the first observer after leaving staging. The lock assignment
// is rolled back when setPoolScope reverts.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(_multiAccountScope());
assertFalse(pool.scopeLocked());
// AttackRegistry.rejectAttackRequest() performs this production transition.
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
pool.setPoolScope(_multiAccountScope());
// Alice's existing stake remains, but the scope to which it is exposed changed.
assertEq(pool.eligibleStake(alice), 100 * ONE);
assertTrue(pool.isAccountInScope(ACCOUNT_X));
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
}

The existing test testAuditScopeLockRollsBackWhenSetPoolScopeIsFirstPostStagingObservation
already demonstrates that the reverting observation leaves scopeLocked false
and that a subsequent staging-state call replaces the scope. The additional
stake assertions show why the state inconsistency affects existing stakers.

Verified locally with:

forge test --match-test test_scopeLockRollbackLetsSponsorChangeCoverageForExistingStaker -vv

Result: 1 passed; 0 failed.

Recommended Mitigation

Make the first post-staging setPoolScope() call succeed after persisting the
new lock, without applying the requested replacement. Later attempts can
continue to revert because the lock was already present before the call.

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

Apply the same persistence rule to pokeRiskWindow(): a first observation of
ATTACK_REQUESTED must not revert away the newly set scope lock merely because
no risk-window timestamp was sealed.

Support

FAQs

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

Give us feedback!