FoundrySolidityLayer 2
7.25 ETH
Submission Details
Severity: low
Valid

Reverting ATTACK_REQUESTED Observation Fails to Persist Scope Lock

Author Revealed upon completion

Description

The pool scope is intended to become immutable once the agreement registry leaves pre-attack staging. pokeRiskWindow() is also documented as a permissionless keeper hook that can observe registry transitions and seal the relevant one-way markers.

The issue is that pokeRiskWindow() calls _observePoolState() and then reverts with RiskWindowNotReached when the registry is ATTACK_REQUESTED. Because the revert rolls back all state changes, the scopeLocked = true write made inside _observePoolState() is not persisted. If the registry later returns to a pre-attack state without any successful pool interaction in between, the owner can still change the scope even though the registry had already left pre-attack staging.

function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
// @> This write is rolled back when the caller later reverts
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
...
}
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
// @> ATTACK_REQUESTED locks no risk-window marker, so the call reverts and undoes scopeLocked
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

Risk

Likelihood:

  • This occurs when the agreement enters ATTACK_REQUESTED and the only pool observation during that state is a reverting call such as pokeRiskWindow().

  • This occurs when the registry later returns to NEW_DEPLOYMENT or NOT_DEPLOYED before any successful pool interaction has persisted scopeLocked.

Impact:

  • The owner can change the pool's scope after the agreement already left pre-attack staging, breaking the stated scope immutability point.

  • Existing stakers may have committed funds under one published scope while a different scope is later installed.

Proof of Concept

Add this test to test/unit/ConfidencePool.scope.t.sol:

function testRevertingAttackRequestedObservationDoesNotPersistScopeLock() external {
_seedAgreementScope();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
pool.setPoolScope(_multiAccountScope());
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertTrue(pool.isAccountInScope(ACCOUNT_X));
assertTrue(pool.isAccountInScope(ACCOUNT_Y));
assertTrue(pool.isAccountInScope(ACCOUNT_Z));
}

Run:

forge test --match-test testRevertingAttackRequestedObservationDoesNotPersistScopeLock -vvv

Expected result:

[PASS] testRevertingAttackRequestedObservationDoesNotPersistScopeLock()

Recommended Mitigation

Allow pokeRiskWindow() to succeed when it observes and persists a scope lock, even when no risk-window marker is sealed.

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

Alternatively, document that scope locking only persists after a successful pool transaction and does not necessarily happen at the first attempted observation.

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!