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

Pool scope can change after a staker deposits, locking them into coverage they never agreed to

Author Revealed upon completion

Root + Impact

Description

A confidence pool commits to a specific set of BattleChain contracts (its scope). Stakers read that scope, decide they believe those contracts will survive, and deposit. The docs are explicit about what this buys them. In the DESIGN.md section 8 says "stakers' exposure is bounded by what they signed up for at deposit time" and in section 10 tells stakers to "All staker-relevant state is on-chain; stakers should verify pool parameters before depositing."

The problem is that verifying at deposit time isn't enough. setPoolScope stays open until scopeLocked flips, and scopeLocked only flips once the registry leaves the pre-attack staging conditions. So there's a live window where a staker has already deposited and the scope can still be replaced. Once the registry moves to UNDER_ATTACK, the one-way riskWindowStart permanently disables withdraw(), so the staker is committed to whatever the scope was changed to, with no way out.

This does not require any bad intent from the sponsor. A regular, scope adjustment to narrow the agreement, swapping set of contracts still within the general scope etc. This silently rebinds every existing staker to the new scope and removes their exit. The trap is a property of the mechanism, not of the sponsor's motives; a malicious sponsor is only the worst case. Meaning the staker remains in conditions that they did not sign up for upon deposit. Which could eventually lead to loss of funds if any of the new swapped out scoped which they didn't intend to put confidence in gets corrupted.

// src/ConfidencePool.sol
// we can see here that the scope can stil be changed until it enders the UNDER_ATTACK mode
// and the changes can be made without the consent of the alreayd deposited stakers validating
// if they still want to be a part of the new scope
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
@> if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
// Notice that the scope changing only gets locked long after deposits are open
// and the risk period has begun
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
@> ) {
scopeLocked = true;
}
...
}
// at this point withdrawals are already locked and the already deposited users dont have enough time to react to
// the change in scope
function withdraw() external nonReentrant {
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
@> if (
riskWindowStart != 0 ||
(state != IAttackRegistry.ContractState.NOT_DEPLOYED &&
state != IAttackRegistry.ContractState.NEW_DEPLOYMENT &&
state != IAttackRegistry.ContractState.ATTACK_REQUESTED)
) {
revert WithdrawsDisabled();
}
...
}

Risk

Likelihood:

  • The scope mutable window and the deposit window overlap by design — both are open during NOT_DEPLOYED and NEW_DEPLOYMENT, this means that any scope changes made after a staker has already committed funds rebinds them, whether or not that was the intent.

  • setPoolScope runs _observePoolState() and then only checks scopeLocked. A scope change that occurs in the block or period before the UNDER_ATTACK state is reached (which is allowed) leaves the staker no time in which to react. Withdrawals are blocked once the state transitions.

  • Stakers cannot defend by monitoring, because the change and the withdraw-lock can fall in the same block. It is intended and allowed behavior for a moderator to be able to change scope until locked.

I go for a medium likelihood simply because for the loss of funds to occur, the eventual state has to be corrupted.

From codehawks docs
Medium: Requires specific conditions (unusual token parameters)

Impact:

  • The staker's risk profile changes without their consent. This means that they end up insuring or putting confidence in contracts they did not choose, and cannot exit.

  • When the new scope is breached, the pool resolves CORRUPTED and the staker's full principal is swept to recoveryAddress.

  • The protocol's own deisgn doc section 8 guarantee ("bounded by what they signed up for at deposit time") is broken.

I go for a medium impact as well.

From codehawks docs on severity
Medium Impact: Indirect fund risk or moderate functionality disruption.

Therefore a medium severity issue.

Proof of Concept

Drop the constant and the two test functions inside ConfidencePool.t.sol.
To run both tests:
forge test --match-contract ConfidencePoolTest --match-test "test_sponsorSwapsScopeAfterDepositAndTrapsStaker" -vv
forge test --match-contract ConfidencePoolTest --match-test "test_trapLeadsToPrincipalLossOnUnwantedScope" -vv

Both cases pass: the staker is trapped, and loss of principal on a scope they never put confidence to.

// POC details: pool scope can change after a staker deposits, locking them into coverage they never
// agreed to. `SCOPE_SWAP_UNWANTED` is the contract swapped in behind the staker's back in these two POC's
address internal constant SCOPE_SWAP_UNWANTED = address(0x123456);
function test_sponsorSwapsScopeAfterDepositAndTrapsStaker() external {
// Alice deposits for the scope she verified
assertEq(pool.getScopeAccounts()[0], DEFAULT_SCOPE_ACCOUNT);
_stake(alice, 100 * ONE);
assertEq(pool.scopeLocked(), false);
// Scope is replaced with a contract Alice never consented to
agreementContract.setContractInScope(SCOPE_SWAP_UNWANTED, true);
address[] memory swapped = new address[](1);
swapped[0] = SCOPE_SWAP_UNWANTED;
pool.setPoolScope(swapped);
assertTrue(pool.isAccountInScope(SCOPE_SWAP_UNWANTED));
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
// Registry enters UNDER_ATTACK state where the withdrawal becomes locked
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
// This essentially blocks alice from withdrawing
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
assertEq(pool.scopeLocked(), true);
assertEq(pool.eligibleStake(alice), 100 * ONE); // fully staked, no way out
}
function test_trapLeadsToPrincipalLossOnUnwantedScope() external {
// Alice stakes with the original scope and it gets swapped out
assertEq(pool.getScopeAccounts()[0], DEFAULT_SCOPE_ACCOUNT);
_stake(alice, 100 * ONE);
agreementContract.setContractInScope(SCOPE_SWAP_UNWANTED, true);
address[] memory swapped = new address[](1);
swapped[0] = SCOPE_SWAP_UNWANTED;
pool.setPoolScope(swapped);
// UNDER_ATTACK state is enterred essentially blocking withdrawals
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.UNDER_ATTACK
);
pool.pokeRiskWindow();
// The unwanted contract is breached and state set to `CORRUPTED` meaning the pool sweeps to recovery
attackRegistry.setAgreementState(
IAttackRegistry.ContractState.CORRUPTED
);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
// Alice's principal is gone, on a breach of a contract she never agreed to insure or put confidence in
assertEq(token.balanceOf(recovery) - recoveryBefore, 100 * ONE);
}

Recommended Mitigation

Some options would be:

  • Freeze the scope at the first stake, exactly as the deadline is frozen. expiryLocked already flips on the first deposit and never resets, so it is the natural "someone has committed funds" signal to gate scope changes on.

  • If the sponsor needs to keep adjusting scope while the pool is still filling, use a grace window instead: record the timestamp of the last setPoolScope and allow withdraw() for a fixed period after it even once the risk window has opened, so a staker always has time to exit a scope they didn't sign up for.

Support

FAQs

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

Give us feedback!