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

Resolved pools can rewrite their binding scope and corrupt the settlement audit trail

Author Revealed upon completion

Root + Impact

Description

When a pool expires while the registry remains in a pre-deployment state, claimExpired() finalizes the outcome without locking scope. The owner can subsequently replace the scope even though settlement is final, causing the canonical getters to publish a different coverage commitment from the one that existed when the outcome was resolved.

The pool scope is the public commitment used to determine which BattleChain accounts the pool covers. Although the moderator performs the in-scope breach assessment off-chain, the stored scope is intended to remain the binding audit trail for that assessment and the resulting pool outcome.

Scope normally becomes immutable when _observePoolState() sees a registry state beyond NOT_DEPLOYED or NEW_DEPLOYMENT. However, a pool can reach expiry while the registry is still in either pre-deployment state. In that case, claimExpired() resolves the pool as EXPIRED and sets claimsStarted = true, but _observePoolState() deliberately leaves scopeLocked == false.

setPoolScope() checks only scopeLocked; it does not reject calls after outcome or claimsStarted has become final. The owner can therefore replace _scopeAccounts after settlement and after claims have begun. getScopeAccounts() and isAccountInScope then expose the replacement as the pool's current scope, rather than the scope committed when the outcome was resolved.

Historical ScopeUpdated events allow a careful indexer to reconstruct the former scope, and the rewrite does not change snapshotted balances or claim rights. The defect nevertheless breaks outcome finality for the protocol's canonical scope state and can cause explorers, indexers, auditors, or governance processes that read the current getters to attribute a settled outcome to the wrong covered contracts.

src/ConfidencePool.sol
function claimExpired() external nonReentrant {
if (block.timestamp < expiry) revert PoolNotExpired();
if (outcome != PoolStates.Outcome.UNRESOLVED && outcome != PoolStates.Outcome.EXPIRED) {
revert InvalidOutcome();
}
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
// ... snapshot assignments omitted ...
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(address(0), PoolStates.Outcome.SURVIVED, false, address(0));
} else {
outcome = PoolStates.Outcome.EXPIRED; // @> Pre-deployment state finalizes the outcome.
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}
claimsStarted = true; // @> Outcome finality is recorded, but scope remains unlocked.
}
// ... claimant accounting and payout omitted ...
if (outcome == PoolStates.Outcome.SURVIVED) {
emit ClaimSurvived(msg.sender, userEligible, bonusShare);
} else {
emit ClaimExpired(msg.sender, userEligible, bonusShare);
}
}
function setPoolScope(address[] calldata accounts) external onlyOwner {
// aderyn-ignore-next-line(unchecked-return)
_observePoolState();
if (scopeLocked) revert ScopePostLockImmutable(); // @> No outcome/claimsStarted finality guard.
_replaceScope(accounts); // @> Rewrites the published scope after settlement.
}

Risk

Likelihood:

  • The pool reaches expiry while its agreement still reports NOT_DEPLOYED or NEW_DEPLOYMENT, so mechanical resolution does not set scopeLocked.

  • The pool owner submits a later scope update containing accounts that remain valid in the underlying agreement. No registry rewind or unprivileged exploit is required.

Impact:

  • The latest getScopeAccounts() and isAccountInScope values can describe a different coverage commitment from the one present when the final outcome and accounting snapshots were established.

  • Historical consumers may misattribute a settled outcome to the wrong contracts. Funds and claim calculations are unchanged, and event reconstruction remains possible, limiting the issue to Low severity.

Proof of Concept

Create test/audit/CP045ResolvedScopeRewrite.t.sol with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP045ResolvedScopeRewriteTest is BaseConfidencePoolTest {
address internal constant REPLACEMENT_SCOPE_ACCOUNT = address(0xBEEF);
function test_ResolvedPoolCanRewriteItsHistoricalScope() external {
agreementContract.setContractInScope(REPLACEMENT_SCOPE_ACCOUNT, true);
address[] memory replacementScope = new address[](1);
replacementScope[0] = REPLACEMENT_SCOPE_ACCOUNT;
// Existing principal is committed while the registry remains in pre-deployment staging.
_stake(alice, 100 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.NOT_DEPLOYED);
vm.warp(pool.expiry());
vm.prank(dave);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertTrue(pool.claimsStarted(), "mechanical resolution is final");
assertFalse(pool.scopeLocked(), "pre-deployment resolution leaves scope unlocked");
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "original settlement-time scope");
// The owner can rewrite the canonical scope after outcome finality.
pool.setPoolScope(replacementScope);
assertFalse(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "original scope was erased");
assertTrue(pool.isAccountInScope(REPLACEMENT_SCOPE_ACCOUNT), "replacement is now canonical");
address[] memory currentScope = pool.getScopeAccounts();
assertEq(currentScope.length, 1);
assertEq(currentScope[0], REPLACEMENT_SCOPE_ACCOUNT);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED), "outcome remains final");
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP045ResolvedScopeRewrite.t.sol -vv.

  2. Confirm that test_ResolvedPoolCanRewriteItsHistoricalScope() passes: the pool is already EXPIRED with claimsStarted == true, yet the owner replaces the original scope and the canonical getters expose only the replacement.

Recommended Mitigation

Reject scope replacement after any outcome has been resolved. This preserves the settlement-time scope even when the registry never left pre-deployment staging. Optionally introduce a separate scopeFinalized flag or set scopeLocked during resolution so the public lifecycle state also reflects finality.

src/ConfidencePool.sol
function setPoolScope(address[] calldata accounts) external onlyOwner {
+ if (outcome != PoolStates.Outcome.UNRESOLVED) revert OutcomeAlreadySet();
// aderyn-ignore-next-line(unchecked-return)
_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!