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

Rejected attack request rolls back scope lock and allows staked pool scope replacement

Author Revealed upon completion

Summary

ConfidencePool is intended to publish a pool-local scope and then make that scope immutable once the BattleChain registry leaves pre-attack staging. That commitment matters because stakers deposit against a specific set of in-scope BattleChain accounts, and a later CORRUPTED outcome can sweep their principal and bonus to the sponsor-controlled recovery address.

The issue is that the permissionless observer path can see ATTACK_REQUESTED, set scopeLocked = true, and then revert in pokeRiskWindow(). The revert rolls back the lock. When the upstream attack request is later rejected, the real BattleChain registry returns the agreement to NOT_DEPLOYED, leaving the pool with live stake but no persisted scope lock. A sponsor can then replace the original scope and expose existing stakers to a different account set than the one they deposited against. This is a Critical candidate only under a rubric that treats sponsor-enabled full affected-pool loss from a broken scope commitment as Critical; otherwise it should be treated as a high-impact integrity issue with a sponsor-action precondition.

Affected Contract

File: src/ConfidencePool.sol

Lines: 636-640

Function/Type: setPoolScope(address[])

File: src/ConfidencePool.sol

Lines: 649-657

Function/Type: pokeRiskWindow()

File: src/ConfidencePool.sol

Lines: 749-776

Function/Type: _replaceScope(address[])

File: src/ConfidencePool.sol

Lines: 784-799

Function/Type: _observePoolState()

File: lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol

Lines: 350-376

Function/Type: rejectAttackRequest(address,bool) integration behavior

Vulnerability Details

_observePoolState() locks the pool scope whenever the registry state is no longer NOT_DEPLOYED or NEW_DEPLOYMENT. This includes ATTACK_REQUESTED, because the agreement has already left pre-attack staging.

pokeRiskWindow() calls _observePoolState(), but then reverts with RiskWindowNotReached whenever neither riskWindowStart nor riskWindowEnd was sealed. During ATTACK_REQUESTED, _observePoolState() only performs the scope lock. It does not open the risk window because ATTACK_REQUESTED is not an active-risk state. The subsequent revert in pokeRiskWindow() therefore rolls back the only useful state write, leaving scopeLocked == false.

This creates a mismatch with the upstream registry lifecycle. The real AttackRegistry.rejectAttackRequest() clears the agreement information and emits NOT_DEPLOYED. Once that happens, setPoolScope() observes a pre-attack state again. Because the attempted ATTACK_REQUESTED lock was reverted, setPoolScope() does not hit ScopePostLockImmutable() and proceeds to _replaceScope(). The sponsor can remove the original scope and install a replacement scope while existing stakers remain in the pool.

The loss path is not immediate at the replacement transaction. The loss occurs when the replacement scope later enters an attack lifecycle and the pool is resolved as CORRUPTED. At that point, claimCorrupted() sweeps the full pool balance, including the existing stakers' principal and bonus, to recoveryAddress.

Root Cause

The root cause is that pokeRiskWindow() treats a scope-only observation as failure and reverts after _observePoolState() has set scopeLocked. The revert undoes the lock even though observing ATTACK_REQUESTED should permanently freeze the pool scope.

// File: src/ConfidencePool.sol:649-657
function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
_observePoolState();
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}
// File: src/ConfidencePool.sol:784-799
function _observePoolState() internal returns (IAttackRegistry.ContractState state) {
state = _getAgreementState();
if (
!scopeLocked && state != IAttackRegistry.ContractState.NOT_DEPLOYED
&& state != IAttackRegistry.ContractState.NEW_DEPLOYMENT
) {
scopeLocked = true;
emit ScopeLocked(block.timestamp);
}
if (riskWindowStart == 0 && _isActiveRiskState(state)) {
_markRiskWindowStart();
}
if (riskWindowEnd == 0 && _isTerminalState(state)) {
_markRiskWindowEnd();
}
}
// File: src/ConfidencePool.sol:636-640
function setPoolScope(address[] calldata accounts) external onlyOwner {
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}
// File: src/ConfidencePool.sol:749-776
function _replaceScope(address[] calldata accounts) internal {
if (accounts.length == 0) revert EmptyScope();
address[] memory old = _scopeAccounts;
for (uint256 i; i < old.length; ++i) {
isAccountInScope[old[i]] = false;
}
delete _scopeAccounts;
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
emit ScopeUpdated(accounts);
}
// File: lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol:350-376
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];
}

Attack Vectors

Attack Vector 1: Scope replacement after rejected attack request

Step 1 Stakers deposit into a pool after reviewing the original pool scope.
Step 2 The BattleChain registry moves the agreement to ATTACK_REQUESTED.
Step 3 A keeper or staker calls pokeRiskWindow() to persist the scope lock, but the call reverts and the lock is rolled back.
Step 4 The upstream registry rejects the attack request and returns the agreement to NOT_DEPLOYED.
Result: The sponsor can call setPoolScope() and replace the already-staked pool scope.

Attack Vector 2: Full pool sweep after replacement scope corrupts

Step 1 The sponsor replaces the original scope after the rejected request.
Step 2 The replacement scope later moves through UNDER_ATTACK.
Step 3 The replacement scope reaches CORRUPTED, and the moderator flags the pool as bad-faith CORRUPTED.
Step 4 Any caller executes claimCorrupted().
Result: The full pool balance, including principal deposited against the original scope and bonus funds, is swept to recoveryAddress.

Execution Flow

  • A sponsor creates a pool with an initial scope.

  • Stakers deposit principal and bonus contributors fund the bonus pool.

  • The registry reaches ATTACK_REQUESTED, which should permanently lock the scope.

  • pokeRiskWindow() calls _observePoolState().

  • _observePoolState() sets scopeLocked = true.

  • pokeRiskWindow() then reverts with RiskWindowNotReached because no active-risk or terminal marker was sealed.

  • The revert rolls back scopeLocked.

  • The upstream registry rejects the attack request and emits NOT_DEPLOYED.

  • The sponsor calls setPoolScope() while scopeLocked is still false.

  • _replaceScope() removes the original scope and installs the replacement scope.

  • A later CORRUPTED lifecycle for the replacement scope allows claimCorrupted() to sweep stake and bonus to recovery.

Impact

The practical impact is a broken scope commitment for already-staked pools. Existing stakers can be made to back a different set of BattleChain accounts than the set they evaluated when depositing. If the replacement scope later corrupts, stakers can lose their full principal, and bonus contributors can lose the full bonus pool, through the normal CORRUPTED sweep path.

The blast radius is one affected pool at a time, but the loss within that pool can be complete. The attacker cost is low for a malicious or opportunistic sponsor once the upstream request is rejected. The important precondition is sponsor participation: this is not a fully permissionless theft path. It is strongest as a Critical candidate when the judging rubric treats sponsor-enabled replacement of a staked risk scope followed by full affected-pool loss as Critical.

Validation steps

Standalone Validation

Framework used: Foundry / forge

Validation Test File

File 1: test/poc/ConfidencePoolScopeLockPoC.t.sol

function testPoC_rejectedAttackRequestLetsSponsorReplaceStakedPoolScope() external {
agreementContract.setContractInScope(ORIGINAL_SCOPE, true);
agreementContract.setContractInScope(REPLACEMENT_SCOPE, true);
address[] memory originalScope = new address[](1);
originalScope[0] = ORIGINAL_SCOPE;
pool.setPoolScope(originalScope);
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
assertTrue(pool.isAccountInScope(ORIGINAL_SCOPE));
assertFalse(pool.isAccountInScope(REPLACEMENT_SCOPE));
assertFalse(pool.scopeLocked());
attackRegistry.setAgreementState(IAttackRegistry.ContractState.ATTACK_REQUESTED);
vm.expectRevert(IConfidencePool.RiskWindowNotReached.selector);
pool.pokeRiskWindow();
assertFalse(pool.scopeLocked(), "reverted poke rolled back scope lock");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
address[] memory replacementScope = new address[](1);
replacementScope[0] = REPLACEMENT_SCOPE;
pool.setPoolScope(replacementScope);
assertFalse(pool.isAccountInScope(ORIGINAL_SCOPE), "original commitment removed");
assertTrue(pool.isAccountInScope(REPLACEMENT_SCOPE), "replacement commitment installed");
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "stake and bonus swept");
assertEq(token.balanceOf(alice), 0, "original staker receives nothing");
}

How to Run

forge test --match-path test/poc/ConfidencePoolScopeLockPoC.t.sol -vvv

Captured Test Output

No files changed, compilation skipped
2026-07-10T02:43:12.841694Z WARN evm::traces::external: etherscan config not found
Ran 1 test for test/poc/ConfidencePoolScopeLockPoC.t.sol:ConfidencePoolScopeLockPoC
[PASS] testPoC_rejectedAttackRequestLetsSponsorReplaceStakedPoolScope() (gas: 661010)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.55ms (1.03ms CPU time)
Ran 1 test suite in 45.59ms (5.55ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Log-to-Impact Walkthrough

The PoC first proves that pokeRiskWindow() cannot persist the ATTACK_REQUESTED scope lock: after the expected revert, scopeLocked() is still false. It then returns the mock registry to NOT_DEPLOYED, matching the upstream rejectAttackRequest() behavior, and shows that the sponsor can replace the original scope.

The final lifecycle moves the replacement scope through UNDER_ATTACK and CORRUPTED, flags the pool as bad-faith CORRUPTED, and calls claimCorrupted(). The recovery address receives 150 * ONE, which is the full pool value in the test: Alice's 100 * ONE stake plus Carol's 50 * ONE bonus. Alice receives nothing even though she deposited before the scope replacement.

Key Assertions Proven

assertFalse(pool.scopeLocked(), "reverted poke rolled back scope lock") -> proves the scope lock is not persisted during ATTACK_REQUESTED -> PASS
assertFalse(pool.isAccountInScope(ORIGINAL_SCOPE), "original commitment removed") -> proves the original staked scope can be removed -> PASS
assertTrue(pool.isAccountInScope(REPLACEMENT_SCOPE), "replacement commitment installed") -> proves a replacement scope can be installed for the same live pool -> PASS
assertEq(token.balanceOf(recovery) - recoveryBefore, 150 * ONE, "stake and bonus swept") -> proves the later CORRUPTED lifecycle can sweep the full pool value -> PASS
assertEq(token.balanceOf(alice), 0, "original staker receives nothing") -> proves the original staker loses their deposited principal in the demonstrated lifecycle -> PASS

Recommended Fix

Make pokeRiskWindow() persist scope-only observations instead of reverting after _observePoolState() locks the scope. The function should only revert when absolutely no relevant state was changed.

function pokeRiskWindow() external {
if (outcome != PoolStates.Outcome.UNRESOLVED) return;
+ bool wasScopeLocked = scopeLocked;
_observePoolState();
+ if (!wasScopeLocked && scopeLocked) return;
if (riskWindowStart == 0 && riskWindowEnd == 0) revert RiskWindowNotReached();
}

Add a second hardening check that disallows scope replacement once any stake has entered the pool. This protects stakers even when upstream registry state later returns to a staging state.

function setPoolScope(address[] calldata accounts) external onlyOwner {
+ if (totalEligibleStake != 0) revert ScopePostStakeImmutable();
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable();
_replaceScope(accounts);
}

Support

FAQs

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

Give us feedback!