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

setPoolScope Can Seal Risk Window and Lock All Stakers

Author Revealed upon completion

Root + Impact

The setPoolScope function, callable only by the sponsor, internally invokes _observePoolState(). If the agreement is already in an active‑risk state, this side effect seals the riskWindowStart latch and permanently disables withdrawals.

The sponsor can use this administrative function to deliberately lock all stakers.

Description

  • setPoolScope allows the sponsor to update the pool’s scope before it is locked. It should be an administrative action without side effects on staker funds.

  • setPoolScope internally calls _observePoolState(), which, if the agreement is already in an active‑risk state, sets the one‑way riskWindowStart and permanently disables withdrawals. The sponsor can use a scope change to deliberately lock stakers.

// src/ConfidencePool.sol setPoolScope()
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState(); // @> Side effect: can seal riskWindowStart and lock withdrawals
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
// _observePoolState() as above; marks riskWindowStart on active‑risk state

Risk

Likelihood:

  • The sponsor is the pool owner and can call setPoolScope at any time before scopeLocked is true.

  • If the agreement happens to be in UNDER_ATTACK or PROMOTION_REQUESTED, the call seals the risk window.

Impact:

  • Stakers are locked out of withdrawals, even if the scope change was benign.

  • The sponsor can use this administrative function as a griefing mechanism.

Proof of Concept

Explanation:

A pool is created and a staker deposits 100 tokens while the agreement is UNDER_ATTACK. The sponsor calls setPoolScope (with the same accounts) a seemingly innocent administrative action.

Internally, _observePoolState sees the active‑risk state and sets riskWindowStart. The staker then tries to withdraw but the call reverts. The sponsor has locked the stakers without any visible attack, simply by updating the scope.

function test_M4_ScopeChangeLocksWithdrawals() public {
MockRegistry registry = new MockRegistry();
registry.setAgreementState(agreement, IAttackRegistry.ContractState.UNDER_ATTACK);
address sponsor = makeAddr("sponsor");
address staker = makeAddr("staker");
vm.prank(sponsor);
ConfidencePool pool = factory.createPool(agreement, address(stakeToken), expiry, minStake, sponsor, accounts);
deal(address(stakeToken), staker, 100 ether);
vm.startPrank(staker);
stakeToken.approve(address(pool), 100 ether);
pool.stake(100 ether);
vm.stopPrank();
vm.prank(sponsor);
pool.setPoolScope(accounts);
vm.prank(staker);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
}

Recommended Mitigation

Mitigation explanation:

The fix removes the call to _observePoolState() from setPoolScope. Scope management is an administrative operation that should not have side effects on the risk‑window markers or withdrawal permissions.

The risk window is observed only in functions that genuinely depend on the registry state for access control: stake, withdraw, pokeRiskWindow, flagOutcome, and claimExpired.

Decoupling the scope update from the risk observation prevents the sponsor from using a routine scope change as a covert way to freeze stakers.

function setPoolScope(address[] calldata accounts) external onlyOwner {
- _observePoolState(); // Remove registry observation from scope update
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
+ // Risk window observation should occur only in functions that directly depend on registry state
+ // (stake, withdraw, pokeRiskWindow, flagOutcome, claimExpired).

Support

FAQs

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

Give us feedback!