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

Scope-lock latch ignores registry resets, permanently disabling the sponsor's documented ability to update pool scope while the agreement is in a pre-attack state

Author Revealed upon completion

Description:

ConfidencePool gates scope changes on a one-way scopeLocked latch. The latch has exactly one write site - it is only ever set to true, and no code path ever clears it - inside _observePoolState, on the first interaction that observes the registry in any state other than NOT_DEPLOYED / NEW_DEPLOYMENT:

if (!scopeLocked && state != NOT_DEPLOYED && state != NEW_DEPLOYMENT) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}

setPoolScope is blocked by that latch:

function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

The pool's documented contract to the sponsor is explicit that scope is mutable exactly while the registry is in a pre-attack state (docs/DESIGN.md §8):

"The sponsor can update scope freely while the registry is in NOT_DEPLOYED / NEW_DEPLOYMENT (pre-attack staging). Scope locks permanently on the first interaction observing any other state."

The bug is a state-machine modeling defect: the pool treats "past pre-attack staging" as a monotonic, forward-only condition and therefore latches irreversibly. But the external BattleChain AttackRegistry - which the pool explicitly reads as its source of truth — is not forward-only. Its registry-moderator can move an agreement backward to a fully fresh, unregistered state via rejectAttackRequest, which deletes the agreement record and returns it to NOT_DEPLOYED:

function rejectAttackRequest(address agreementAddress, bool slashBond) external onlyRegistryModerator {
ContractState currentState = _getAgreementState(agreementAddress);
if (currentState != ContractState.ATTACK_REQUESTED) revert AttackRegistry__InvalidState(currentState);
...
emit AgreementStateChanged(agreementAddress, ContractState.NOT_DEPLOYED);
delete s_agreementInfo[agreementAddress]; // agreement fully un-registered → NOT_DEPLOYED (mutable again)
...
}

rejectAttackRequest is a legitimate, expected, non-adversarial registry operation - the standard "the DAO reviewed this attack request and declined it" path. It carries no malicious intent and is not an error. Yet its normal use, combined with any prior pool interaction that observed ATTACK_REQUESTED (staking, a pokeRiskWindow, or any other call - an entirely ordinary occurrence), leaves the pool permanently inconsistent: the registry reports the agreement as NOT_DEPLOYED — the exact state in which the documentation guarantees scope is editable - while scopeLocked stays true forever, so every setPoolScope call reverts ScopePostLockImmutable.

The affected party is the pool sponsor, who is a legitimate protocol participant. The sponsor is entitled, by the protocol's own documented state rules, to reconfigure scope while the agreement sits in pre-attack staging (e.g. to realign the pool with a re-requested attack whose BattleChain scope was adjusted after the rejection). The protocol advertises that capability as a function of the current on-chain state, and the on-chain state grants it - but the code denies it. A documented protocol function is thus rendered permanently inoperable under a legitimate flow, and there is no on-chain remedy: no function can clear scopeLocked, so the condition is irreversible for the life of that pool.

Scenario:

  1. A sponsor creates a pool against an agreement and commits to scope {ContractA}. Stakers deposit.

  2. The sponsor calls requestUnderAttack; the registry enters ATTACK_REQUESTED. An ordinary pool interaction (a stake, a pokeRiskWindow, or any observing call) latches scopeLocked = true.

  3. The DAO (registry moderator) reviews the request and legitimately declines it via rejectAttackRequest, which deletes the agreement record and returns the registry to NOT_DEPLOYED.

  4. The registry is now in NOT_DEPLOYED — the state in which docs/DESIGN.md §8 guarantees the sponsor can update scope. The sponsor calls setPoolScope to adjust coverage.

  5. The call reverts ScopePostLockImmutable, because scopeLocked was never cleared. The sponsor cannot reconfigure the pool for its now-fresh agreement, and no on-chain action can restore the capability; the pool is permanently stuck with its pre-reset scope.

Impact:

  • A documented protocol function (setPoolScope, mutable while pre-attack per §8) is permanently disabled for the affected pool, under a legitimate, non-adversarial flow (a normal pool observation plus a routine DAO rejection). No admin error and no malice are involved.

  • The pool sponsor - a legitimate protocol participant - is denied a capability the current on-chain registry state explicitly grants, with no on-chain recovery (the latch is irreversible).

  • Pool coverage can permanently diverge from the underlying agreement after a reject-and-re-register cycle, leaving the pool committed to a scope the sponsor can no longer bring back into alignment.

PoC:

  1. Create a test/fork/ScopeLockLatchIgnoresRegistryReset.fork.t.sol file

  2. Put the code below in the file

  3. Run BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test --match-test ScopeLatchStaysLockedAfterRegistryResetBlockingSetPoolScopeForever -vv in the terminal

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {
ERC1967Proxy
} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {IAgreementFactory} from "@battlechain/interface/IAgreementFactory.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {
AgreementDetails,
Chain as BcChain,
Account as BcAccount,
Contact as BcContact,
BountyTerms as BcBountyTerms,
ChildContractScope,
IdentityRequirements
} from "@battlechain/types/AgreementTypes.sol";
contract Verify_M_ScopeLockLatchIgnoresRegistryResetForkTest is Test {
// Live BattleChain testnet (chainId 627) deployments.
address internal constant SAFE_HARBOR_REGISTRY =
0x0a652e265336a0296816aC4D8400880e3E537C24;
address internal constant ATTACK_REGISTRY =
0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address internal constant AGREEMENT_FACTORY =
0x2Bee2970f10FDc2aeA28662BB6F6A501278Ebd46;
// AttackRegistry.getRegistryModerator() at the pinned block: the DAO that can rejectAttackRequest.
address internal constant REGISTRY_MODERATOR =
0x1bC64E6F187a47D136106784f4E9182801535BD3;
string internal constant BATTLECHAIN_CAIP2 = "eip155:627";
uint256 internal constant PIN_BLOCK = 17000;
uint256 internal constant ONE = 1e18;
address internal sponsor = makeAddr("sponsor"); // agreement owner == pool owner
address internal staker = makeAddr("staker");
address internal poolModerator = makeAddr("poolModerator");
address internal recovery = makeAddr("recovery");
address internal contractA = makeAddr("contractA"); // initial pool scope
address internal contractB = makeAddr("contractB"); // the sponsor's intended, blocked re-scope
ConfidencePoolFactory internal factory;
MockERC20 internal stakeToken;
function setUp() public {
try vm.envString("BATTLECHAIN_TESTNET_RPC") returns (string memory) {
vm.createSelectFork("battlechain_testnet", PIN_BLOCK);
} catch {
vm.skip(true);
}
}
function test_ScopeLatchStaysLockedAfterRegistryResetBlockingSetPoolScopeForever()
external
{
// Stage 1: create a REAL agreement covering both A and B; deploy the in-scope pool stack and
// create a pool committing to scope {A}.
address agreement = _createAgreement();
vm.prank(sponsor);
IAgreement(agreement).extendCommitmentWindow(block.timestamp + 8 days); // >= MIN_COMMITMENT (7d)
_deployConfidencePoolStack();
address[] memory scopeA = new address[](1);
scopeA[0] = contractA;
vm.prank(sponsor);
ConfidencePool pool = ConfidencePool(
factory.createPool(
agreement,
address(stakeToken),
block.timestamp + 60 days,
ONE,
recovery,
scopeA
)
);
// Stage 2: the sponsor requests attack mode via the real registry -> ATTACK_REQUESTED.
vm.prank(sponsor);
IAttackRegistry(ATTACK_REGISTRY)
.requestUnderAttackForUnverifiedContracts(agreement);
assertEq(
uint256(
IAttackRegistry(ATTACK_REGISTRY).getAgreementState(agreement)
),
uint256(IAttackRegistry.ContractState.ATTACK_REQUESTED),
"registry is ATTACK_REQUESTED"
);
// Stage 3: an ordinary pool interaction while ATTACK_REQUESTED latches scopeLocked = true.
// Staking is permitted in ATTACK_REQUESTED, and `_observePoolState` sets the one-way latch.
uint256 principal = 100 * ONE;
stakeToken.mint(staker, principal);
vm.startPrank(staker);
stakeToken.approve(address(pool), principal);
pool.stake(principal);
vm.stopPrank();
assertTrue(
pool.scopeLocked(),
"scope latched on observing ATTACK_REQUESTED"
);
// Stage 4: the DAO legitimately declines the attack request via rejectAttackRequest, which
// deletes the agreement record and returns the registry to a fresh NOT_DEPLOYED state.
vm.prank(REGISTRY_MODERATOR);
IAttackRegistry(ATTACK_REGISTRY).rejectAttackRequest(agreement, false);
assertEq(
uint256(
IAttackRegistry(ATTACK_REGISTRY).getAgreementState(agreement)
),
uint256(IAttackRegistry.ContractState.NOT_DEPLOYED),
"registry reset back to NOT_DEPLOYED (scope editable per docs)"
);
// The inconsistency: registry says pre-attack (mutable), the pool's latch says locked forever.
assertTrue(
pool.scopeLocked(),
"latch was never cleared despite the registry reset"
);
assertTrue(
IAgreement(agreement).isContractInScope(contractB),
"B is a valid re-scope target"
);
// Stage 5 (harm): the sponsor tries to reconfigure scope while the registry is NOT_DEPLOYED —
// a capability the on-chain state and docs both grant — but the stale latch reverts the call,
// permanently, with no on-chain way to clear it.
address[] memory scopeB = new address[](1);
scopeB[0] = contractB;
vm.prank(sponsor);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
pool.setPoolScope(scopeB);
// Confirm the pool is stuck with the pre-reset scope: A still in, B still excluded.
assertTrue(
pool.isAccountInScope(contractA),
"HARM: pool frozen on pre-reset scope {A}"
);
assertFalse(
pool.isAccountInScope(contractB),
"HARM: sponsor can never realign scope to {B}"
);
}
function _deployConfidencePoolStack() internal {
ConfidencePool poolImpl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(SAFE_HARBOR_REGISTRY, address(poolImpl), poolModerator)
)
);
factory = ConfidencePoolFactory(address(proxy));
stakeToken = new MockERC20();
factory.setStakeTokenAllowed(address(stakeToken), true);
}
function _createAgreement() internal returns (address agreement) {
BcAccount[] memory accounts = new BcAccount[](2);
accounts[0] = BcAccount({
accountAddress: vm.toString(contractA),
childContractScope: ChildContractScope.None
});
accounts[1] = BcAccount({
accountAddress: vm.toString(contractB),
childContractScope: ChildContractScope.None
});
BcChain[] memory chains = new BcChain[](1);
chains[0] = BcChain({
assetRecoveryAddress: vm.toString(recovery),
accounts: accounts,
caip2ChainId: BATTLECHAIN_CAIP2
});
AgreementDetails memory details = AgreementDetails({
protocolName: "PoC Protocol",
contactDetails: new BcContact[](0),
chains: chains,
bountyTerms: BcBountyTerms({
bountyPercentage: 10,
bountyCapUsd: 0,
retainable: true,
identity: IdentityRequirements.Anonymous,
diligenceRequirements: "",
aggregateBountyCapUsd: 0
}),
agreementURI: "ipfs://poc"
});
agreement = IAgreementFactory(AGREEMENT_FACTORY).create(
details,
sponsor,
keccak256("poc-scopelock-salt")
);
}
}

Recommended Mitigation:

Make the latch bidirectional: have _observePoolState clear scopeLocked when it observes a return to NOT_DEPLOYED / NEW_DEPLOYMENT, keeping it in lock-step with the registry's actual (non-monotonic) state machine.

Support

FAQs

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

Give us feedback!