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

Sponsor can rewrite the pool's scope commitment after the pool has already resolved to EXPIRED, because setPoolScope is gated only on scopeLocked and never on the resolved outcome

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: The pool's scope (a flat address[] of BattleChain accounts) is meant to be a fixed, pool-local commitment. Per docs/DESIGN.md §8, the sponsor may update scope only while the registry is in pre-attack staging (NOT_DEPLOYED / NEW_DEPLOYMENT), and the scope "locks permanently on the first interaction observing any other state"; once resolved, "the published scope is the binding audit trail."

  • Specific issue: setPoolScope gates only on the scopeLocked flag, never on the pool's resolution outcome. scopeLocked is only ever set as a side effect of observing the registry in a non-pre-attack state. A pool that reaches expiry while the registry never left pre-attack staging resolves to EXPIRED via the else branch of claimExpired without ever setting scopeLocked = true. The sponsor can therefore call setPoolScope(...) after the pool is fully resolved and stakers have already been paid, silently rewriting the on-chain scope commitment.

// src/ConfidencePool.sol
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
@> if (scopeLocked) revert ScopePostLockImmutable(); // only gate — no outcome check
_replaceScope(accounts);
}
function claimExpired() external nonReentrant {
...
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
...
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
...
} else {
@> // Reached for pre-attack states (NOT_DEPLOYED / NEW_DEPLOYMENT) too, where
@> // `_observePoolState` never flips `scopeLocked`. Pool resolves EXPIRED with
@> // scopeLocked still == false.
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
...
}
claimsStarted = true;
}
...
}

Risk

Likelihood:

  • Occurs whenever a pool reaches its expiry while the underlying agreement's registry state has stayed in NOT_DEPLOYED / NEW_DEPLOYMENT for the pool's entire lifetime (an agreement that is staged but never activated before the pool's deadline — a normal, non-adversarial lifecycle).

  • The SURVIVED and CORRUPTED resolution paths both require a terminal registry state, which is itself a non-pre-attack observation that sets scopeLocked = true; only the EXPIRED fallback leaves scopeLocked unset, so this is the one resolution path where the gap is reachable.

Impact:

  • The sponsor can retroactively replace the published scope after the pool has fully resolved and stakers have claimed, so the "binding audit trail" documented in §8 no longer reflects what the pool committed to insure at the time stakers deposited.

  • Any party relying on the immutability of a resolved pool's scope history (indexers, downstream integrators, other pools curated for the same agreement) can be misled, undermining the integrity of the on-chain confidence-mechanism commitment. No stake or bonus funds are at risk.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
contract ScopeMutableAfterExpiredTest is BaseConfidencePoolTest {
function test_ownerCanRewriteScopeAfterExpiredResolution() public {
// Registry stays in NEW_DEPLOYMENT (pre-attack staging) for the pool's entire life.
_stake(alice, 10 * ONE);
assertFalse(pool.scopeLocked(), "sanity: scope should not be locked yet");
// Warp past expiry and mechanically resolve -> EXPIRED (else branch of claimExpired).
vm.warp(block.timestamp + 31 days);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertFalse(pool.scopeLocked(), "bug: scope is still unlocked after resolution");
// Owner rewrites the scope commitment after the pool is fully resolved and paid out.
address[] memory newScope = new address[](1);
newScope[0] = address(0xBEEF);
agreementContract.setContractInScope(address(0xBEEF), true);
pool.setPoolScope(newScope); // succeeds post-resolution
address[] memory scopeAfter = pool.getScopeAccounts();
assertEq(scopeAfter[0], address(0xBEEF), "scope was rewritten post-resolution");
}
}
Run:
forge test --match-test test_ownerCanRewriteScopeAfterExpiredResolution -vv
Result: PASS — confirms the owner can replace scope after the pool has resolved to EXPIRED.

Recommended Mitigation

Gate setPoolScope on the pool outcome as well, matching the pattern already used by stake / contributeBonus / withdraw:
function setPoolScope(address[] calldata accounts) external onlyOwner {
+ if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

Support

FAQs

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

Give us feedback!