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

A locked pool can stop tracking a covered account when the Agreement commitment ends first

Author Revealed upon completion

Root + Impact

Description

A ConfidencePool stores a list of covered accounts and one Agreement address. Once the pool observes that the Agreement has moved past its initial state, the pool locks its local scope so the sponsor cannot remove any covered account.

The Agreement has a separate commitment period. The Agreement owner cannot reduce its scope during this period, but the restriction ends at cantChangeUntil:

// lib/battlechain-safe-harbor-contracts/src/Agreement.sol
function removeAccounts(string memory caip2ChainId, string[] memory accountAddresses) external onlyOwner {
if (block.timestamp < s_cantChangeUntil) {
revert Agreement__CannotReduceScopeDuringCommitment();
}
// ...
}

The pool never checks that the Agreement commitment lasts until the pool expiry. Both initialize() and setExpiry() accept an expiry without comparing it to getCantChangeUntil():

// src/ConfidencePool.sol
function initialize(
address agreement_,
address stakeToken_,
address safeHarborRegistry_,
address outcomeModerator_,
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_,
address owner_,
address[] calldata accounts
) external initializer {
// ...
if (expiry_ < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (expiry_ > type(uint32).max) revert ExpiryTooFar();
agreement = agreement_;
expiry = uint32(expiry_); // @> No check against the Agreement commitment
_replaceScope(accounts);
// ...
}
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
expiry = uint32(newExpiry); // @> The new expiry may outlive the commitment
}

This allows the following setup:

Agreement A scope: [X, Y]
Agreement commitment: 7 days
Pool scope: [X]
Pool expiry: 30 days
Staker principal: 100 tokens
Pool bonus: 50 tokens

The pool can lock X while X belongs to Agreement A. After day 7, the Agreement owner can remove X from A even though the pool remains active for another 23 days. Removing X deletes its registry link to A:

// lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol
function unregisterContractForExistingAgreement(address contractAddress) external {
// ...
delete s_contractToAgreement[contractAddress]; // @> X is no longer linked to A
emit ContractUnregistered(contractAddress, msg.sender);
}

X can then be registered under Agreement B. The pool still lists X in its locked scope, but it never follows X to B. Every state check continues to use the original Agreement A:

// src/ConfidencePool.sol
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
// @> The pool always reads A, even after X has been moved to B
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}

When X is exploited, Agreement B becomes CORRUPTED, while Agreement A can remain UNDER_ATTACK. The pool moderator cannot correct the outcome because flagOutcome(CORRUPTED) requires the pool's original Agreement A to be corrupted:

// src/ConfidencePool.sol
if (newOutcome == PoolStates.Outcome.CORRUPTED) {
// ...
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
}

At expiry, claimExpired() sees A as UNDER_ATTACK and resolves the pool as EXPIRED. The staker receives the principal and bonus even though X, which is still shown in the pool's locked scope, was breached under B.

Risk

The design documentation partially acknowledges the stale-reference condition. Its scope section titled "**Scope is a fixed, pool-local commitment" **states that narrowing the Agreement may leave the pool's locked scope referencing accounts that are no longer in that Agreement, but claims that “the pool's own commitment to stakers remains the binding source of truth.”

The implementation does not enforce that guarantee. It preserves X only as scope metadata, while resolution continues to depend exclusively on Agreement A's registry state. Once X is rebound to Agreement B, a breach of X corrupts B—not A—so neither the moderator nor the expiry backstop can apply the economic consequence associated with covering X. Therefore, the stale reference itself is documented, but its consequence incorrect settlrement and loss of the whitehat's pool bounty is not.

Likelihood: Medium

  • The issue is reachable whenever the pool expiry is later than the Agreement commitment.

  • The Agreement owner can remove and rebind X through normal protocol functions after the commitment ends.

  • A wrong payout requires X to be breached after it has been moved to another Agreement.

Impact: Medium

  • The pool's locked coverage becomes ineffective for X.

  • The moderator cannot record the correct CORRUPTED outcome.

  • The entire pool can be paid to stakers instead of the good-faith whitehat.

Proof of Concept

Add the following test to test/unit/ScopeCommitmentDrift.t.sol and run:

forge test --match-contract ScopeCommitmentDriftTest -vv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
/// @dev The project's normal registry mock stores only one global state. This small test double
/// stores one state per Agreement so the PoC can model A remaining UNDER_ATTACK while B is
/// CORRUPTED, as the real AttackRegistry does.
contract PerAgreementAttackRegistryMock {
mapping(address agreement => IAttackRegistry.ContractState state) internal states;
function setAgreementState(address agreement, IAttackRegistry.ContractState state) external {
states[agreement] = state;
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return states[agreement];
}
}
contract ScopeCommitmentDriftTest is BaseConfidencePoolTest {
function testPoC_LockedScopeCanMoveToAnotherAgreementBeforePoolExpiry() external {
PerAgreementAttackRegistryMock registry = new PerAgreementAttackRegistryMock();
safeHarborRegistry.setAttackRegistry(address(registry));
MockAgreement agreementB = new MockAgreement(address(this));
// Alice underwrites X for the full pool term and the sponsor contributes the bonus.
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
// Agreement A becomes attackable. Observing it locks X into the pool's local scope.
registry.setAgreementState(agreement, IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
assertTrue(pool.scopeLocked());
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT));
// The Agreement commitment ends before the pool. The real Agreement/AttackRegistry flow
// now permits X to be removed from A (deleting its X -> A binding) and registered under B.
vm.warp(BASE_TIMESTAMP + 7 days);
agreementContract.setContractInScope(DEFAULT_SCOPE_ACCOUNT, false);
agreementB.setContractInScope(DEFAULT_SCOPE_ACCOUNT, true);
// X is exploited under B. B becomes CORRUPTED, but A remains UNDER_ATTACK.
registry.setAgreementState(address(agreementB), IAttackRegistry.ContractState.CORRUPTED);
assertTrue(pool.isAccountInScope(DEFAULT_SCOPE_ACCOUNT), "pool still promises to cover X");
assertFalse(agreementContract.isContractInScope(DEFAULT_SCOPE_ACCOUNT), "A no longer covers X");
assertTrue(agreementB.isContractInScope(DEFAULT_SCOPE_ACCOUNT), "B now covers X");
assertEq(uint256(registry.getAgreementState(agreement)), uint256(IAttackRegistry.ContractState.UNDER_ATTACK));
assertEq(
uint256(registry.getAgreementState(address(agreementB))), uint256(IAttackRegistry.ContractState.CORRUPTED)
);
// The pool moderator cannot record X's corruption because the pool only reads A.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker);
// At pool expiry, claimExpired still sees A as UNDER_ATTACK and resolves EXPIRED.
// Alice receives her 100-token stake plus the complete 50-token bonus, while the
// whitehat for X receives nothing from this pool.
vm.warp(pool.expiry());
uint256 aliceBefore = token.balanceOf(alice);
vm.prank(alice);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(alice) - aliceBefore, 150 * ONE, "staker receives stake plus bonus");
assertEq(token.balanceOf(attacker), 0, "whitehat receives no pool bounty");
}
}

The test passes and shows that the staker receives all 150 tokens while the whitehat receives 0.

Recommended Mitigation

Require the Agreement commitment to cover the complete pool lifetime:

uint256 cantChangeUntil = IAgreement(agreement).getCantChangeUntil();
if (cantChangeUntil < poolExpiry) revert AgreementCommitmentTooShort();

Support

FAQs

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

Give us feedback!