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

Active stakers can be exposed to a different scope than the one they deposited for

Author Revealed upon completion

Active stakers can be exposed to a different scope than the one they deposited for

Description

  • DESIGN.md (§8) states that "stakers' exposure is bounded by what they signed up for at deposit time." In other words, once a user deposits, the covered risk should remain consistent with what they accepted when staking.

  • This guarantee is enforced for expiry: the first stake permanently locks it through expiryLocked. However, the same protection does not exist for the pool scope. scopeLocked is only set once the registry leaves the NOT_DEPLOYED / NEW_DEPLOYMENT states.

  • As a result, the sponsor can still call setPoolScope() after users have already deposited, changing the covered contracts while active stakers remain in the pool. Nothing on-chain notifies or requires existing stakers to acknowledge this change before the scope becomes immutable.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (!expiryLocked) {
expiryLocked = true; // @> expiry locked on first stake
}
// @> no equivalent lock for scope — mutable until registry transitions
_assertDepositsAllowed(_observePoolState());
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
if (!scopeLocked && state != NOT_DEPLOYED && state != NEW_DEPLOYMENT) {
scopeLocked = true; // @> triggered by registry state, not by first stake
}
}

Risk

Impact:

  • Once the registry leaves NOT_DEPLOYED/NEW_DEPLOYMENT, the scope locks permanently. When the risk window opens, withdraw() is permanently disabled — a staker who didn't notice the scope change is now trapped with no exit and exposure to contracts they never evaluated.

  • A malicious sponsor can bait deposits with a robust scope, silently swap it for a sponsor-controlled fragile contract before the lock, trigger a corruption on that contract, and drain the entire pool via claimAttackerBounty().

I will accept a downgrade to Low if "stakers' exposure is bounded by what they signed up for at deposit time" is considered inaccurate wording that conflicts with the explicit §8 statement that "the sponsor can update scope freely while the registry is in NOT_DEPLOYED/NEW_DEPLOYMENT", making this a documentation inconsistency rather than a security issue.

Likelihood:

  • Becoming a pool sponsor only requires calling ConfidencePoolFactory.createPool() with a valid BattleChain Agreement.

  • The replacement scope must belong to the Agreement's existing scope, so the sponsor cannot introduce arbitrary contracts. The attack consists of switching from the advertised robust scope to a riskier — or sponsor-controlled — contract already listed in the same Agreement.

Proof of Concept

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

function testActiveStakerScopeCanBeChangedBeforeScopeLock() external {
// Stage a second valid contract in the agreement so it can later replace the initial pool scope.
agreementContract.setContractInScope(ACCOUNT_X, true);
// Alice stakes while the pool is still in the pre-lock phase.
// At this point she believes she is providing coverage for DEFAULT_SCOPE_ACCOUNT.
_stake(alice, 50 * ONE);
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertFalse(pool.isAccountInScope(ACCOUNT_X));
// Because the scope is not yet locked, the sponsor can replace it
// even though Alice's stake is still active.
address[] memory scope = new address[](1);
scope[0] = ACCOUNT_X;
pool.setPoolScope(scope);
// Alice is now exposed to a completely different contract than the one
// she accepted to underwrite when depositing.
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
assertTrue(pool.isAccountInScope(ACCOUNT_X));
// The pool remains mutable until the registry leaves the pre-deployment stage.
assertFalse(pool.scopeLocked());
}

Then run forge test --mt testActiveStakerScopeCanBeChangedBeforeScopeLock -vvv

Recommended Mitigation

Two possible solutions:

1. Lock scope on first stake, mirroring the expiryLocked pattern:

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
// ...
if (!expiryLocked) {
expiryLocked = true;
}
+ if (!scopeLocked) {
+ scopeLocked = true;
+ emit ScopeLocked(block.timestamp);
+ }
// ...
}

2. Remove the contradictory invariant from the design doc if the current behavior is intentional:

- stakers' exposure is bounded by what they signed up for at deposit time.
+ stakers' exposure is bounded by what they signed up for when the pool scope becomes locked.

Support

FAQs

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

Give us feedback!